From e2b43b2a86ba98771d04d077e226278fc378c4a3 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Mon, 1 Jun 2026 14:35:18 +0200 Subject: [PATCH] tickets reader: adopt retained ReaderNav + static tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bring the tickets detail in line with markdown/decisions (D-81). The controller loads on 'load' (the channel the retained ReaderNav emits), the extension reveals the static tickets.detail tab on selection instead of the per-click uncontribute/contribute churn (the T-188 anti-pattern), and the view grabs nav.current on mount and wraps in ClidePaneChrome with a ReaderActionBar — pin toggle left, back/forward + jump-to-pin right, no edit pencil (tickets are pql records, not files). The controller drops its now-unused panels dependency. Also adds the tickets builtin's first tests — the sidebar list (load/sections/filter/select/empty/error/refresh) and the detail reader (load, nav, pin, parents/decisions/status) — covering a pre-existing gap exposed by bringing these files under test. Co-Authored-By: Claude Opus 4.8 (1M context) --- .pql/changelog/ticket_history/2026-06.sql | 2 + .pql/changelog/tickets/2026-06.sql | 1 + CHANGELOG.md | 8 +- lib/builtin/tickets/src/extension.dart | 17 +- .../tickets/src/ticket_detail_controller.dart | 14 +- .../tickets/src/ticket_detail_view.dart | 96 ++++++---- test/builtin/tickets/ticket_detail_test.dart | 171 ++++++++++++++++++ test/builtin/tickets/tickets_view_test.dart | 127 +++++++++++++ 8 files changed, 382 insertions(+), 54 deletions(-) create mode 100644 test/builtin/tickets/ticket_detail_test.dart create mode 100644 test/builtin/tickets/tickets_view_test.dart diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index c9bb92c8..b916e66d 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -3,3 +3,5 @@ INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-197', 'status', 'backlog', 'done', NULL, '2026-06-01 10:45:14', '2026-06-01 10:45:14', '2026-06-01 10:45:14', NULL, '7d9a5a6040ccaff63f95c106c088f7b5', 1) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-196', 'status', 'backlog', 'done', NULL, '2026-06-01 10:45:14', '2026-06-01 10:45:14', '2026-06-01 10:45:14', NULL, 'a6afc45a7b24dc8dc65a0a6cda319f7d', 1) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-198', 'status', 'backlog', 'done', NULL, '2026-06-01 11:31:39', '2026-06-01 11:31:39', '2026-06-01 11:31:39', NULL, '5abb12dc3c3b72a4bccf1760204fe1ae', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-199', 'status', 'backlog', 'in_progress', NULL, '2026-06-01 11:38:40', '2026-06-01 11:38:40', '2026-06-01 11:38:40', NULL, '44bad6e0417988025dae8606879de2a4', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-199', 'status', 'in_progress', 'done', NULL, '2026-06-01 12:35:04', '2026-06-01 12:35:04', '2026-06-01 12:35:04', NULL, '56c14e217540c5789e70317aa8b6461f', 1) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index 1e0d6881..b5f114cf 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -3,3 +3,4 @@ INSERT INTO tickets (id, type, parent_id, title, description, status, priority, INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-197', 'bug', NULL, 'Editor pane never reveals on editor.open (edit button, file-tree open)', 'Opening a file in the editor (the reader''s edit pencil, a non-.md file-tree click, a decision''s edit) calls editor.open and the daemon opens the buffer, but the editor pane never appears in the workspace slot above the Claude pane. Root cause: EditorExtension contributes a workspace tab (editor.active, priority 80) but has no activate() that reveals/activates it. editor.opened is emitted on the DaemonBus, but the EditorController that handles it only exists once EditorView is mounted — and nothing ever activates the editor tab to mount it. Fix: add EditorExtension.activate() subscribing to the editor.opened / editor.active-changed DaemonEvents and calling panels.activateTab(Slots.workspace, ''editor.active''). EditorView.hydrate() already pulls the active buffer on mount, so reveal-then-hydrate avoids any publish/subscribe race.', 'done', 'high', NULL, NULL, NULL, '2026-06-01 08:58:19', '2026-06-01 10:45:14', NULL, '05ae54e31d0c9befdbfef2fe096737c0', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-196', 'bug', 'T-186', 'Decision reader: first click switches tab but doesn''t load (lost on tab mount)', 'Clicking a D-record activates the decisions.detail context-panel tab but does not load the record; only the SECOND click (any record) works. Root cause: DecisionDetailView subscribes to the ''selection'' MessageBus channel in didChangeDependencies, which runs only AFTER the extension''s activateTab mounts the view. The bus is broadcast (no replay), so the ''selection'' message that triggered the reveal is already gone — the view subscribes too late and misses it. The markdown extension already solved this: activateTab, then addPostFrameCallback -> re-publish on a separate ''load'' channel the viewer subscribes to, so the load fires after the viewer mounts+subscribes. Fix: mirror that — DecisionDetailView subscribes to ''load''; DecisionsExtension forwards ''selection'' -> post-frame ''load'' after activateTab.', 'done', 'high', NULL, NULL, NULL, '2026-06-01 08:58:10', '2026-06-01 10:45:14', NULL, 'c87a8726edd727831291bd1055e39ae1', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-198', 'task', 'T-186', 'Reader pin UX: pin icon, toggle, left-toggle/right-navigate', 'Polish on the reader action bar (follows T-190). (1) push-pin glyph instead of the chain/link icon; (2) the pin button TOGGLES pinned state (tap to pin current, tap again to unpin) rather than set/replace; (3) layout split — pin/unpin toggle on the LEFT, jump-to-pin moved into the navigator (back/forward) on the right. Added ReaderNav.togglePin(), PhosphorIcons.pushPin (0xe3e2) + arrowUUpLeft (0xe08a), and an active state on the action button.', 'done', 'low', NULL, NULL, NULL, '2026-06-01 11:11:32', '2026-06-01 11:31:39', NULL, 'd2392d2e56ffc552c8f4f8993350d939', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-199', 'task', 'T-186', 'Tickets detail: adopt retained ReaderNav (back/forward + pin) + static tab', 'Bring the tickets detail reader in line with markdown/decisions (T-196/D-81). Adopt the retained ReaderNav(''builtin.tickets'', dataKey ''id''): the nav records selections + emits ''load''; the controller loads on ''load'' (not ''selection''); the view grabs nav.current on mount and wraps in ClidePaneChrome with a ReaderActionBar (pin toggle left; back/forward + jump-to-pin right; NO edit pencil since tickets are pql records, not files). Also drop the per-click uncontribute/contribute churn in TicketsExtension (the T-188 anti-pattern) — use the static tickets.detail tab + reveal on selection. TicketDetailController loses its panels dependency (reveal moves to the extension).', 'done', 'medium', NULL, NULL, NULL, '2026-06-01 11:38:32', '2026-06-01 12:35:04', NULL, '82fe1aefd26d3f53872cc6983d1ae52d', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); diff --git a/CHANGELOG.md b/CHANGELOG.md index 28eba7a5..f977faa0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,9 +40,11 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. `.clideignore` pair — the single ignore knob clide owns. (T-52) - `files.walk` command — a recursive, ignore-pruned, capped flat file listing of the workspace, backing quick-open and search. (T-51, T-52) -- Sidebar readers (markdown + decision) gain a chrome action bar: a pin/unpin - toggle on the left, then a navigator (back, forward, jump-to-pin) and an edit - pencil on the right. (T-189, T-190, T-191, T-198) +- Sidebar readers (markdown, decision, and ticket) gain a chrome action bar: a + pin/unpin toggle on the left, then a navigator (back, forward, jump-to-pin) and + an edit pencil on the right (no pencil for tickets — they're not files). The + ticket reader joins the shared retained nav and drops its per-click tab churn. + (T-189, T-190, T-191, T-198, T-199) ### Fixed diff --git a/lib/builtin/tickets/src/extension.dart b/lib/builtin/tickets/src/extension.dart index fd13825f..a3b09dd5 100644 --- a/lib/builtin/tickets/src/extension.dart +++ b/lib/builtin/tickets/src/extension.dart @@ -20,17 +20,14 @@ class TicketsExtension extends ClideExtension { @override Future activate(ClideExtensionContext ctx) async { + // Ensure the retained nav exists so it records selections + emits + // loads whether or not the detail view is mounted (T-199/D-81). + ctx.readerNav.navFor(id, dataKey: 'id'); + // Reveal the static detail tab on selection — no per-click + // uncontribute/contribute churn (the T-188 anti-pattern). The nav + // owns load + history. _sub = ctx.messages.subscribe(publisher: id, channel: 'selection').listen((msg) { - final selectedId = msg.data['id'] as String?; - if (selectedId == null) return; - ctx.panels.uncontribute('tickets.detail'); - ctx.panels.contribute(TabContribution( - id: 'tickets.detail', - slot: Slots.contextPanel, - title: 'Ticket', - icon: PhosphorIcons.ticket, - build: (_) => TicketDetailView(initialId: selectedId), - )); + if (msg.data['id'] is! String) return; ctx.arrangement.setVisible(Slots.contextPanel, true); ctx.arrangement.setCollapsed(Slots.contextPanel, false); ctx.panels.activateTab(Slots.contextPanel, 'tickets.detail'); diff --git a/lib/builtin/tickets/src/ticket_detail_controller.dart b/lib/builtin/tickets/src/ticket_detail_controller.dart index 2ba54721..241e0f7c 100644 --- a/lib/builtin/tickets/src/ticket_detail_controller.dart +++ b/lib/builtin/tickets/src/ticket_detail_controller.dart @@ -21,13 +21,14 @@ class TicketDetail { } class TicketDetailController extends ChangeNotifier { - TicketDetailController({required this.ipc, required this.messages, required this.panels}) { - _sub = messages.subscribe(publisher: 'builtin.tickets', channel: 'selection').listen(_onSelection); + TicketDetailController({required this.ipc, required this.messages}) { + // Load on 'load' — the single channel the retained ReaderNav emits + // (T-199). The extension reveals the tab; the nav owns history. + _sub = messages.subscribe(publisher: 'builtin.tickets', channel: 'load').listen(_onLoad); } final DaemonClient ipc; final MessageBus messages; - final PanelRegistry panels; StreamSubscription? _sub; TicketDetail? _detail; @@ -36,12 +37,9 @@ class TicketDetailController extends ChangeNotifier { bool _loading = false; bool get loading => _loading; - void _onSelection(Message msg) { + void _onLoad(Message msg) { final id = msg.data['id'] as String?; - if (id != null) { - panels.activateTab(Slots.contextPanel, 'tickets.detail'); - unawaited(load(id)); - } + if (id != null) unawaited(load(id)); } Future load(String id) async { diff --git a/lib/builtin/tickets/src/ticket_detail_view.dart b/lib/builtin/tickets/src/ticket_detail_view.dart index fe114dde..b9874c1c 100644 --- a/lib/builtin/tickets/src/ticket_detail_view.dart +++ b/lib/builtin/tickets/src/ticket_detail_view.dart @@ -1,3 +1,4 @@ +import 'package:clide/builtin/shared/reader_chrome.dart'; import 'package:clide/builtin/tickets/src/ticket_colors.dart'; import 'package:clide/builtin/tickets/src/ticket_detail_controller.dart'; import 'package:clide/kernel/kernel.dart'; @@ -14,24 +15,37 @@ class TicketDetailView extends StatefulWidget { class _TicketDetailViewState extends State { TicketDetailController? _controller; + ReaderNav? _nav; @override void didChangeDependencies() { super.didChangeDependencies(); if (_controller != null) return; final kernel = ClideKernel.of(context); - _controller = TicketDetailController(ipc: kernel.ipc, messages: kernel.messages, panels: kernel.panels); - if (widget.initialId != null) { - _controller!.load(widget.initialId!); - } + _controller = TicketDetailController(ipc: kernel.ipc, messages: kernel.messages); + _nav = kernel.readerNav.navFor('builtin.tickets', dataKey: 'id')..addListener(_onNavChanged); + // Grab the entry the retained nav already holds (a selection that + // revealed this tab before we mounted), else the initialId. + final current = _nav!.current ?? widget.initialId; + if (current != null) _controller!.load(current); + } + + void _onNavChanged() { + if (mounted) setState(() {}); // refresh action-bar button state } @override void dispose() { + _nav?.removeListener(_onNavChanged); _controller?.dispose(); super.dispose(); } + void _onBack() => _nav?.back(); + void _onForward() => _nav?.forward(); + void _onPin() => _nav?.togglePin(); + void _onJumpToPin() => _nav?.jumpToPin(); + void _navigateToRecord(BuildContext context, String id) { final kernel = ClideKernel.of(context); if (id.startsWith('T-')) { @@ -56,37 +70,53 @@ class _TicketDetailViewState extends State { final isDark = ClideTheme.of(ctx).dark; final typeColors = TicketTypeColors.forTheme(dark: isDark); - return SingleChildScrollView( - padding: const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _TicketHeader(detail: d, tokens: tokens, typeColors: typeColors), - const SizedBox(height: 12), - _StatusControls(detail: d, tokens: tokens, controller: c), - if (d.description != null && d.description!.isNotEmpty) ...[ + return ClidePaneChrome( + title: d.id, + subtitle: d.title, + trailing: [ + ReaderActionBar( + canGoBack: _nav?.canGoBack ?? false, + canGoForward: _nav?.canGoForward ?? false, + hasPinned: _nav?.hasPinned ?? false, + onBack: (_nav?.canGoBack ?? false) ? _onBack : null, + onForward: (_nav?.canGoForward ?? false) ? _onForward : null, + onPin: _onPin, + onJumpToPin: (_nav?.hasPinned ?? false) ? _onJumpToPin : null, + onEdit: null, // tickets are pql records, not files + ), + ], + child: SingleChildScrollView( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _TicketHeader(detail: d, tokens: tokens, typeColors: typeColors), const SizedBox(height: 12), - ClideMarkdown(d.description!, onRecordTap: (id) => _navigateToRecord(ctx, id)), + _StatusControls(detail: d, tokens: tokens, controller: c), + if (d.description != null && d.description!.isNotEmpty) ...[ + const SizedBox(height: 12), + ClideMarkdown(d.description!, onRecordTap: (id) => _navigateToRecord(ctx, id)), + ], + if (d.parents.isNotEmpty) ...[ + const SizedBox(height: 16), + _SectionLabel(label: 'PARENT TREE', tokens: tokens), + const SizedBox(height: 6), + for (var i = 0; i < d.parents.length; i++) + _CompactCard( + data: d.parents[i], + tokens: tokens, + typeColors: typeColors, + indent: i, + ), + ], + if (d.decisions.isNotEmpty) ...[ + const SizedBox(height: 16), + _SectionLabel(label: 'REFERENCED DECISIONS', tokens: tokens), + const SizedBox(height: 6), + for (final dec in d.decisions) _DecisionRefCard(data: dec, tokens: tokens), + ], ], - if (d.parents.isNotEmpty) ...[ - const SizedBox(height: 16), - _SectionLabel(label: 'PARENT TREE', tokens: tokens), - const SizedBox(height: 6), - for (var i = 0; i < d.parents.length; i++) - _CompactCard( - data: d.parents[i], - tokens: tokens, - typeColors: typeColors, - indent: i, - ), - ], - if (d.decisions.isNotEmpty) ...[ - const SizedBox(height: 16), - _SectionLabel(label: 'REFERENCED DECISIONS', tokens: tokens), - const SizedBox(height: 6), - for (final dec in d.decisions) _DecisionRefCard(data: dec, tokens: tokens), - ], - ], + ), ), ); }, diff --git a/test/builtin/tickets/ticket_detail_test.dart b/test/builtin/tickets/ticket_detail_test.dart new file mode 100644 index 00000000..f0a4914c --- /dev/null +++ b/test/builtin/tickets/ticket_detail_test.dart @@ -0,0 +1,171 @@ +/// Tests for the tickets detail reader after the ReaderNav migration +/// (T-199): the controller loads on 'load', the extension reveals a +/// static tab (no per-click churn), and the view drives back/forward + +/// pin through the retained nav. +library; + +import 'package:clide/builtin/tickets/src/extension.dart'; +import 'package:clide/builtin/tickets/src/ticket_detail_controller.dart'; +import 'package:clide/builtin/tickets/src/ticket_detail_view.dart'; +import 'package:clide/clide.dart'; +import 'package:clide/extension/extension.dart' show LayoutPresetContribution, LayoutSlot; +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'; + +IpcResponse _ticket(String id) => IpcResponse.ok(id: '', data: { + 'id': id, + 'title': 'Ticket $id', + 'type': 'task', + 'status': 'backlog', + 'priority': 'medium', + 'description': 'Body of $id', + 'ancestors': [], + 'decisions': [], + }); + +void main() { + group('TicketDetailController — loads on load (T-199)', () { + late KernelFixture f; + TicketDetailController? c; + setUp(() async { + f = await KernelFixture.create(); + f.ipc.stub('pql.tickets.show', (args) async => _ticket(args['id'] as String? ?? '?')); + }); + tearDown(() async { + c?.dispose(); + c = null; + await f.dispose(); + }); + + test('a load message loads the ticket', () async { + c = TicketDetailController(ipc: f.ipc, messages: f.services.messages); + f.services.messages.publish('builtin.tickets', 'load', {'id': 'T-1'}); + await Future.delayed(Duration.zero); + expect(c!.detail?.id, 'T-1'); + }); + + test('a bare selection does NOT load (the nav re-emits as load)', () async { + c = TicketDetailController(ipc: f.ipc, messages: f.services.messages); + f.services.messages.publish('builtin.tickets', 'selection', {'id': 'T-9'}); + await Future.delayed(Duration.zero); + expect(c!.detail, isNull); + }); + }); + + group('TicketsExtension — static tab reveal (T-199)', () { + late KernelFixture f; + setUp(() async { + f = await KernelFixture.create(); + f.services.panels.registerSlot(const SlotDefinition(id: Slots.contextPanel, position: SlotPosition.right)); + f.services.arrangement.applyPreset(const LayoutPresetContribution( + id: 'test', + displayName: 'test', + slots: [LayoutSlot(slot: Slots.contextPanel, position: SlotPosition.right, visible: false)], + )); + f.services.extensions.register(TicketsExtension()); + await f.services.extensions.activate('builtin.tickets'); + }); + tearDown(() => f.dispose()); + + test('selection reveals + activates the static detail tab without churn', () async { + f.services.messages.publish('builtin.tickets', 'selection', {'id': 'T-1'}); + await Future.delayed(Duration.zero); + f.services.messages.publish('builtin.tickets', 'selection', {'id': 'T-2'}); + await Future.delayed(Duration.zero); + expect(f.services.panels.activeTabIn(Slots.contextPanel), 'tickets.detail'); + expect(f.services.panels.tabsFor(Slots.contextPanel).where((t) => t.id == 'tickets.detail').length, 1); + expect(f.services.arrangement.isVisible(Slots.contextPanel), isTrue); + }); + }); + + group('TicketDetailView — nav-driven (T-199)', () { + late KernelFixture f; + setUp(() async { + f = await KernelFixture.create(); + f.ipc.stub('pql.tickets.show', (args) async => _ticket(args['id'] as String? ?? '?')); + }); + tearDown(() => f.dispose()); + + Future open(WidgetTester tester, String id) async { + f.services.readerNav.navFor('builtin.tickets', dataKey: 'id').open(id); + await pumpAsync(tester); + } + + Future pumpView(WidgetTester tester) async { + tester.view.physicalSize = const Size(600, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(() { + tester.view.resetPhysicalSize(); + tester.view.resetDevicePixelRatio(); + }); + await tester.pumpWidget(harness(f, const TicketDetailView())); + await pumpAsync(tester); + } + + testWidgets('opening a ticket loads it; back returns to the previous', (tester) async { + await pumpView(tester); + await open(tester, 'T-1'); + await open(tester, 'T-2'); + expect(find.text('Ticket T-2'), findsWidgets); + + await tester.tap(find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Back' && (w.properties.enabled ?? true)).first); + await pumpAsync(tester); + expect(find.text('Ticket T-1'), findsWidgets); + }); + + testWidgets('pin toggle shows jump-to-pin and toggles off', (tester) async { + await pumpView(tester); + await open(tester, 'T-1'); + await tester.tap(find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Pin').first); + await pumpAsync(tester); + expect(find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Jump to pin'), findsOneWidget); + await tester.tap(find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Unpin').first); + await pumpAsync(tester); + expect(find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Jump to pin'), findsNothing); + }); + + testWidgets('renders parents, decisions, assignee, and applies a status change', (tester) async { + Map? statusArgs; + f.ipc.stub( + 'pql.tickets.show', + (args) async => IpcResponse.ok(id: '', data: { + 'id': 'T-1', + 'title': 'Rich ticket', + 'type': 'task', + 'status': 'backlog', + 'priority': 'high', + 'assigned_to': 'alice', + 'description': 'body', + 'ancestors': [ + {'id': 'T-9', 'title': 'Parent epic', 'type': 'epic'}, + ], + 'decisions': [ + {'id': 'D-1', 'title': 'Decision one', 'type': 'confirmed', 'domain': 'architecture'}, + ], + })); + f.ipc.stub('pql.tickets.status', (args) async { + statusArgs = args; + return IpcResponse.ok(id: '', data: const {}); + }); + await pumpView(tester); + await open(tester, 'T-1'); + + expect(find.text('high'), findsWidgets); + expect(find.textContaining('assigned: alice'), findsOneWidget); + expect(find.text('PARENT TREE'), findsOneWidget); + expect(find.text('Parent epic'), findsOneWidget); + expect(find.text('REFERENCED DECISIONS'), findsOneWidget); + expect(find.text('Decision one'), findsOneWidget); + + // Tap the READY status control (current is backlog → tappable). + await tester.tap(find.text('READY')); + await pumpAsync(tester); + expect(statusArgs?['status'], 'ready'); + expect(statusArgs?['ids'], ['T-1']); + }); + }); +} diff --git a/test/builtin/tickets/tickets_view_test.dart b/test/builtin/tickets/tickets_view_test.dart new file mode 100644 index 00000000..f2663e1a --- /dev/null +++ b/test/builtin/tickets/tickets_view_test.dart @@ -0,0 +1,127 @@ +/// 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('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)); + }); +}