/// Widget tests for the native Claude ConversationView + controller /// (T-137): renders each transcript item kind as a card, and text /// selects + copies across cards via ClideSelectionArea (the terminal /// affordance we keep, T-135). library; import 'dart:async'; import 'package:clide/builtin/claude/src/claude_banner.dart'; import 'package:clide/builtin/claude/src/conversation_controller.dart'; import 'package:clide/builtin/claude/src/conversation_view.dart'; import 'package:clide/builtin/claude/src/transcript_publisher.dart'; 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_test/flutter_test.dart'; import '../../helpers/kernel_fixture.dart'; import '../../helpers/widget_harness.dart'; final _t = DateTime.utc(2026, 1, 1); UserMessage _user(String text) => UserMessage(uuid: 'u', timestamp: _t, isSidechain: false, text: text); AssistantTextMessage _asst(String text) => AssistantTextMessage(uuid: 'a', timestamp: _t, isSidechain: false, text: text); AssistantThinkingMessage _think(String text) => AssistantThinkingMessage(uuid: 't', timestamp: _t, isSidechain: false, thinking: text); AssistantToolUse _tool(String name, Map input) => AssistantToolUse(uuid: 'tu', timestamp: _t, isSidechain: false, toolUseId: 'x1', name: name, input: input); ToolResultMessage _result(String content, {bool isError = false}) => ToolResultMessage(uuid: 'r', timestamp: _t, isSidechain: false, toolUseId: 'x1', content: content, isError: isError); class _MockClipboard { Map _data = {'text': null}; Future handleMethodCall(MethodCall call) async { switch (call.method) { case 'Clipboard.setData': _data = Map.from(call.arguments as Map); case 'Clipboard.getData': return _data; case 'Clipboard.hasStrings': final t = _data['text'] as String?; return {'value': t != null && t.isNotEmpty}; } return null; } String? get text => _data['text'] as String?; } void main() { group('ConversationController', () { test('accumulates items from the stream and notifies', () async { final ctrl = StreamController(); final c = ConversationController(stream: ctrl.stream); addTearDown(c.dispose); var notifications = 0; c.addListener(() => notifications++); expect(c.isEmpty, isTrue); ctrl.add(_user('hi')); ctrl.add(_asst('hello')); // Wait past the coalescing timer (zero-duration, fires after the // microtask queue drains). await Future.delayed(const Duration(milliseconds: 20)); expect(c.items, hasLength(2)); expect(c.items.first, isA()); // Notifications are coalesced: a burst of items collapses to a // single notify so the view rebuilds once, not per item. expect(notifications, 1); await ctrl.close(); }); test('onDispose is invoked on dispose', () async { final ctrl = StreamController(); var disposed = false; final c = ConversationController(stream: ctrl.stream, onDispose: () async => disposed = true); c.dispose(); expect(disposed, isTrue); await ctrl.close(); }); test('toolUseById indexes AssistantToolUse by toolUseId (T-168)', () async { final ctrl = StreamController(); final c = ConversationController(stream: ctrl.stream); addTearDown(c.dispose); ctrl.add(_tool('Bash', {'command': 'ls'})); await Future.delayed(const Duration(milliseconds: 20)); expect(c.toolUseById['x1'], isNotNull); expect(c.toolUseById['x1']!.name, 'Bash'); await ctrl.close(); }); test('partial-uuid items upsert in the controller (T-168)', () async { final ctrl = StreamController(); final c = ConversationController(stream: ctrl.stream); addTearDown(c.dispose); // Two partials with the same `partial-` uuid — second replaces first. ctrl.add(AssistantTextMessage(uuid: 'partial-m1', timestamp: _t, isSidechain: false, text: 'hello')); await Future.delayed(const Duration(milliseconds: 20)); ctrl.add(AssistantTextMessage(uuid: 'partial-m1', timestamp: _t, isSidechain: false, text: 'hello world')); await Future.delayed(const Duration(milliseconds: 20)); expect(c.items.whereType(), hasLength(1)); expect(c.items.whereType().first.text, 'hello world'); await ctrl.close(); }); }); group('ConversationController.fromBus', () { test('consumes items published on its publisher/channel', () async { final bus = MessageBus(); addTearDown(bus.dispose); final c = ConversationController.fromBus(messages: bus); addTearDown(c.dispose); bus.publish(ClaudeConversation.publisher, ClaudeConversation.leadChannel, {ClaudeConversation.itemKey: _user('hi')}); bus.publish(ClaudeConversation.publisher, ClaudeConversation.leadChannel, {ClaudeConversation.itemKey: _asst('hello')}); await Future.delayed(const Duration(milliseconds: 20)); expect(c.items, hasLength(2)); expect(c.items.first, isA()); }); test('ignores other publishers and channels', () async { final bus = MessageBus(); addTearDown(bus.dispose); final c = ConversationController.fromBus(messages: bus); addTearDown(c.dispose); bus.publish('someone.else', ClaudeConversation.leadChannel, {ClaudeConversation.itemKey: _user('nope')}); bus.publish(ClaudeConversation.publisher, 'conversation/other', {ClaudeConversation.itemKey: _user('nope')}); await Future.delayed(const Duration(milliseconds: 20)); expect(c.items, isEmpty); }); }); group('ConversationView', () { late KernelFixture f; setUp(() async => f = await KernelFixture.create()); tearDown(() => f.dispose()); Future pumpWith(WidgetTester tester, List items, {Set hiddenToolUseIds = const {}, Map toolUseOutcomes = const {}}) async { tester.view.physicalSize = const Size(900, 700); tester.view.devicePixelRatio = 1.0; addTearDown(() { tester.view.resetPhysicalSize(); tester.view.resetDevicePixelRatio(); }); 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))); for (final it in items) { stream.add(it); } await tester.pumpAndSettle(); return c; } testWidgets('empty controller shows the waiting hint', (tester) async { await pumpWith(tester, const []); expect(find.text('Waiting for Claude…'), findsOneWidget); }); testWidgets('empty controller shows the provided emptyState instead', (tester) async { final stream = StreamController.broadcast(); final c = ConversationController(stream: stream.stream); addTearDown(c.dispose); await tester.pumpWidget(harness( f, ConversationView( controller: c, emptyState: const ClideText('CUSTOM EMPTY'), ), )); expect(find.text('CUSTOM EMPTY'), findsOneWidget); expect(find.text('Waiting for Claude…'), findsNothing); }); testWidgets('renders a card per item kind with role/tool labels', (tester) async { await pumpWith(tester, [ _user('a question'), _asst('an answer'), _think('hmm'), _tool('Bash', {'command': 'ls'}), _result('ok'), _result('boom', isError: true), ]); expect(find.text('you'), findsOneWidget); expect(find.text('claude'), findsOneWidget); expect(find.text('thinking'), findsOneWidget); expect(find.text('Bash'), findsOneWidget); // Result labels now include the paired tool name (T-168). expect(find.text('Bash · result'), findsOneWidget); expect(find.text('Bash · error'), findsOneWidget); }); testWidgets('AskUserQuestion tool-use and its result are hidden (it shows as a prompt)', (tester) async { await pumpWith(tester, [ _asst('let me ask'), AssistantToolUse(uuid: 'au', timestamp: _t, isSidechain: false, toolUseId: 'auq1', name: 'AskUserQuestion', input: const {'questions': []}), ToolResultMessage(uuid: 'ar', timestamp: _t, isSidechain: false, toolUseId: 'auq1', content: 'answered', isError: false), _asst('thanks'), ]); expect(find.text('AskUserQuestion'), findsNothing); expect(find.text('let me ask'), findsOneWidget); expect(find.text('thanks'), findsOneWidget); }); testWidgets('a permission-prompted tool-use is hidden but its result is kept', (tester) async { await pumpWith( tester, [ _tool('Write', {'file_path': '/tmp/x'}), _result('done') ], hiddenToolUseIds: {'x1'}, // _tool + _result both use toolUseId 'x1' ); expect(find.text('Write'), findsNothing); // payload hidden expect(find.text('done'), findsOneWidget); // result kept // Label now includes paired tool name (T-168). expect(find.text('Write · result'), findsOneWidget); }); testWidgets('a resolved permission tool-use is shown collapsed, not hidden', (tester) async { await pumpWith( tester, [ _tool('Write', {'file_path': '/tmp/x'}), _result('done') ], hiddenToolUseIds: {'x1'}, toolUseOutcomes: {'x1': true}, // approved ); expect(find.text('Write'), findsOneWidget); // shown (resolved) expect(find.byType(ClideIcon), findsOneWidget); // collapsed caret }); testWidgets('an injected user message renders as a muted "context" card, not "you"', (tester) async { await pumpWith(tester, [ UserMessage(uuid: 'i', timestamp: _t, isSidechain: false, text: 'Base directory for this skill: /x\n\n# pql', injected: true), _user('a real question'), ]); expect(find.text('context'), findsOneWidget); expect(find.text('you'), findsOneWidget); // the real one }); testWidgets('tool-use body: Bash shows the command in the collapsed summary (T-168)', (tester) async { await pumpWith(tester, [ _tool('Bash', {'command': 'ls -la'}) ]); // Card starts collapsed — the command appears as the collapsed summary. expect(find.text('ls -la'), findsOneWidget); // Expand to verify the body is a bash code block. await tester.tap(find.byType(ClideIcon)); await tester.pump(); final blocks = tester.widgetList(find.byType(ClideCodeBlock)).toList(); expect(blocks.any((b) => b.language == 'bash' && b.source.contains('ls -la')), isTrue); }); testWidgets('tool-use body: Read/Grep/LS shows a compact path label (T-168)', (tester) async { await pumpWith(tester, [ _tool('Read', {'file_path': '/foo/bar.dart'}) ]); // The path label appears (collapsed summary or body). expect(find.text('/foo/bar.dart'), findsOneWidget); }); testWidgets('result label includes paired tool name (T-168)', (tester) async { await pumpWith(tester, [ _tool('Read', {'file_path': '/x'}), _result('file content'), ]); expect(find.text('Read · result'), findsOneWidget); }); testWidgets('error result label includes paired tool name (T-168)', (tester) async { await pumpWith(tester, [ _tool('Bash', {'command': 'cat nonexistent'}), _result('No such file', isError: true), ]); expect(find.text('Bash · error'), findsOneWidget); }); testWidgets('result without a paired tool_use uses plain "result" label (T-168)', (tester) async { // Orphan result (no matching tool_use in the controller). await pumpWith(tester, [ ToolResultMessage( uuid: 'r-orphan', timestamp: _t, isSidechain: false, toolUseId: 'unknown-id', content: 'ok', isError: false, ), ]); expect(find.text('result'), findsOneWidget); }); testWidgets('error result defaults expanded so it is visible (T-168)', (tester) async { // An error result should show its content without requiring an expand tap. await pumpWith(tester, [ _tool('Bash', {'command': 'bad'}), _result('permission denied', isError: true), ]); // Error content visible without expand. expect(find.text('permission denied'), findsOneWidget); }); testWidgets('a one-line tool result renders inline (no collapse caret)', (tester) async { await pumpWith(tester, [_result('hello-from-spike')]); expect(find.text('hello-from-spike'), findsOneWidget); expect(find.byType(ClideIcon), findsNothing); // not collapsible → no caret }); testWidgets('a multi-line tool result starts collapsed with a first-line summary', (tester) async { await pumpWith(tester, [_result('first line\nsecond line\nthird line')]); // Collapsed: caret present, summary (first line) shown, full body hidden. expect(find.byType(ClideIcon), findsOneWidget); expect(find.text('first line'), findsOneWidget); expect(find.text('first line\nsecond line\nthird line'), findsNothing); await tester.tap(find.byType(ClideIcon)); await tester.pump(); expect(find.text('first line\nsecond line\nthird line'), findsOneWidget); }); testWidgets('select-all + copy spans multiple cards', (tester) async { final clipboard = _MockClipboard(); TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, clipboard.handleMethodCall); addTearDown(() => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, null)); await pumpWith(tester, [_user('question text'), _asst('answer text')]); // Focus the selection region, select all, copy. final region = find.byType(ClideSelectionArea); expect(region, findsOneWidget); await tester.tap(region); await tester.pump(); Future keys(LogicalKeyboardKey k) async { await tester.sendKeyDownEvent(LogicalKeyboardKey.control); await tester.sendKeyDownEvent(k); await tester.sendKeyUpEvent(k); await tester.sendKeyUpEvent(LogicalKeyboardKey.control); await tester.pump(); } await keys(LogicalKeyboardKey.keyA); await keys(LogicalKeyboardKey.keyC); await tester.pump(); final copied = clipboard.text ?? ''; expect(copied, contains('question text')); expect(copied, contains('answer text')); }); }); group('ClaudeBanner', () { late KernelFixture f; setUp(() async => f = await KernelFixture.create()); tearDown(() => f.dispose()); testWidgets('shows role, workspace, status, and a hint', (tester) async { await tester.pumpWidget(harness( f, const ClaudeBanner( role: 'primary', workspace: '/work/space', statusLine: 'tmux · clide-claude-x', ), )); await tester.pump(); expect(find.text('Claude'), findsOneWidget); expect(find.text('primary'), findsOneWidget); expect(find.text('/work/space'), findsOneWidget); expect(find.text('tmux · clide-claude-x'), findsOneWidget); expect(find.textContaining('Warming up'), findsOneWidget); }); }); }