From 407ed25ab0deba878a73e95ecbf37bca93fcf29d Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Tue, 9 Jun 2026 18:34:27 +0200 Subject: [PATCH] group consecutive same-file edits into one collapsed card (T-296) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A run of 2+ consecutive edits to the same file now folds into one ClideHolderCard labelled '# edits' (coalesceEditRuns, run after groupConversation) instead of a stack of cards; a different file or an interleaving step splits the run. Every edit stays reachable on expand. The holder gained an optional aggregate status. New owned primitives: ClideSpinner (the logo mark, monochrome, 3D Y-axis rotation, reduced-motion-aware) and ClideStatusIndicator (running→spinner / success→check / error→cross, with an AnimatedSwitcher seam for a richer transition later — kept self-contained, not built on ConversationCard's mark). The activity card shares the same indicator. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 7 ++ lib/builtin/claude/src/activity_cluster.dart | 49 ++++++++++ lib/builtin/claude/src/conversation_view.dart | 77 ++++++++++++++- lib/builtin/claude/src/holder_card.dart | 13 +++ lib/widgets/src/clide_spinner.dart | 95 +++++++++++++++++++ lib/widgets/src/clide_status_indicator.dart | 43 +++++++++ lib/widgets/widgets.dart | 2 + .../builtin/claude/activity_cluster_test.dart | 55 +++++++++++ .../claude/conversation_view_test.dart | 51 +++++++++- .../src/clide_status_indicator_test.dart | 61 ++++++++++++ 10 files changed, 447 insertions(+), 6 deletions(-) create mode 100644 lib/widgets/src/clide_spinner.dart create mode 100644 lib/widgets/src/clide_status_indicator.dart create mode 100644 test/widgets/src/clide_status_indicator_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index b3d7e604..8f930192 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,13 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Added +- **Consecutive edits to one file fold into a single card.** A run of edits to + the same file now collapses to one `# edits` holder (the latest edit as the + ticker; every edit reachable on expand) instead of a stack of cards — a + different file or an interleaving step splits the run. The card carries an + aggregate live status: a new logo-mark **`ClideSpinner`** while editing, + settling to a check (or a cross on failure); the activity card shares it. + (T-296) - **Collapse toggles in the status bar.** A small caret-line button bookends each end of the bottom status bar — left collapses/expands the sidebar, right the context pane. The chevron points inward to collapse, outward to expand, diff --git a/lib/builtin/claude/src/activity_cluster.dart b/lib/builtin/claude/src/activity_cluster.dart index 39d5b167..12df048c 100644 --- a/lib/builtin/claude/src/activity_cluster.dart +++ b/lib/builtin/claude/src/activity_cluster.dart @@ -50,9 +50,58 @@ final class FoldedCluster extends RenderGroup { final List items; } +/// A run of 2+ consecutive edits to the SAME file, bundled into one collapsed +/// "# edits" card (T-296). Holds the edit tool-use items (their success results +/// fold into each child card as usual). Always length >= 2. +final class EditRun extends RenderGroup { + const EditRun(this.edits, this.filePath); + final List edits; + final String filePath; +} + /// Tools whose result is a diff the user wants to keep first-class at L1/L2. bool isDiffTool(String name) => const {'Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'Update'}.contains(name); +/// The file an edit tool-use targets, or null if [it] isn't a same-file edit +/// (used to group consecutive edits, T-296). +String? editFilePath(ConversationItem it) { + if (it is! AssistantToolUse || !isDiffTool(it.name)) return null; + final p = it.input['file_path'] ?? it.input['path'] ?? it.input['notebook_path']; + return p is String && p.isNotEmpty ? p : null; +} + +/// Coalesce maximal runs of consecutive same-file edit [StickyItem]s into one +/// [EditRun] (T-296). A different file, or any non-edit group, breaks the run; +/// a lone edit stays a [StickyItem]. Run this after [groupConversation], on its +/// output, so it composes with the existing meta-folding (a folded Read cluster +/// between two edit runs splits them — matching the worked example). +List coalesceEditRuns(List groups) { + final out = []; + var run = []; + String? runPath; + + void flush() { + if (run.isEmpty) return; + out.add(run.length >= 2 ? EditRun(List.unmodifiable(run), runPath!) : StickyItem(run.single)); + run = []; + runPath = null; + } + + for (final g in groups) { + final path = g is StickyItem ? editFilePath(g.item) : null; + if (path != null) { + if (runPath != null && path != runPath) flush(); // different file → new run + runPath = path; + run.add((g as StickyItem).item); + } else { + flush(); + out.add(g); + } + } + flush(); + return out; +} + /// Group [items] into render units per [level]. Pairs tool results to their /// originating tool-use (by `toolUseId`) so a result can be classified by its /// tool name (diffs stay first-class at L1/L2). diff --git a/lib/builtin/claude/src/conversation_view.dart b/lib/builtin/claude/src/conversation_view.dart index a22c8f63..a165b66d 100644 --- a/lib/builtin/claude/src/conversation_view.dart +++ b/lib/builtin/claude/src/conversation_view.dart @@ -270,8 +270,9 @@ class _ConversationViewState extends State { }; // Fold runs of meta items into collapsible activity cards (T-230); sticky - // items (user/prose/surfaced errors) render first-class as before. - final groups = groupConversation(items, widget.foldLevel); + // items (user/prose/surfaced errors) render first-class as before. Then + // bundle consecutive same-file edits into one "# edits" card (T-296). + final groups = coalesceEditRuns(groupConversation(items, widget.foldLevel)); final list = ClideScrollbar( controller: _scroll, child: ListView.builder( @@ -304,6 +305,16 @@ class _ConversationViewState extends State { promptsByToolUseId: fold.promptsByToolUseId, runByToolUseId: fold.runByToolUseId, ), + EditRun(:final edits) => _EditRunCard( + key: ValueKey('edits.${edits.first.uuid}'), + edits: edits, + tokens: tokens, + toolUseOutcomes: widget.toolUseOutcomes, + toolUseById: widget.controller.toolUseById, + resultByToolUseId: resultByToolUseId, + promptsByToolUseId: fold.promptsByToolUseId, + runByToolUseId: fold.runByToolUseId, + ), }; }, ), @@ -724,6 +735,7 @@ class _ActivityCard extends StatelessWidget { return ClideHolderCard( collapsedSummary: _summarizeActivity(items.last), stepLabel: count == 1 ? '1 step' : '$count steps', + status: _runStatus(items, resultByToolUseId), children: [ for (final item in items) _ConversationTurn( @@ -741,6 +753,67 @@ class _ActivityCard extends StatelessWidget { } } +/// Aggregate live status for a run's header tick (T-296): error if any tool in +/// the run failed, else running while its last tool awaits a result, else +/// success. Null (no tools) shows no indicator. +ClideRunStatus? _runStatus(List items, Map results) { + final tools = items.whereType().toList(); + if (tools.isEmpty) return null; + for (final t in tools) { + final r = results[t.toolUseId]; + if (r != null && r.isError) return ClideRunStatus.error; + } + return results[tools.last.toolUseId] == null ? ClideRunStatus.running : ClideRunStatus.success; +} + +/// A run of consecutive same-file edits, bundled into one collapsible "# edits" +/// card (T-296) through the shared [ClideHolderCard]. Each edit keeps its own +/// merged tool card when expanded; the header carries the aggregate live tick. +class _EditRunCard extends StatelessWidget { + const _EditRunCard({ + super.key, + required this.edits, + required this.tokens, + required this.toolUseOutcomes, + required this.toolUseById, + required this.resultByToolUseId, + required this.promptsByToolUseId, + required this.runByToolUseId, + }); + + final List edits; + final SurfaceTokens tokens; + final Map toolUseOutcomes; + final Map toolUseById; + final Map resultByToolUseId; + final Map> promptsByToolUseId; + final Map> runByToolUseId; + + @override + Widget build(BuildContext context) { + final count = edits.length; + return ClideHolderCard( + title: 'Edits', + collapsedSummary: _summarizeActivity(edits.last), + stepLabel: count == 1 ? '1 edit' : '$count edits', + status: _runStatus(edits, resultByToolUseId), + children: [ + for (final item in edits) + _ConversationTurn( + key: ValueKey('edit.${item.uuid}'), + item: item, + tokens: tokens, + toolUseOutcomes: toolUseOutcomes, + toolUseById: toolUseById, + resultByToolUseId: resultByToolUseId, + promptsByToolUseId: promptsByToolUseId, + runByToolUseId: runByToolUseId, + ), + ], + ); + } +} + /// One-line summary of a folded item for the collapsed ticker. String _summarizeActivity(ConversationItem item) { switch (item) { diff --git a/lib/builtin/claude/src/holder_card.dart b/lib/builtin/claude/src/holder_card.dart index 83bd6697..eb6549f4 100644 --- a/lib/builtin/claude/src/holder_card.dart +++ b/lib/builtin/claude/src/holder_card.dart @@ -30,6 +30,7 @@ class ClideHolderCard extends StatefulWidget { required this.children, this.title = 'Activity', this.initiallyExpanded = false, + this.status, }); /// One-line gist of the latest step, shown in the collapsed ticker. @@ -47,6 +48,10 @@ class ClideHolderCard extends StatefulWidget { final bool initiallyExpanded; + /// Optional aggregate run status (spinner / check / cross) shown at the head + /// of the ticker + header — the run's live state (T-296). Null shows nothing. + final ClideRunStatus? status; + @override State createState() => _ClideHolderCardState(); } @@ -108,6 +113,10 @@ class _ClideHolderCardState extends State { ), ), const SizedBox(width: 8), + if (widget.status != null) ...[ + ClideStatusIndicator(status: widget.status!, size: 12), + const SizedBox(width: 8), + ], ClideText(widget.stepLabel, fontSize: clideFontCaption, color: tokens.globalTextMuted), ], ), @@ -186,6 +195,10 @@ class _ClideHolderCardState extends State { child: ClideText(widget.title, fontSize: clideFontCaption, fontFamily: clideMonoFamily, color: tokens.globalTextMuted), ), const SizedBox(width: 8), + if (widget.status != null) ...[ + ClideStatusIndicator(status: widget.status!, size: 12), + const SizedBox(width: 8), + ], ClideText(widget.stepLabel, fontSize: clideFontCaption, color: tokens.globalTextMuted), ], ), diff --git a/lib/widgets/src/clide_spinner.dart b/lib/widgets/src/clide_spinner.dart new file mode 100644 index 00000000..8da45a7c --- /dev/null +++ b/lib/widgets/src/clide_spinner.dart @@ -0,0 +1,95 @@ +/// A compact in-progress spinner: the clide logo mark, monochrome, rotating in +/// 3D about its vertical axis (T-296). +/// +/// Reuses `assets/logo/logo.svg` as the single source of truth for the mark +/// (tinted to one colour via a srcIn [ColorFilter]) rather than re-coding the +/// geometry, and spins it with a perspective Y-rotation. Honours +/// reduced-motion: when animations are disabled it shows the static, front-on +/// mark. Animation is one [AnimationController] (no timers) so tests advance it +/// with bounded pumps. +library; + +import 'dart:math' as math; + +import 'package:clide/kernel/src/theme/controller.dart'; +import 'package:clide/widgets/src/clide_svg_view.dart'; +import 'package:flutter/widgets.dart'; + +class ClideSpinner extends StatefulWidget { + const ClideSpinner({ + super.key, + this.size = 14, + this.color, + this.period = const Duration(milliseconds: 1500), + this.semanticLabel, + }); + + final double size; + + /// Mark colour; defaults to the theme's foreground (monochrome on the chrome). + final Color? color; + + /// Time for one full rotation. + final Duration period; + + /// Optional AT label (e.g. 'running'); omit when a parent announces status. + final String? semanticLabel; + + @override + State createState() => _ClideSpinnerState(); +} + +class _ClideSpinnerState extends State with SingleTickerProviderStateMixin { + late final AnimationController _ctrl = AnimationController(vsync: this, duration: widget.period); + bool _reducedMotion = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _reducedMotion = MediaQuery.maybeOf(context)?.disableAnimations ?? false; + _sync(); + } + + void _sync() { + if (_reducedMotion) { + _ctrl.stop(); + _ctrl.value = 0; + } else if (!_ctrl.isAnimating) { + _ctrl.repeat(); + } + } + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final color = widget.color ?? ClideTheme.of(context).surface.globalForeground; + // Tint every stroke of the multi-colour logo to one colour, keeping alpha. + final mark = ColorFiltered( + colorFilter: ColorFilter.mode(color, BlendMode.srcIn), + child: ClideSvgView.asset('assets/logo/logo.svg', width: widget.size, height: widget.size), + ); + final child = _reducedMotion + ? mark + : AnimatedBuilder( + animation: _ctrl, + child: mark, + builder: (_, child) => Transform( + alignment: Alignment.center, + transform: Matrix4.identity() + ..setEntry(3, 2, 0.0015) // perspective + ..rotateY(_ctrl.value * 2 * math.pi), + child: child, + ), + ); + return Semantics( + label: widget.semanticLabel, + excludeSemantics: widget.semanticLabel == null, + child: SizedBox(width: widget.size, height: widget.size, child: child), + ); + } +} diff --git a/lib/widgets/src/clide_status_indicator.dart b/lib/widgets/src/clide_status_indicator.dart new file mode 100644 index 00000000..e13a47ac --- /dev/null +++ b/lib/widgets/src/clide_status_indicator.dart @@ -0,0 +1,43 @@ +/// A self-contained run-status glyph: spinner while running, check on success, +/// cross on failure (T-296). +/// +/// Deliberately NOT built on ConversationCard's success/error mark — it owns its +/// own states and rendering so the spinner→check / spinner→cross transition can +/// grow richer (a morph/cross-fade) without being constrained by that card. A +/// light [AnimatedSwitcher] cross-fade between states is wired now; the keyed +/// children leave the seam for a fuller transition later. +library; + +import 'package:clide/kernel/src/theme/controller.dart'; +import 'package:clide/widgets/src/clide_icon.dart'; +import 'package:clide/widgets/src/clide_spinner.dart'; +import 'package:clide/widgets/src/icons/check.dart'; +import 'package:clide/widgets/src/icons/x.dart'; +import 'package:flutter/widgets.dart'; + +enum ClideRunStatus { running, success, error } + +class ClideStatusIndicator extends StatelessWidget { + const ClideStatusIndicator({super.key, required this.status, this.size = 14}); + + final ClideRunStatus status; + final double size; + + @override + Widget build(BuildContext context) { + final tokens = ClideTheme.of(context).surface; + final (Widget glyph, String label) = switch (status) { + ClideRunStatus.running => (ClideSpinner(size: size, color: tokens.globalTextMuted, key: const ValueKey('running')), 'running'), + ClideRunStatus.success => (ClideIcon(const CheckIcon(), size: size, color: tokens.statusSuccess, key: const ValueKey('success')), 'succeeded'), + ClideRunStatus.error => (ClideIcon(const CloseIcon(), size: size, color: tokens.statusError, key: const ValueKey('error')), 'failed'), + }; + return Semantics( + label: label, + container: true, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + child: glyph, + ), + ); + } +} diff --git a/lib/widgets/widgets.dart b/lib/widgets/widgets.dart index 0f43a0a8..55db7cda 100644 --- a/lib/widgets/widgets.dart +++ b/lib/widgets/widgets.dart @@ -16,6 +16,8 @@ export 'src/clide_lightbox.dart'; export 'src/clide_markdown.dart'; export 'src/clide_marquee.dart'; export 'src/clide_menu.dart'; +export 'src/clide_spinner.dart'; +export 'src/clide_status_indicator.dart'; export 'src/clide_svg_view.dart'; export 'src/clide_toast.dart'; export 'src/clide_icon.dart'; diff --git a/test/builtin/claude/activity_cluster_test.dart b/test/builtin/claude/activity_cluster_test.dart index 4773d018..f6f620e1 100644 --- a/test/builtin/claude/activity_cluster_test.dart +++ b/test/builtin/claude/activity_cluster_test.dart @@ -13,6 +13,8 @@ AssistantTextMessage _prose([String t = 'sure']) => AssistantTextMessage(uuid: ' AssistantThinkingMessage _think() => AssistantThinkingMessage(uuid: 't${_n++}', timestamp: _ts, isSidechain: false, thinking: '…'); AssistantToolUse _tool(String id, String name) => AssistantToolUse(uuid: 'tu${_n++}', timestamp: _ts, isSidechain: false, toolUseId: id, name: name, input: const {}); +AssistantToolUse _edit(String id, String path, {String name = 'Edit'}) => + AssistantToolUse(uuid: 'tu${_n++}', timestamp: _ts, isSidechain: false, toolUseId: id, name: name, input: {'file_path': path}); ToolResultMessage _result(String id, {bool isError = false}) => ToolResultMessage(uuid: 'r${_n++}', timestamp: _ts, isSidechain: false, toolUseId: id, content: '', isError: isError); @@ -87,4 +89,57 @@ void main() { expect((groups[1] as StickyItem).item, isA()); }); }); + + group('editFilePath', () { + test('reads the file of an edit tool-use; null otherwise', () { + expect(editFilePath(_edit('1', '/a/b.dart')), '/a/b.dart'); + expect(editFilePath(_edit('1', '/a/b.dart', name: 'Write')), '/a/b.dart'); + expect(editFilePath(_tool('1', 'Bash')), isNull); // not a diff tool + expect(editFilePath(_tool('1', 'Edit')), isNull); // diff tool, but no file_path + expect(editFilePath(_prose()), isNull); + }); + }); + + group('coalesceEditRuns (T-296)', () { + test('consecutive same-file edits bundle into one EditRun', () { + final out = coalesceEditRuns([StickyItem(_edit('1', '/a')), StickyItem(_edit('2', '/a')), StickyItem(_edit('3', '/a'))]); + expect(out, hasLength(1)); + expect(out.single, isA()); + final run = out.single as EditRun; + expect(run.edits, hasLength(3)); + expect(run.filePath, '/a'); + }); + + test('a lone edit stays a StickyItem (not a one-item run)', () { + final out = coalesceEditRuns([StickyItem(_edit('1', '/a')), StickyItem(_prose())]); + expect(out.first, isA()); + expect((out.first as StickyItem).item, isA()); + }); + + test('a different file starts a new run', () { + final out = coalesceEditRuns([StickyItem(_edit('1', '/a')), StickyItem(_edit('2', '/a')), StickyItem(_edit('3', '/b')), StickyItem(_edit('4', '/b'))]); + expect(out.map((g) => g.runtimeType.toString()), ['EditRun', 'EditRun']); + expect((out[0] as EditRun).filePath, '/a'); + expect((out[1] as EditRun).filePath, '/b'); + }); + + test('an interleaving non-edit group splits the run (the worked example)', () { + // 3 edits to /a, a folded Read cluster, then 7 edits to /a → [3 edits][cluster][7 edits]. + final groups = [ + for (var i = 0; i < 3; i++) StickyItem(_edit('a$i', '/a')), + FoldedCluster([_tool('r', 'Read'), _result('r')]), + for (var i = 0; i < 7; i++) StickyItem(_edit('b$i', '/a')), + ]; + final out = coalesceEditRuns(groups); + expect(out.map((g) => g.runtimeType.toString()), ['EditRun', 'FoldedCluster', 'EditRun']); + expect((out[0] as EditRun).edits, hasLength(3)); + expect((out[2] as EditRun).edits, hasLength(7)); + }); + + test('a sticky non-edit between edits breaks the run', () { + final out = coalesceEditRuns([StickyItem(_edit('1', '/a')), StickyItem(_prose()), StickyItem(_edit('2', '/a'))]); + // edit (lone) → sticky, prose → sticky, edit (lone) → sticky. + expect(out.map((g) => g.runtimeType.toString()), ['StickyItem', 'StickyItem', 'StickyItem']); + }); + }); } diff --git a/test/builtin/claude/conversation_view_test.dart b/test/builtin/claude/conversation_view_test.dart index e316d46e..87133831 100644 --- a/test/builtin/claude/conversation_view_test.dart +++ b/test/builtin/claude/conversation_view_test.dart @@ -16,7 +16,7 @@ import 'package:clide/builtin/claude/src/transcript_reader.dart'; import 'package:clide/kernel/src/events/message_bus.dart'; import 'package:clide/widgets/widgets.dart'; import 'package:flutter/services.dart'; -import 'package:flutter/widgets.dart' show Image, FileImage, ValueKey; +import 'package:flutter/widgets.dart' show Builder, Image, FileImage, MediaQuery, ValueKey; import 'package:flutter_test/flutter_test.dart'; import '../../helpers/kernel_fixture.dart'; @@ -159,8 +159,17 @@ void main() { final stream = StreamController.broadcast(); final c = ConversationController(stream: stream.stream); addTearDown(c.dispose); - await tester - .pumpWidget(harness(f, ConversationView(controller: c, hiddenToolUseIds: hiddenToolUseIds, toolUseOutcomes: toolUseOutcomes, foldLevel: foldLevel))); + // Disable animations so an in-flight run's ClideSpinner (a perpetual + // animation) renders static and pumpAndSettle can settle (T-296). + await tester.pumpWidget(harness( + f, + Builder( + builder: (ctx) => MediaQuery( + data: MediaQuery.of(ctx).copyWith(disableAnimations: true), + child: ConversationView(controller: c, hiddenToolUseIds: hiddenToolUseIds, toolUseOutcomes: toolUseOutcomes, foldLevel: foldLevel), + ), + ), + )); for (final it in items) { stream.add(it); } @@ -199,7 +208,15 @@ void main() { final stream = StreamController.broadcast(); final c = ConversationController(stream: stream.stream); addTearDown(c.dispose); - await tester.pumpWidget(harness(f, ConversationView(controller: c, foldLevel: FoldLevel.tools))); + await tester.pumpWidget(harness( + f, + Builder( + builder: (ctx) => MediaQuery( + data: MediaQuery.of(ctx).copyWith(disableAnimations: true), + child: ConversationView(controller: c, foldLevel: FoldLevel.tools), + ), + ), + )); stream.add(AssistantToolUse(uuid: 'A', timestamp: _t, isSidechain: false, toolUseId: 'A', name: 'Bash', input: const {'command': 'echo a'})); stream.add(AssistantToolUse(uuid: 'B', timestamp: _t, isSidechain: false, toolUseId: 'B', name: 'Read', input: const {'file_path': '/a'})); await tester.pumpAndSettle(); @@ -247,6 +264,32 @@ void main() { expect(find.byType(ImageThumbnail), findsNothing); }); + testWidgets('consecutive same-file edits collapse into one "# edits" card (T-296)', (tester) async { + AssistantToolUse edit(String id, String path) => + AssistantToolUse(uuid: id, timestamp: _t, isSidechain: false, toolUseId: id, name: 'Edit', input: {'file_path': path}); + ToolResultMessage ok(String id) => ToolResultMessage(uuid: 'r$id', timestamp: _t, isSidechain: false, toolUseId: id, content: 'done', isError: false); + await pumpWith( + tester, + [ + edit('e1', '/lib/x.dart'), + ok('e1'), + edit('e2', '/lib/x.dart'), + ok('e2'), + ], + foldLevel: FoldLevel.tools); + // One bundled card labelled "2 edits" with an aggregate status indicator. + expect(find.text('2 edits'), findsOneWidget); + expect(find.byType(ClideStatusIndicator), findsOneWidget); + }); + + testWidgets('an edit to a different file is not bundled with the first (T-296)', (tester) async { + AssistantToolUse edit(String id, String path) => + AssistantToolUse(uuid: id, timestamp: _t, isSidechain: false, toolUseId: id, name: 'Edit', input: {'file_path': path}); + await pumpWith(tester, [edit('e1', '/a.dart'), edit('e2', '/b.dart')], foldLevel: FoldLevel.tools); + // Two lone edits, different files → no "edits" bundle. + expect(find.textContaining('edits'), findsNothing); + }); + testWidgets('meta items fold into a collapsed activity card; tap expands (T-230)', (tester) async { await pumpWith( tester, diff --git a/test/widgets/src/clide_status_indicator_test.dart b/test/widgets/src/clide_status_indicator_test.dart new file mode 100644 index 00000000..c3e6f365 --- /dev/null +++ b/test/widgets/src/clide_status_indicator_test.dart @@ -0,0 +1,61 @@ +/// Tests for ClideSpinner + ClideStatusIndicator (T-296): the run-status glyph +/// (spinner / check / cross) and its reduced-motion behaviour. +library; + +import 'package:clide/widgets/widgets.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers/kernel_fixture.dart'; +import '../../helpers/widget_harness.dart'; + +void main() { + late KernelFixture f; + setUp(() async => f = await KernelFixture.create()); + tearDown(() => f.dispose()); + + // Disable animations so the spinner is static (no perpetual rotation) and + // pumpAndSettle can settle — mirroring how every animated widget behaves + // under reduced motion. + Future pump(WidgetTester tester, Widget child) => tester.pumpWidget(harness( + f, + Builder( + builder: (ctx) => MediaQuery( + data: MediaQuery.of(ctx).copyWith(disableAnimations: true), + child: Center(child: child), + ), + ), + )); + + Finder iconWith(Object painterType) => find.byWidgetPredicate((w) => w is ClideIcon && w.painter.runtimeType == painterType); + + group('ClideSpinner', () { + testWidgets('renders the logo mark and settles under reduced motion', (tester) async { + await pump(tester, const ClideSpinner(size: 16)); + await tester.pumpAndSettle(); // would hang if it kept rotating + expect(find.byType(ClideSvgView), findsOneWidget); + expect(tester.takeException(), isNull); + }); + }); + + group('ClideStatusIndicator', () { + testWidgets('running shows the spinner', (tester) async { + await pump(tester, const ClideStatusIndicator(status: ClideRunStatus.running)); + await tester.pumpAndSettle(); + expect(find.byType(ClideSpinner), findsOneWidget); + }); + + testWidgets('success shows a check', (tester) async { + await pump(tester, const ClideStatusIndicator(status: ClideRunStatus.success)); + await tester.pumpAndSettle(); + expect(iconWith(CheckIcon), findsOneWidget); + expect(find.byType(ClideSpinner), findsNothing); + }); + + testWidgets('error shows a cross', (tester) async { + await pump(tester, const ClideStatusIndicator(status: ClideRunStatus.error)); + await tester.pumpAndSettle(); + expect(iconWith(CloseIcon), findsOneWidget); + }); + }); +}