Files
clide/test/builtin/tickets/tickets_view_test.dart
T
jpmschweitzerandClaude Opus 4.8 a0501b4a0a pql: refetch ticket + decision sidebars when the workspace opens (T-352)
On a desktop launch the daemon's PqlClient boots with workDir set to the
launch CWD (e.g. HOME), not the repo — swapIpcServer only rewires it once
the project opens. The tickets and decisions panes fire their first pql
fetch before that swap, so pql runs in the wrong directory against a
stale/global pql.db and the pane errors (observed:
"ticket_deps.blocker_record_id missing — pql.db is from an earlier
schema"). A manual refresh worked because by then the workspace was open.

This is a wrong-workDir timing issue, not db-busy, so the T-350 retry
doesn't catch it. Both panes now re-fetch on ProjectOpened, which fires
after the IPC server swaps to the project workRoot.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 20:38:44 +02:00

218 lines
7.9 KiB
Dart

/// 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('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 = <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('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<void> 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);
});
}