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:
@@ -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
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user