add toast notification system (T-50)

Non-modal operation-feedback toasts, bottom-right: a ClideToast card per
severity (success/warning/error/info), auto-dismiss (errors linger), queue
with a visible cap, slide+fade in, manual dismiss, live-region a11y.

ToastService is a MessageBus consumer — components raise a toast by publishing
to the 'toast' channel (publishToast helper), so emitters stay decoupled from
the UI. GitController's push/pull are the first emitters. ToastOverlay mounts
in the app-root Stack.

Also adds comprehensive GitController coverage: importing it for the toast
emitter test first pulled the whole file into the coverage denominator, so the
controller is now tested end to end (status/stage/commit/stash/push/pull).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-05 23:14:42 +02:00
co-authored by Claude Opus 4.8
parent 55ed3013e1
commit 1f09abcfd7
12 changed files with 747 additions and 4 deletions
+201
View File
@@ -0,0 +1,201 @@
/// Tests for GitController — the git sidebar's state model. Drives every
/// action against a stubbed DaemonClient (no real git), covering status
/// hydration + parsing, the stage/unstage/discard/commit/stash verbs, the
/// event-driven refresh, and the push/pull MessageBus toast emitters (T-50).
///
/// The KernelFixture is built in setUp (real file I/O — kept out of any
/// fake-async zone); these are plain async tests, so the toast auto-dismiss
/// Timers are real and cancelled by fixture dispose in tearDown.
library;
import 'package:clide/builtin/git/src/git_controller.dart';
import 'package:clide/clide.dart';
import 'package:clide/kernel/kernel.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../helpers/kernel_fixture.dart';
void main() {
late KernelFixture f;
setUp(() async => f = await KernelFixture.create());
tearDown(() async => f.dispose());
GitController controller() {
final c = GitController(ipc: f.ipc, events: f.services.events, messages: f.services.messages);
addTearDown(c.dispose);
return c;
}
IpcResponse ok([Map<String, Object?> data = const {}]) => IpcResponse.ok(id: '', data: data);
IpcResponse err(String message) => IpcResponse.err(
id: '',
error: IpcError(code: IpcExitCode.userError, kind: IpcErrorKind.userError, message: message),
);
// Let the broadcast streams (bus / events) deliver.
Future<void> settle() => Future<void>.delayed(Duration.zero);
group('load + status parsing', () {
test('hydrates branch / counts / file lists from git.status', () async {
f.ipc.stub(
'git.status',
(_) async => ok({
'branch': 'main',
'upstream': 'origin/main',
'ahead': 2,
'behind': 1,
'clean': false,
'hasConflicts': true,
'staged': [
{'path': 'a.dart'},
],
'unstaged': [
{'path': 'b.dart'},
],
'untracked': [
{'path': 'c.dart'},
],
'conflicted': [
{'path': 'd.dart'},
],
}));
final c = controller();
await c.load();
expect(c.loading, isFalse);
expect(c.branch, 'main');
expect(c.upstream, 'origin/main');
expect(c.ahead, 2);
expect(c.behind, 1);
expect(c.isClean, isFalse);
expect(c.hasConflicts, isTrue);
expect(c.staged.single['path'], 'a.dart');
expect(c.unstaged.single['path'], 'b.dart');
expect(c.untracked.single['path'], 'c.dart');
expect(c.conflicted.single['path'], 'd.dart');
});
test('defaults missing fields and tolerates a non-list payload', () async {
f.ipc.stub('git.status', (_) async => ok({'branch': 'dev', 'staged': 'not-a-list'}));
final c = controller();
await c.load();
expect(c.branch, 'dev');
expect(c.ahead, 0);
expect(c.behind, 0);
expect(c.isClean, isTrue);
expect(c.hasConflicts, isFalse);
expect(c.staged, isEmpty);
});
test('records an error when git.status fails', () async {
f.ipc.stub('git.status', (_) async => err('not a repo'));
final c = controller();
await c.load();
expect(c.loading, isFalse);
expect(c.error, 'not a repo');
});
});
group('staging verbs pass through ok', () {
test('stage / stageAll / unstage / discard', () async {
for (final cmd in ['git.stage', 'git.stage-all', 'git.unstage', 'git.discard']) {
f.ipc.stub(cmd, (_) async => ok());
}
final c = controller();
expect(await c.stage(['a']), isTrue);
expect(await c.stageAll(), isTrue);
expect(await c.unstage(['a']), isTrue);
expect(await c.discard(['a']), isTrue);
});
test('a failing verb returns false', () async {
f.ipc.stub('git.stage', (_) async => err('locked'));
expect(await controller().stage(['a']), isFalse);
});
});
group('commit', () {
test('returns the new hash on success', () async {
f.ipc.stub('git.commit', (args) async {
expect(args['message'], 'msg');
return ok({'hash': 'abc123'});
});
expect(await controller().commit('msg'), 'abc123');
});
test('returns null and records the error on failure', () async {
f.ipc.stub('git.commit', (_) async => err('nothing staged'));
final c = controller();
expect(await c.commit('msg'), isNull);
expect(c.error, 'nothing staged');
c.clearError();
expect(c.error, isNull);
});
});
group('stash', () {
test('omits the message arg when none is given', () async {
f.ipc.stub('git.stash', (args) async {
expect(args.containsKey('message'), isFalse);
return ok();
});
expect(await controller().stash(), isTrue);
});
test('passes the message arg when given', () async {
f.ipc.stub('git.stash', (args) async {
expect(args['message'], 'wip');
return ok();
});
expect(await controller().stash(message: 'wip'), isTrue);
});
});
group('push / pull raise toasts on the bus', () {
test('push success → success toast', () async {
f.ipc.stub('git.status', (_) async => ok({'upstream': 'origin/main'}));
f.ipc.stub('git.push', (_) async => ok());
final c = controller();
await c.load(); // sets _upstream so the message includes it
expect(await c.push(), isTrue);
await settle();
expect(f.services.toast.entries.any((e) => e.severity == ToastSeverity.success && e.message == 'Pushed to origin/main'), isTrue);
});
test('push failure → error toast + error state', () async {
f.ipc.stub('git.push', (_) async => err('rejected'));
final c = controller();
expect(await c.push(), isFalse);
expect(c.error, 'rejected');
await settle();
expect(f.services.toast.entries.any((e) => e.severity == ToastSeverity.error && e.message.contains('rejected')), isTrue);
});
test('pull success + failure raise toasts', () async {
f.ipc.stub('git.pull', (_) async => ok());
final c = controller();
expect(await c.pull(), isTrue);
await settle();
expect(f.services.toast.entries.any((e) => e.severity == ToastSeverity.success && e.message.startsWith('Pulled')), isTrue);
f.services.toast.clear();
f.ipc.stub('git.pull', (_) async => err('diverged'));
expect(await c.pull(), isFalse);
await settle();
expect(f.services.toast.entries.any((e) => e.severity == ToastSeverity.error && e.message.contains('diverged')), isTrue);
});
});
group('event-driven refresh', () {
test('a git.changed event triggers a reload; non-git events are ignored', () async {
var statusCalls = 0;
f.ipc.stub('git.status', (_) async {
statusCalls++;
return ok({'branch': 'main'});
});
controller();
f.services.events.emit(DaemonEvent(subsystem: 'pty', kind: 'output', data: const {}, ts: DateTime.utc(2026)));
f.services.events.emit(DaemonEvent(subsystem: 'git', kind: 'git.changed', data: const {}, ts: DateTime.utc(2026)));
await settle();
expect(statusCalls, 1); // only the git.changed reload
});
});
}
+127
View File
@@ -0,0 +1,127 @@
/// Unit tests for ToastService (T-50): MessageBus consumption, queueing,
/// the visible cap, manual dismiss, clear, and per-severity auto-dismiss.
///
/// Timer-driven cases run under testWidgets so flutter_test's fake clock
/// fires the Timers on `tester.pump(duration)` — no `fake_async` dependency.
library;
import 'package:clide/kernel/src/events/message_bus.dart';
import 'package:clide/kernel/src/toast.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
// A service wired to a fresh bus; both torn down.
(ToastService, MessageBus) make({int maxVisible = 4}) {
final bus = MessageBus();
final t = ToastService(messages: bus, maxVisible: maxVisible);
addTearDown(t.dispose);
addTearDown(bus.dispose);
return (t, bus);
}
group('ToastService — MessageBus consumption', () {
test('shows a toast for each message published to the toast channel', () async {
final (t, bus) = make();
publishToast(bus, 'builtin.git', 'Pushed to origin/main', severity: ToastSeverity.success, duration: Duration.zero);
await Future<void>.delayed(Duration.zero); // let the broadcast stream deliver
expect(t.entries.single.message, 'Pushed to origin/main');
expect(t.entries.single.severity, ToastSeverity.success);
});
test('ignores messages without a string "message" payload', () async {
final (t, bus) = make();
bus.publish('x', toastChannel, {'severity': 'error'}); // no message
bus.publish('x', 'other-channel', {'message': 'nope'}); // wrong channel
await Future<void>.delayed(Duration.zero);
expect(t.entries, isEmpty);
});
test('parses severity by name and defaults unknown to info', () async {
final (t, bus) = make();
bus.publish('x', toastChannel, {'message': 'a', 'severity': 'warning', 'durationMs': 0});
bus.publish('x', toastChannel, {'message': 'b', 'severity': 'bogus', 'durationMs': 0});
await Future<void>.delayed(Duration.zero);
expect(t.entries.map((e) => e.severity), [ToastSeverity.warning, ToastSeverity.info]);
});
});
group('ToastService — queue', () {
test('show appends entries with monotonic ids and the given severity', () {
final (t, _) = make();
final a = t.show('hello', duration: Duration.zero);
final b = t.show('there', severity: ToastSeverity.error, duration: Duration.zero);
expect(a, isNot(b));
expect(t.entries.map((e) => e.message), ['hello', 'there']);
expect(t.entries.first.severity, ToastSeverity.info); // default
expect(t.entries.last.severity, ToastSeverity.error);
});
test('caps the visible count, dropping the oldest', () {
final (t, _) = make(maxVisible: 2);
t.show('1', duration: Duration.zero);
t.show('2', duration: Duration.zero);
t.show('3', duration: Duration.zero);
expect(t.entries.map((e) => e.message), ['2', '3']);
});
test('dismiss removes by id and is a no-op for unknown ids', () {
final (t, _) = make();
final id = t.show('x', duration: Duration.zero);
var fired = 0;
t.addListener(() => fired++);
t.dismiss(99999); // unknown → no notify
expect(fired, 0);
t.dismiss(id);
expect(t.entries, isEmpty);
expect(fired, 1);
});
test('clear drops everything (and only notifies when non-empty)', () {
final (t, _) = make();
var fired = 0;
t.addListener(() => fired++);
t.clear(); // already empty → no notify
expect(fired, 0);
t.show('a', duration: Duration.zero);
t.show('b', duration: Duration.zero);
fired = 0;
t.clear();
expect(t.entries, isEmpty);
expect(fired, 1);
});
});
group('ToastService — auto-dismiss timers', () {
testWidgets('fires after the default duration; errors linger longer', (tester) async {
final (t, _) = make();
await tester.pumpWidget(const SizedBox());
t.show('info');
t.show('boom', severity: ToastSeverity.error);
await tester.pump(const Duration(seconds: 3, milliseconds: 900));
expect(t.entries.length, 2);
await tester.pump(const Duration(milliseconds: 200)); // past 4s
expect(t.entries.map((e) => e.message), ['boom']);
await tester.pump(const Duration(seconds: 4)); // past 8s
expect(t.entries, isEmpty);
});
testWidgets('a zero/sticky duration never auto-dismisses', (tester) async {
final (t, _) = make();
await tester.pumpWidget(const SizedBox());
t.show('sticky', duration: Duration.zero);
await tester.pump(const Duration(minutes: 5));
expect(t.entries.length, 1);
});
testWidgets('dispose cancels pending timers (no lingering callbacks)', (tester) async {
final bus = MessageBus();
addTearDown(bus.dispose);
final t = ToastService(messages: bus);
await tester.pumpWidget(const SizedBox());
t.show('x');
t.dispose();
await tester.pump(const Duration(seconds: 10)); // cancelled timer must not fire
});
});
}
+85
View File
@@ -0,0 +1,85 @@
/// Widget tests for ClideToast + ToastOverlay (T-50): render per severity,
/// manual dismiss, the live-region a11y contract, and the overlay reflecting
/// the ToastService queue. Toasts are shown sticky (Duration.zero) so no
/// auto-dismiss Timer is left pending at teardown.
library;
import 'package:clide/kernel/kernel.dart';
import 'package:clide/widgets/widgets.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../helpers/kernel_fixture.dart';
void main() {
late KernelFixture f;
setUp(() async => f = await KernelFixture.create());
tearDown(() async => f.dispose());
Widget host(Widget child) => Directionality(
textDirection: TextDirection.ltr,
child: ClideKernel(
services: f.services,
child: ClideTheme(
controller: f.services.theme,
child: Align(alignment: Alignment.topLeft, child: child),
),
),
);
testWidgets('renders the message and calls onDismiss when the × is tapped', (tester) async {
var dismissed = false;
await tester.pumpWidget(host(ClideToast(
entry: const ToastEntry(id: 1, message: 'Pushed to origin/main', severity: ToastSeverity.success),
onDismiss: () => dismissed = true,
)));
await tester.pump(const Duration(milliseconds: 300)); // settle entrance
expect(find.text('Pushed to origin/main'), findsOneWidget);
// The dismiss × is the toast's only ClideTappable.
await tester.tap(find.byType(ClideTappable));
await tester.pump();
expect(dismissed, isTrue);
});
testWidgets('exposes the message as a live region (a11y)', (tester) async {
await tester.pumpWidget(host(ClideToast(
entry: const ToastEntry(id: 1, message: 'Heads up', severity: ToastSeverity.warning),
onDismiss: () {},
)));
await tester.pump(const Duration(milliseconds: 300));
// The message is wrapped in a live-region Semantics so screen readers
// announce it when it appears.
expect(
find.byWidgetPredicate((w) => w is Semantics && w.properties.liveRegion == true && w.properties.label == 'Heads up'),
findsOneWidget,
);
});
testWidgets('overlay renders a card per queued toast and dismiss removes one', (tester) async {
await tester.pumpWidget(host(const SizedBox(
width: 800,
height: 600,
child: Stack(children: [ToastOverlay()]),
)));
await tester.pump();
expect(find.byType(ClideToast), findsNothing);
f.services.toast.show('one', duration: Duration.zero);
f.services.toast.show('two', severity: ToastSeverity.error, duration: Duration.zero);
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(find.byType(ClideToast), findsNWidgets(2));
expect(find.text('one'), findsOneWidget);
expect(find.text('two'), findsOneWidget);
// Dismissing via the service updates the overlay (tap-to-dismiss is
// covered by the ClideToast test above; here we assert reactivity).
f.services.toast.dismiss(f.services.toast.entries.first.id);
await tester.pump();
expect(find.byType(ClideToast), findsNWidgets(1));
expect(find.text('one'), findsNothing);
expect(find.text('two'), findsOneWidget);
});
}