tickets reader: adopt retained ReaderNav + static tab
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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': <Object?>[],
|
||||
'decisions': <Object?>[],
|
||||
});
|
||||
|
||||
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<void>.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<void>.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<void>.delayed(Duration.zero);
|
||||
f.services.messages.publish('builtin.tickets', 'selection', {'id': 'T-2'});
|
||||
await Future<void>.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<void> open(WidgetTester tester, String id) async {
|
||||
f.services.readerNav.navFor('builtin.tickets', dataKey: 'id').open(id);
|
||||
await pumpAsync(tester);
|
||||
}
|
||||
|
||||
Future<void> 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<String, Object?>? 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']);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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<String, Object?> _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<Map<String, Object?>> tickets) => IpcResponse.ok(id: '', data: {'tickets': tickets});
|
||||
|
||||
void main() {
|
||||
late KernelFixture f;
|
||||
|
||||
setUp(() async {
|
||||
f = await KernelFixture.create();
|
||||
});
|
||||
tearDown(() => f.dispose());
|
||||
|
||||
Future<void> 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 = <Message>[];
|
||||
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));
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user