/// Tests for the tickets sidebar list (TicketsView): load, sectioned /// rendering, filtering, card selection, and the empty/error states. library; import 'package:clide/builtin/tickets/src/tickets_view.dart'; import 'package:clide/clide.dart'; import 'package:clide/kernel/kernel.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import '../../helpers/kernel_fixture.dart'; import '../../helpers/widget_harness.dart'; Map _t(String id, String title, String status, {String? type, String? parentId}) => { 'id': id, 'title': title, 'status': status, 'type': type ?? 'task', 'priority': 'medium', if (parentId != null) 'parent_id': parentId, }; IpcResponse _list(List> tickets) => IpcResponse.ok(id: '', data: {'tickets': tickets}); void main() { late KernelFixture f; setUp(() async { f = await KernelFixture.create(); }); tearDown(() => f.dispose()); Future pumpView(WidgetTester tester) async { tester.view.physicalSize = const Size(600, 900); tester.view.devicePixelRatio = 1.0; addTearDown(() { tester.view.resetPhysicalSize(); tester.view.resetDevicePixelRatio(); }); await tester.pumpWidget(harness(f, const TicketsView())); await pumpAsync(tester); } testWidgets('shows a loading placeholder until the list resolves', (tester) async { f.ipc.stub('pql.tickets.list', (_) async => _list([_t('T-1', 'First', 'backlog')])); await tester.pumpWidget(harness(f, const TicketsView())); expect(find.text('Loading tickets...'), findsOneWidget); await pumpAsync(tester); expect(find.text('Loading tickets...'), findsNothing); }); testWidgets('renders sectioned cards from the loaded list', (tester) async { f.ipc.stub( 'pql.tickets.list', (_) async => _list([ _t('T-1', 'Active thing', 'in_progress', parentId: 'T-9'), _t('T-2', 'Queued thing', 'backlog'), ])); await pumpView(tester); expect(find.textContaining('IN PROGRESS'), findsOneWidget); expect(find.textContaining('BACKLOG'), findsOneWidget); expect(find.text('T-1'), findsOneWidget); expect(find.text('Active thing'), findsOneWidget); expect(find.text('Queued thing'), findsOneWidget); // parent breadcrumb on T-1 expect(find.text('T-9'), findsOneWidget); }); testWidgets('filter narrows the visible cards', (tester) async { f.ipc.stub( 'pql.tickets.list', (_) async => _list([ _t('T-1', 'Alpha', 'backlog'), _t('T-2', 'Beta', 'backlog'), ])); await pumpView(tester); expect(find.text('Alpha'), findsOneWidget); await tester.enterText(find.byType(EditableText).first, 'beta'); await tester.pump(const Duration(milliseconds: 250)); expect(find.text('Beta'), findsOneWidget); expect(find.text('Alpha'), findsNothing); }); testWidgets('tapping a card publishes a selection', (tester) async { f.ipc.stub('pql.tickets.list', (_) async => _list([_t('T-1', 'Tap me', 'backlog')])); final selections = []; final sub = f.services.messages.subscribe(publisher: 'builtin.tickets', channel: 'selection').listen(selections.add); addTearDown(sub.cancel); await pumpView(tester); await tester.tap(find.text('Tap me')); await pumpAsync(tester); expect(selections.single.data['id'], 'T-1'); }); testWidgets('tapping the parent breadcrumb selects the parent (T-281)', (tester) async { f.ipc.stub('pql.tickets.list', (_) async => _list([_t('T-1', 'Child', 'backlog', parentId: 'T-9')])); final selections = []; final sub = f.services.messages.subscribe(publisher: 'builtin.tickets', channel: 'selection').listen(selections.add); addTearDown(sub.cancel); await pumpView(tester); await tester.tap(find.text('T-9')); // the muted parent breadcrumb await pumpAsync(tester); expect(selections.single.data['id'], 'T-9'); }); testWidgets('empty list shows the placeholder', (tester) async { f.ipc.stub('pql.tickets.list', (_) async => _list(const [])); await pumpView(tester); expect(find.textContaining('No tickets'), findsOneWidget); }); testWidgets('a load error is surfaced', (tester) async { f.ipc.stub( 'pql.tickets.list', (_) async => IpcResponse.err( id: '', error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'boom'), )); await pumpView(tester); expect(find.text('boom'), findsOneWidget); }); testWidgets('a changed event triggers a refresh', (tester) async { var calls = 0; f.ipc.stub('pql.tickets.list', (_) async { calls++; return _list([_t('T-1', 'Thing', 'backlog')]); }); await pumpView(tester); final before = calls; f.services.messages.publish('builtin.tickets', 'changed', {'id': 'T-1'}); await pumpAsync(tester); expect(calls, greaterThan(before)); }); testWidgets('refetches when the workspace opens (T-352)', (tester) async { // The first load can fire before the daemon's pql workDir is the repo; a // ProjectOpened (fired after the IPC server swaps) must re-fetch. var calls = 0; f.ipc.stub('pql.tickets.list', (_) async { calls++; return _list([_t('T-1', 'Thing', 'backlog')]); }); await pumpView(tester); final before = calls; f.services.events.emit(const ProjectOpened(path: '/repo')); await pumpAsync(tester); expect(calls, greaterThan(before)); }); // -- Type-filter chips (T-343) ------------------------------------------ // The chips own one GestureDetector for both onTap (toggle) and onDoubleTap // (solo), so a single tap's onTap only fires after the ~300ms double-tap // window — hence the 350ms pumps below. Future loadTwoTypes(WidgetTester tester) async { f.ipc.stub( 'pql.tickets.list', (_) async => _list([ _t('T-1', 'a bug item', 'backlog', type: 'bug'), _t('T-2', 'a task item', 'backlog', type: 'task'), ])); await pumpView(tester); } testWidgets('renders a chip per type, large→small (T-343)', (tester) async { f.ipc.stub('pql.tickets.list', (_) async => _list([_t('T-1', 'x', 'backlog')])); await pumpView(tester); for (final label in ['Initiative', 'Epic', 'Story', 'Task', 'Bug']) { expect(find.text(label), findsOneWidget); } }); testWidgets('single-click toggles a type out, others stay (T-343)', (tester) async { await loadTwoTypes(tester); expect(find.text('a bug item'), findsOneWidget); await tester.tap(find.text('Bug')); await tester.pump(const Duration(milliseconds: 350)); expect(find.text('a bug item'), findsNothing); expect(find.text('a task item'), findsOneWidget); }); testWidgets('double-click isolates a type (solo) (T-343)', (tester) async { await loadTwoTypes(tester); await tester.tap(find.text('Bug')); await tester.pump(const Duration(milliseconds: 50)); await tester.tap(find.text('Bug')); await tester.pump(const Duration(milliseconds: 350)); expect(find.text('a bug item'), findsOneWidget); expect(find.text('a task item'), findsNothing); }); testWidgets('toggling off the last enabled type resets all on (T-343)', (tester) async { await loadTwoTypes(tester); // Solo Bug → task hidden. await tester.tap(find.text('Bug')); await tester.pump(const Duration(milliseconds: 50)); await tester.tap(find.text('Bug')); await tester.pump(const Duration(milliseconds: 350)); expect(find.text('a task item'), findsNothing); // Toggle the only-remaining Bug off → snaps all back on → task returns. await tester.tap(find.text('Bug')); await tester.pump(const Duration(milliseconds: 350)); expect(find.text('a task item'), findsOneWidget); expect(find.text('a bug item'), findsOneWidget); }); }