add a reader chrome: back/forward, pin, and edit-pencil
The markdown and decision sidebar readers gain a shared action bar. A new lib/builtin/shared/reader_chrome.dart provides ReaderHistory (browser-style back/forward stack — push truncates forward), a ReaderHistoryMixin that also holds a single pin slot, and a ReaderActionBar widget. Both readers push to history only on external selection; back/forward and jump-to-pin reload in-place without re-publishing a selection (no bus churn / no decision-tab re-trigger). The edit pencil opens the current doc in the editor (editor.open) — the markdown path, or the decision's file_path. T-189, T-190, T-191. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
/// T-188: Decision reader — static-tab fix.
|
||||
/// T-189, T-190, T-191: back/forward, pin, edit pencil in DecisionDetailView.
|
||||
///
|
||||
/// Verifies that clicking a decision opens it in the static `decisions.detail`
|
||||
/// context-panel tab (no per-click uncontribute/contribute churn), that a
|
||||
@@ -61,7 +62,7 @@ void _select(KernelFixture f, String id) {
|
||||
// IPC stub: pql.decisions.read
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
IpcResponse _decisionResponse(String id) => IpcResponse.ok(
|
||||
IpcResponse _decisionResponse(String id, {String? filePath}) => IpcResponse.ok(
|
||||
id: '',
|
||||
data: {
|
||||
'id': id,
|
||||
@@ -72,6 +73,7 @@ IpcResponse _decisionResponse(String id) => IpcResponse.ok(
|
||||
'date': '2026-01-01',
|
||||
'body': 'Body of $id.',
|
||||
'refs': <Object?>[],
|
||||
'file_path': filePath ?? 'governance/decisions/architecture.md',
|
||||
},
|
||||
);
|
||||
|
||||
@@ -224,8 +226,10 @@ void main() {
|
||||
|
||||
testWidgets('loads and renders initialId on first mount', (tester) async {
|
||||
await pumpView(tester, initialId: 'D-1');
|
||||
expect(find.text('D-1'), findsOneWidget);
|
||||
expect(find.text('Decision D-1'), findsOneWidget);
|
||||
// The id appears in the pane-chrome title AND in the body card, so
|
||||
// findsWidgets (≥1) is the right matcher.
|
||||
expect(find.text('D-1'), findsWidgets);
|
||||
expect(find.text('Decision D-1'), findsWidgets);
|
||||
});
|
||||
|
||||
testWidgets('selection message loads a decision into the view', (tester) async {
|
||||
@@ -238,30 +242,31 @@ void main() {
|
||||
// Give the broadcast stream a microtask to deliver.
|
||||
await pumpAsync(tester);
|
||||
|
||||
expect(find.text('D-7'), findsOneWidget);
|
||||
expect(find.text('Decision D-7'), findsOneWidget);
|
||||
// Id appears in both pane header and body card.
|
||||
expect(find.text('D-7'), findsWidgets);
|
||||
expect(find.text('Decision D-7'), findsWidgets);
|
||||
});
|
||||
|
||||
testWidgets('second selection switches the displayed decision', (tester) async {
|
||||
await pumpView(tester, initialId: 'D-1');
|
||||
expect(find.text('Decision D-1'), findsOneWidget);
|
||||
expect(find.text('Decision D-1'), findsWidgets);
|
||||
|
||||
f.services.messages.publish('builtin.decisions', 'selection', {'id': 'D-2'});
|
||||
await pumpAsync(tester);
|
||||
|
||||
expect(find.text('Decision D-2'), findsOneWidget);
|
||||
expect(find.text('Decision D-2'), findsWidgets);
|
||||
expect(find.text('Decision D-1'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('clicking the same decision twice leaves view stable', (tester) async {
|
||||
await pumpView(tester, initialId: 'D-5');
|
||||
expect(find.text('Decision D-5'), findsOneWidget);
|
||||
expect(find.text('Decision D-5'), findsWidgets);
|
||||
|
||||
f.services.messages.publish('builtin.decisions', 'selection', {'id': 'D-5'});
|
||||
await pumpAsync(tester);
|
||||
|
||||
// Still shows D-5, no crash, no duplicate.
|
||||
expect(find.text('Decision D-5'), findsOneWidget);
|
||||
// Still shows D-5, no crash.
|
||||
expect(find.text('Decision D-5'), findsWidgets);
|
||||
});
|
||||
|
||||
testWidgets('rapid sequential selections resolve to the last one', (tester) async {
|
||||
@@ -274,7 +279,8 @@ void main() {
|
||||
|
||||
// The last resolved data should be for D-5 (stub is synchronous so
|
||||
// each load completes before the next, but all 5 fire in order).
|
||||
expect(find.text('Decision D-5'), findsOneWidget);
|
||||
// Title appears in pane header subtitle + body card.
|
||||
expect(find.text('Decision D-5'), findsWidgets);
|
||||
});
|
||||
|
||||
testWidgets('cross-reference tap publishes a new selection', (tester) async {
|
||||
@@ -295,6 +301,7 @@ void main() {
|
||||
'refs': [
|
||||
{'target_id': 'D-11', 'ref_type': 'implements'},
|
||||
],
|
||||
'file_path': 'governance/decisions/architecture.md',
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -302,7 +309,8 @@ void main() {
|
||||
});
|
||||
|
||||
await pumpView(tester, initialId: 'D-10');
|
||||
expect(find.text('D-10'), findsOneWidget);
|
||||
// D-10 appears in pane title + body card; D-11 appears only in the ref card.
|
||||
expect(find.text('D-10'), findsWidgets);
|
||||
expect(find.text('D-11'), findsOneWidget);
|
||||
|
||||
// Tap the ref card — it should publish selection for D-11.
|
||||
@@ -349,6 +357,7 @@ void main() {
|
||||
'date': '2026-01-01',
|
||||
'body': '',
|
||||
'refs': <Object?>[],
|
||||
'file_path': 'governance/questions/architecture.md',
|
||||
},
|
||||
));
|
||||
|
||||
@@ -371,6 +380,7 @@ void main() {
|
||||
'date': '2026-01-01',
|
||||
'body': '',
|
||||
'refs': <Object?>[],
|
||||
'file_path': 'governance/questions/architecture.md',
|
||||
},
|
||||
));
|
||||
|
||||
@@ -393,6 +403,7 @@ void main() {
|
||||
'date': '2026-01-01',
|
||||
'body': '',
|
||||
'refs': <Object?>[],
|
||||
'file_path': 'governance/decisions/architecture.md',
|
||||
},
|
||||
));
|
||||
|
||||
@@ -417,6 +428,7 @@ void main() {
|
||||
'refs': [
|
||||
{'source_id': 'D-5', 'ref_type': 'amends'},
|
||||
],
|
||||
'file_path': 'governance/decisions/architecture.md',
|
||||
},
|
||||
));
|
||||
|
||||
@@ -445,6 +457,7 @@ void main() {
|
||||
'refs': [
|
||||
{'target_id': 'T-123', 'ref_type': 'tracked-by'},
|
||||
],
|
||||
'file_path': 'governance/decisions/architecture.md',
|
||||
},
|
||||
));
|
||||
|
||||
@@ -476,12 +489,14 @@ void main() {
|
||||
'date': '2026-01-15',
|
||||
'body': 'This is the decision body text.',
|
||||
'refs': <Object?>[],
|
||||
'file_path': 'governance/decisions/architecture.md',
|
||||
},
|
||||
));
|
||||
|
||||
await pumpView(tester, initialId: 'D-50');
|
||||
|
||||
expect(find.text('D-50'), findsOneWidget);
|
||||
// D-50 appears in pane title + body card.
|
||||
expect(find.text('D-50'), findsWidgets);
|
||||
expect(find.text('2026-01-15'), findsOneWidget);
|
||||
});
|
||||
|
||||
@@ -498,13 +513,369 @@ void main() {
|
||||
'status': 'active',
|
||||
'body': '',
|
||||
'refs': <Object?>[],
|
||||
'file_path': 'governance/decisions/architecture.md',
|
||||
},
|
||||
));
|
||||
|
||||
await pumpView(tester, initialId: 'D-60');
|
||||
|
||||
expect(find.text('D-60'), findsOneWidget);
|
||||
// D-60 appears in pane title + body card.
|
||||
expect(find.text('D-60'), findsWidgets);
|
||||
// No date text node — no crash.
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// T-189: back/forward navigation
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
group('DecisionDetailView — back/forward (T-189)', () {
|
||||
late KernelFixture f;
|
||||
|
||||
setUp(() async {
|
||||
f = await KernelFixture.create();
|
||||
f.ipc.stub('pql.decisions.read', (args) async {
|
||||
final id = args['id'] as String? ?? 'unknown';
|
||||
return _decisionResponse(id);
|
||||
});
|
||||
});
|
||||
tearDown(() => f.dispose());
|
||||
|
||||
Future<void> pumpView(WidgetTester tester, {String? initialId}) 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, DecisionDetailView(initialId: initialId)));
|
||||
await pumpAsync(tester);
|
||||
}
|
||||
|
||||
testWidgets('back disabled on initial load', (tester) async {
|
||||
await pumpView(tester, initialId: 'D-1');
|
||||
|
||||
expect(
|
||||
find.byWidgetPredicate(
|
||||
(w) => w is Semantics && w.properties.label == 'Back' && w.properties.enabled == false,
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('back enabled after two selections', (tester) async {
|
||||
await pumpView(tester, initialId: 'D-1');
|
||||
f.services.messages.publish('builtin.decisions', 'selection', {'id': 'D-2'});
|
||||
await pumpAsync(tester);
|
||||
|
||||
expect(
|
||||
find.byWidgetPredicate(
|
||||
(w) => w is Semantics && w.properties.label == 'Back' && (w.properties.enabled ?? true),
|
||||
),
|
||||
findsWidgets,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('back navigates to previous decision', (tester) async {
|
||||
await pumpView(tester, initialId: 'D-1');
|
||||
f.services.messages.publish('builtin.decisions', 'selection', {'id': 'D-2'});
|
||||
await pumpAsync(tester);
|
||||
// Title appears in pane header subtitle + body card.
|
||||
expect(find.text('Decision D-2'), findsWidgets);
|
||||
|
||||
final backBtn = find.byWidgetPredicate(
|
||||
(w) => w is Semantics && w.properties.label == 'Back' && (w.properties.enabled ?? true),
|
||||
);
|
||||
await tester.tap(backBtn.first);
|
||||
await pumpAsync(tester);
|
||||
|
||||
expect(find.text('Decision D-1'), findsWidgets);
|
||||
});
|
||||
|
||||
testWidgets('back/forward does NOT re-publish selection bus event', (tester) async {
|
||||
await pumpView(tester, initialId: 'D-1');
|
||||
f.services.messages.publish('builtin.decisions', 'selection', {'id': 'D-2'});
|
||||
await pumpAsync(tester);
|
||||
|
||||
final selections = <Message>[];
|
||||
final sub = f.services.messages.subscribe(publisher: 'builtin.decisions', channel: 'selection').listen(selections.add);
|
||||
addTearDown(sub.cancel);
|
||||
|
||||
// Go back — should NOT publish a selection message.
|
||||
final backBtn = find.byWidgetPredicate(
|
||||
(w) => w is Semantics && w.properties.label == 'Back' && (w.properties.enabled ?? true),
|
||||
);
|
||||
await tester.tap(backBtn.first);
|
||||
await pumpAsync(tester);
|
||||
|
||||
expect(selections, isEmpty, reason: 'back/forward must not churn the selection bus');
|
||||
});
|
||||
|
||||
testWidgets('forward disabled at end of history', (tester) async {
|
||||
await pumpView(tester, initialId: 'D-1');
|
||||
f.services.messages.publish('builtin.decisions', 'selection', {'id': 'D-2'});
|
||||
await pumpAsync(tester);
|
||||
|
||||
expect(
|
||||
find.byWidgetPredicate(
|
||||
(w) => w is Semantics && w.properties.label == 'Forward' && w.properties.enabled == false,
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('forward navigates after back', (tester) async {
|
||||
await pumpView(tester, initialId: 'D-1');
|
||||
f.services.messages.publish('builtin.decisions', 'selection', {'id': 'D-2'});
|
||||
await pumpAsync(tester);
|
||||
|
||||
// Go back to D-1.
|
||||
final backBtn = find.byWidgetPredicate(
|
||||
(w) => w is Semantics && w.properties.label == 'Back' && (w.properties.enabled ?? true),
|
||||
);
|
||||
await tester.tap(backBtn.first);
|
||||
await pumpAsync(tester);
|
||||
expect(find.text('Decision D-1'), findsWidgets);
|
||||
|
||||
// Go forward to D-2.
|
||||
final fwdBtn = find.byWidgetPredicate(
|
||||
(w) => w is Semantics && w.properties.label == 'Forward' && (w.properties.enabled ?? true),
|
||||
);
|
||||
await tester.tap(fwdBtn.first);
|
||||
await pumpAsync(tester);
|
||||
expect(find.text('Decision D-2'), findsWidgets);
|
||||
});
|
||||
|
||||
testWidgets('new selection truncates forward history', (tester) async {
|
||||
await pumpView(tester, initialId: 'D-1');
|
||||
f.services.messages.publish('builtin.decisions', 'selection', {'id': 'D-2'});
|
||||
await pumpAsync(tester);
|
||||
|
||||
// Go back to D-1.
|
||||
final backBtn = find.byWidgetPredicate(
|
||||
(w) => w is Semantics && w.properties.label == 'Back' && (w.properties.enabled ?? true),
|
||||
);
|
||||
await tester.tap(backBtn.first);
|
||||
await pumpAsync(tester);
|
||||
|
||||
// Load D-3 — truncates D-2 forward history.
|
||||
f.services.messages.publish('builtin.decisions', 'selection', {'id': 'D-3'});
|
||||
await pumpAsync(tester);
|
||||
|
||||
expect(
|
||||
find.byWidgetPredicate(
|
||||
(w) => w is Semantics && w.properties.label == 'Forward' && w.properties.enabled == false,
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// T-190: pin
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
group('DecisionDetailView — pin (T-190)', () {
|
||||
late KernelFixture f;
|
||||
|
||||
setUp(() async {
|
||||
f = await KernelFixture.create();
|
||||
f.ipc.stub('pql.decisions.read', (args) async {
|
||||
final id = args['id'] as String? ?? 'unknown';
|
||||
return _decisionResponse(id);
|
||||
});
|
||||
});
|
||||
tearDown(() => f.dispose());
|
||||
|
||||
Future<void> pumpView(WidgetTester tester, {String? initialId}) 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, DecisionDetailView(initialId: initialId)));
|
||||
await pumpAsync(tester);
|
||||
}
|
||||
|
||||
testWidgets('pin jump affordance not visible before pin set', (tester) async {
|
||||
await pumpView(tester, initialId: 'D-1');
|
||||
expect(
|
||||
find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Jump to pin'),
|
||||
findsNothing,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('pin current shows jump-to-pin affordance', (tester) async {
|
||||
await pumpView(tester, initialId: 'D-1');
|
||||
|
||||
await tester.tap(find
|
||||
.byWidgetPredicate(
|
||||
(w) => w is Semantics && w.properties.label == 'Pin current',
|
||||
)
|
||||
.first);
|
||||
await pumpAsync(tester);
|
||||
|
||||
expect(
|
||||
find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Jump to pin'),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('jump to pin loads the pinned decision', (tester) async {
|
||||
await pumpView(tester, initialId: 'D-1');
|
||||
|
||||
// Pin D-1.
|
||||
await tester.tap(find
|
||||
.byWidgetPredicate(
|
||||
(w) => w is Semantics && w.properties.label == 'Pin current',
|
||||
)
|
||||
.first);
|
||||
await pumpAsync(tester);
|
||||
|
||||
// Navigate to D-2.
|
||||
f.services.messages.publish('builtin.decisions', 'selection', {'id': 'D-2'});
|
||||
await pumpAsync(tester);
|
||||
// Title appears in pane header subtitle + body card.
|
||||
expect(find.text('Decision D-2'), findsWidgets);
|
||||
|
||||
// Jump to pin.
|
||||
await tester.tap(find
|
||||
.byWidgetPredicate(
|
||||
(w) => w is Semantics && w.properties.label == 'Jump to pin',
|
||||
)
|
||||
.first);
|
||||
await pumpAsync(tester);
|
||||
|
||||
expect(find.text('Decision D-1'), findsWidgets);
|
||||
});
|
||||
|
||||
testWidgets('pin replaces previous pin', (tester) async {
|
||||
await pumpView(tester, initialId: 'D-1');
|
||||
|
||||
// Pin D-1.
|
||||
await tester.tap(find
|
||||
.byWidgetPredicate(
|
||||
(w) => w is Semantics && w.properties.label == 'Pin current',
|
||||
)
|
||||
.first);
|
||||
await pumpAsync(tester);
|
||||
|
||||
// Navigate to D-2.
|
||||
f.services.messages.publish('builtin.decisions', 'selection', {'id': 'D-2'});
|
||||
await pumpAsync(tester);
|
||||
|
||||
// Replace pin with D-2.
|
||||
await tester.tap(find
|
||||
.byWidgetPredicate(
|
||||
(w) => w is Semantics && w.properties.label == 'Replace pin',
|
||||
)
|
||||
.first);
|
||||
await pumpAsync(tester);
|
||||
|
||||
// Navigate to D-3.
|
||||
f.services.messages.publish('builtin.decisions', 'selection', {'id': 'D-3'});
|
||||
await pumpAsync(tester);
|
||||
|
||||
// Jump to pin — should go to D-2 (replaced), not D-1.
|
||||
await tester.tap(find
|
||||
.byWidgetPredicate(
|
||||
(w) => w is Semantics && w.properties.label == 'Jump to pin',
|
||||
)
|
||||
.first);
|
||||
await pumpAsync(tester);
|
||||
|
||||
// Title appears in pane header subtitle + body card.
|
||||
expect(find.text('Decision D-2'), findsWidgets);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// T-191: edit pencil
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
group('DecisionDetailView — edit pencil (T-191)', () {
|
||||
late KernelFixture f;
|
||||
|
||||
setUp(() async {
|
||||
f = await KernelFixture.create();
|
||||
});
|
||||
tearDown(() => f.dispose());
|
||||
|
||||
Future<void> pumpView(WidgetTester tester, {String? initialId}) 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, DecisionDetailView(initialId: initialId)));
|
||||
await pumpAsync(tester);
|
||||
}
|
||||
|
||||
testWidgets('edit pencil not visible when no decision loaded', (tester) async {
|
||||
await pumpView(tester);
|
||||
// Placeholder state — no chrome.
|
||||
expect(
|
||||
find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Edit in editor'),
|
||||
findsNothing,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('edit pencil fires editor.open with file_path from decision', (tester) async {
|
||||
const filePath = 'governance/decisions/architecture.md';
|
||||
f.ipc.stub('pql.decisions.read', (args) async {
|
||||
return _decisionResponse('D-1', filePath: filePath);
|
||||
});
|
||||
|
||||
final editorOpenArgs = <Map<String, Object?>>[];
|
||||
f.ipc.stub('editor.open', (args) async {
|
||||
editorOpenArgs.add(args);
|
||||
return IpcResponse.ok(id: '', data: {});
|
||||
});
|
||||
|
||||
await pumpView(tester, initialId: 'D-1');
|
||||
|
||||
expect(
|
||||
find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Edit in editor'),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
await tester.tap(find
|
||||
.byWidgetPredicate(
|
||||
(w) => w is Semantics && w.properties.label == 'Edit in editor',
|
||||
)
|
||||
.first);
|
||||
await pumpAsync(tester);
|
||||
|
||||
expect(editorOpenArgs, hasLength(1));
|
||||
expect(editorOpenArgs.first['path'], filePath);
|
||||
});
|
||||
|
||||
testWidgets('edit pencil hidden when file_path is absent from response', (tester) async {
|
||||
f.ipc.stub('pql.decisions.read', (args) async {
|
||||
return IpcResponse.ok(
|
||||
id: '',
|
||||
data: {
|
||||
'id': 'D-70',
|
||||
'title': 'No file path',
|
||||
'type': 'confirmed',
|
||||
'domain': 'architecture',
|
||||
'status': 'active',
|
||||
'date': '2026-01-01',
|
||||
'body': '',
|
||||
'refs': <Object?>[],
|
||||
// No file_path key.
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
await pumpView(tester, initialId: 'D-70');
|
||||
|
||||
expect(
|
||||
find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Edit in editor'),
|
||||
findsNothing,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
/// Widget tests for MarkdownViewer (T-187).
|
||||
/// Widget tests for MarkdownViewer (T-187, T-189, T-190, T-191).
|
||||
///
|
||||
/// Covers:
|
||||
/// - .md wiki-link via onRecordTap publishes ('builtin.markdown','selection')
|
||||
/// - T- link routes to tickets publisher
|
||||
/// - D- link routes to decisions publisher
|
||||
/// - dead editor.buffer_activated fallback is gone (no _editorSub)
|
||||
/// - T-189: back/forward navigation (history stack)
|
||||
/// - T-190: pin set/replace/jump
|
||||
/// - T-191: edit pencil fires editor.open
|
||||
library;
|
||||
|
||||
import 'package:clide/builtin/markdown/src/markdown_viewer.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';
|
||||
@@ -25,6 +29,33 @@ void _stubRead(KernelFixture f, String path, String content) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Stub files.read with a map of path → content.
|
||||
void _stubReadMap(KernelFixture f, Map<String, String> paths) {
|
||||
f.ipc.stub('files.read', (args) async {
|
||||
final path = args['path'] as String? ?? '';
|
||||
if (paths.containsKey(path)) return _ok({'content': paths[path]!});
|
||||
return IpcResponse.err(id: '', error: IpcError(code: IpcExitCode.notFound, kind: IpcErrorKind.notFound, message: 'not found'));
|
||||
});
|
||||
}
|
||||
|
||||
/// Helper: pump the MarkdownViewer widget.
|
||||
Future<void> pumpView(WidgetTester tester, KernelFixture f) async {
|
||||
tester.view.physicalSize = const Size(800, 600);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(() {
|
||||
tester.view.resetPhysicalSize();
|
||||
tester.view.resetDevicePixelRatio();
|
||||
});
|
||||
await tester.pumpWidget(harness(f, const MarkdownViewer()));
|
||||
await pumpAsync(tester);
|
||||
}
|
||||
|
||||
/// Trigger a file load via the 'load' channel (mirrors the extension bridge).
|
||||
Future<void> loadFile(WidgetTester tester, KernelFixture f, String path) async {
|
||||
f.services.messages.publish('builtin.markdown', 'load', {'path': path});
|
||||
await pumpAsync(tester);
|
||||
}
|
||||
|
||||
void main() {
|
||||
late KernelFixture f;
|
||||
setUp(() async => f = await KernelFixture.create());
|
||||
@@ -142,4 +173,303 @@ void main() {
|
||||
semantics.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// T-189: back/forward navigation
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
group('MarkdownViewer — back/forward (T-189)', () {
|
||||
testWidgets('back button disabled on initial load', (tester) async {
|
||||
_stubRead(f, 'a.md', '# A');
|
||||
await pumpView(tester, f);
|
||||
await loadFile(tester, f, 'a.md');
|
||||
|
||||
// Back should be disabled — the button is in a disabled state (no back entry).
|
||||
// We verify via the action-bar semantics label.
|
||||
expect(
|
||||
find.byWidgetPredicate(
|
||||
(w) => w is Semantics && w.properties.label == 'Back' && w.properties.enabled == false,
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('back enabled after loading two files', (tester) async {
|
||||
_stubReadMap(f, {'a.md': '# A', 'b.md': '# B'});
|
||||
await pumpView(tester, f);
|
||||
await loadFile(tester, f, 'a.md');
|
||||
await loadFile(tester, f, 'b.md');
|
||||
|
||||
// Back should be enabled.
|
||||
expect(
|
||||
find.byWidgetPredicate(
|
||||
(w) => w is Semantics && w.properties.label == 'Back' && (w.properties.enabled ?? true),
|
||||
),
|
||||
findsWidgets,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('back navigates to previous file', (tester) async {
|
||||
_stubReadMap(f, {'a.md': 'File A content', 'b.md': 'File B content'});
|
||||
await pumpView(tester, f);
|
||||
await loadFile(tester, f, 'a.md');
|
||||
await loadFile(tester, f, 'b.md');
|
||||
|
||||
// Currently showing B.
|
||||
expect(find.text('b.md'), findsOneWidget);
|
||||
|
||||
// Tap Back.
|
||||
final backButton = find.byWidgetPredicate(
|
||||
(w) => w is Semantics && w.properties.label == 'Back' && (w.properties.enabled ?? true),
|
||||
);
|
||||
await tester.tap(backButton.first);
|
||||
await pumpAsync(tester);
|
||||
|
||||
// Should now show a.md in the title.
|
||||
expect(find.text('a.md'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('forward disabled at end of history', (tester) async {
|
||||
_stubReadMap(f, {'a.md': '# A', 'b.md': '# B'});
|
||||
await pumpView(tester, f);
|
||||
await loadFile(tester, f, 'a.md');
|
||||
await loadFile(tester, f, 'b.md');
|
||||
|
||||
expect(
|
||||
find.byWidgetPredicate(
|
||||
(w) => w is Semantics && w.properties.label == 'Forward' && w.properties.enabled == false,
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('forward enabled after going back', (tester) async {
|
||||
_stubReadMap(f, {'a.md': 'A', 'b.md': 'B'});
|
||||
await pumpView(tester, f);
|
||||
await loadFile(tester, f, 'a.md');
|
||||
await loadFile(tester, f, 'b.md');
|
||||
|
||||
// Go back.
|
||||
final backButton = find.byWidgetPredicate(
|
||||
(w) => w is Semantics && w.properties.label == 'Back' && (w.properties.enabled ?? true),
|
||||
);
|
||||
await tester.tap(backButton.first);
|
||||
await pumpAsync(tester);
|
||||
|
||||
// Forward should now be enabled.
|
||||
expect(
|
||||
find.byWidgetPredicate(
|
||||
(w) => w is Semantics && w.properties.label == 'Forward' && (w.properties.enabled ?? true),
|
||||
),
|
||||
findsWidgets,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('forward navigates to next file after back', (tester) async {
|
||||
_stubReadMap(f, {'a.md': 'A text', 'b.md': 'B text'});
|
||||
await pumpView(tester, f);
|
||||
await loadFile(tester, f, 'a.md');
|
||||
await loadFile(tester, f, 'b.md');
|
||||
|
||||
// Go back to a.md.
|
||||
final backButton = find.byWidgetPredicate(
|
||||
(w) => w is Semantics && w.properties.label == 'Back' && (w.properties.enabled ?? true),
|
||||
);
|
||||
await tester.tap(backButton.first);
|
||||
await pumpAsync(tester);
|
||||
expect(find.text('a.md'), findsOneWidget);
|
||||
|
||||
// Go forward to b.md.
|
||||
final fwdButton = find.byWidgetPredicate(
|
||||
(w) => w is Semantics && w.properties.label == 'Forward' && (w.properties.enabled ?? true),
|
||||
);
|
||||
await tester.tap(fwdButton.first);
|
||||
await pumpAsync(tester);
|
||||
expect(find.text('b.md'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('loading new file truncates forward history', (tester) async {
|
||||
_stubReadMap(f, {'a.md': 'A', 'b.md': 'B', 'c.md': 'C'});
|
||||
await pumpView(tester, f);
|
||||
await loadFile(tester, f, 'a.md');
|
||||
await loadFile(tester, f, 'b.md');
|
||||
|
||||
// Go back to a.md.
|
||||
final backButton = find.byWidgetPredicate(
|
||||
(w) => w is Semantics && w.properties.label == 'Back' && (w.properties.enabled ?? true),
|
||||
);
|
||||
await tester.tap(backButton.first);
|
||||
await pumpAsync(tester);
|
||||
|
||||
// Load c.md — truncates forward history (b.md).
|
||||
await loadFile(tester, f, 'c.md');
|
||||
|
||||
// Forward should now be disabled (b.md was truncated).
|
||||
expect(
|
||||
find.byWidgetPredicate(
|
||||
(w) => w is Semantics && w.properties.label == 'Forward' && w.properties.enabled == false,
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// T-190: pin
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
group('MarkdownViewer — pin (T-190)', () {
|
||||
testWidgets('pin button present when file is loaded', (tester) async {
|
||||
_stubRead(f, 'a.md', '# A');
|
||||
await pumpView(tester, f);
|
||||
await loadFile(tester, f, 'a.md');
|
||||
|
||||
expect(
|
||||
find.byWidgetPredicate((w) => w is Semantics && (w.properties.label == 'Pin current' || w.properties.label == 'Replace pin')),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('pin jump affordance not visible before pin is set', (tester) async {
|
||||
_stubRead(f, 'a.md', '# A');
|
||||
await pumpView(tester, f);
|
||||
await loadFile(tester, f, 'a.md');
|
||||
|
||||
expect(
|
||||
find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Jump to pin'),
|
||||
findsNothing,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('pin current shows jump-to-pin affordance', (tester) async {
|
||||
_stubReadMap(f, {'a.md': 'A', 'b.md': 'B'});
|
||||
await pumpView(tester, f);
|
||||
await loadFile(tester, f, 'a.md');
|
||||
|
||||
// Tap Pin current.
|
||||
await tester.tap(find
|
||||
.byWidgetPredicate(
|
||||
(w) => w is Semantics && w.properties.label == 'Pin current',
|
||||
)
|
||||
.first);
|
||||
await pumpAsync(tester);
|
||||
|
||||
// Jump-to-pin affordance should now be visible.
|
||||
expect(
|
||||
find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Jump to pin'),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('jump to pin loads the pinned file', (tester) async {
|
||||
_stubReadMap(f, {'a.md': 'File A', 'b.md': 'File B'});
|
||||
await pumpView(tester, f);
|
||||
await loadFile(tester, f, 'a.md');
|
||||
|
||||
// Pin a.md.
|
||||
await tester.tap(find
|
||||
.byWidgetPredicate(
|
||||
(w) => w is Semantics && w.properties.label == 'Pin current',
|
||||
)
|
||||
.first);
|
||||
await pumpAsync(tester);
|
||||
|
||||
// Navigate to b.md.
|
||||
await loadFile(tester, f, 'b.md');
|
||||
expect(find.text('b.md'), findsOneWidget);
|
||||
|
||||
// Jump to pin.
|
||||
await tester.tap(find
|
||||
.byWidgetPredicate(
|
||||
(w) => w is Semantics && w.properties.label == 'Jump to pin',
|
||||
)
|
||||
.first);
|
||||
await pumpAsync(tester);
|
||||
|
||||
// Should be back at a.md.
|
||||
expect(find.text('a.md'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('pin replaces previous pin', (tester) async {
|
||||
_stubReadMap(f, {'a.md': 'A', 'b.md': 'B', 'c.md': 'C'});
|
||||
await pumpView(tester, f);
|
||||
await loadFile(tester, f, 'a.md');
|
||||
|
||||
// Pin a.md.
|
||||
await tester.tap(find
|
||||
.byWidgetPredicate(
|
||||
(w) => w is Semantics && w.properties.label == 'Pin current',
|
||||
)
|
||||
.first);
|
||||
await pumpAsync(tester);
|
||||
|
||||
// Navigate to b.md.
|
||||
await loadFile(tester, f, 'b.md');
|
||||
|
||||
// Replace pin with b.md.
|
||||
await tester.tap(find
|
||||
.byWidgetPredicate(
|
||||
(w) => w is Semantics && w.properties.label == 'Replace pin',
|
||||
)
|
||||
.first);
|
||||
await pumpAsync(tester);
|
||||
|
||||
// Navigate to c.md.
|
||||
await loadFile(tester, f, 'c.md');
|
||||
|
||||
// Jump to pin should go to b.md now (replaced), not a.md.
|
||||
await tester.tap(find
|
||||
.byWidgetPredicate(
|
||||
(w) => w is Semantics && w.properties.label == 'Jump to pin',
|
||||
)
|
||||
.first);
|
||||
await pumpAsync(tester);
|
||||
|
||||
expect(find.text('b.md'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// T-191: edit pencil
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
group('MarkdownViewer — edit pencil (T-191)', () {
|
||||
testWidgets('edit pencil not visible before a file is loaded', (tester) async {
|
||||
await pumpView(tester, f);
|
||||
// No file loaded — placeholder shown, no chrome.
|
||||
expect(
|
||||
find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Edit in editor'),
|
||||
findsNothing,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('edit pencil fires editor.open with current path', (tester) async {
|
||||
const path = 'docs/readme.md';
|
||||
_stubRead(f, path, '# Readme');
|
||||
|
||||
final editorOpenArgs = <Map<String, Object?>>[];
|
||||
f.ipc.stub('editor.open', (args) async {
|
||||
editorOpenArgs.add(args);
|
||||
return IpcResponse.ok(id: '', data: {});
|
||||
});
|
||||
|
||||
await pumpView(tester, f);
|
||||
await loadFile(tester, f, path);
|
||||
|
||||
expect(
|
||||
find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Edit in editor'),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
await tester.tap(find
|
||||
.byWidgetPredicate(
|
||||
(w) => w is Semantics && w.properties.label == 'Edit in editor',
|
||||
)
|
||||
.first);
|
||||
await pumpAsync(tester);
|
||||
|
||||
expect(editorOpenArgs, hasLength(1));
|
||||
expect(editorOpenArgs.first['path'], path);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
/// Unit tests for [ReaderHistory] and [ReaderHistoryMixin] (T-189, T-190).
|
||||
library;
|
||||
|
||||
import 'package:clide/builtin/shared/reader_chrome.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ReaderHistory unit tests (no Flutter needed — plain test())
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void main() {
|
||||
group('ReaderHistory', () {
|
||||
late ReaderHistory<String> h;
|
||||
|
||||
setUp(() => h = ReaderHistory<String>());
|
||||
|
||||
test('starts empty — canGoBack/Forward false, current null', () {
|
||||
expect(h.canGoBack, isFalse);
|
||||
expect(h.canGoForward, isFalse);
|
||||
expect(h.current, isNull);
|
||||
});
|
||||
|
||||
test('push one — current is that entry, no back/forward', () {
|
||||
h.push('A');
|
||||
expect(h.current, 'A');
|
||||
expect(h.canGoBack, isFalse);
|
||||
expect(h.canGoForward, isFalse);
|
||||
});
|
||||
|
||||
test('push two — canGoBack true, canGoForward false', () {
|
||||
h.push('A');
|
||||
h.push('B');
|
||||
expect(h.current, 'B');
|
||||
expect(h.canGoBack, isTrue);
|
||||
expect(h.canGoForward, isFalse);
|
||||
});
|
||||
|
||||
test('back() after two pushes returns first entry', () {
|
||||
h.push('A');
|
||||
h.push('B');
|
||||
final result = h.back();
|
||||
expect(result, 'A');
|
||||
expect(h.current, 'A');
|
||||
expect(h.canGoBack, isFalse);
|
||||
expect(h.canGoForward, isTrue);
|
||||
});
|
||||
|
||||
test('forward() after back() returns second entry', () {
|
||||
h.push('A');
|
||||
h.push('B');
|
||||
h.back();
|
||||
final result = h.forward();
|
||||
expect(result, 'B');
|
||||
expect(h.current, 'B');
|
||||
expect(h.canGoForward, isFalse);
|
||||
});
|
||||
|
||||
test('back() at start returns null', () {
|
||||
h.push('A');
|
||||
expect(h.back(), isNull);
|
||||
});
|
||||
|
||||
test('forward() at end returns null', () {
|
||||
h.push('A');
|
||||
h.push('B');
|
||||
expect(h.forward(), isNull);
|
||||
});
|
||||
|
||||
test('new push truncates forward history', () {
|
||||
h.push('A');
|
||||
h.push('B');
|
||||
h.push('C');
|
||||
h.back(); // now at B
|
||||
h.back(); // now at A
|
||||
expect(h.canGoForward, isTrue);
|
||||
|
||||
h.push('D'); // truncates [B, C], appends D
|
||||
expect(h.current, 'D');
|
||||
expect(h.canGoBack, isTrue);
|
||||
expect(h.canGoForward, isFalse);
|
||||
|
||||
final prev = h.back();
|
||||
expect(prev, 'A');
|
||||
});
|
||||
|
||||
test('pushing duplicate of current is a no-op', () {
|
||||
h.push('A');
|
||||
h.push('A');
|
||||
expect(h.canGoBack, isFalse); // still only one entry
|
||||
expect(h.current, 'A');
|
||||
});
|
||||
|
||||
test('three entries back/forward round-trip', () {
|
||||
h.push('A');
|
||||
h.push('B');
|
||||
h.push('C');
|
||||
expect(h.back(), 'B');
|
||||
expect(h.back(), 'A');
|
||||
expect(h.forward(), 'B');
|
||||
expect(h.forward(), 'C');
|
||||
expect(h.canGoForward, isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ReaderHistoryMixin widget integration test — uses a minimal StatefulWidget.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
group('ReaderHistoryMixin', () {
|
||||
testWidgets('pin current / jump-to-pin round-trip', (tester) async {
|
||||
String? jumpedTo;
|
||||
|
||||
await tester.pumpWidget(
|
||||
Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: _MixinHarness(onJump: (v) => jumpedTo = v),
|
||||
),
|
||||
);
|
||||
|
||||
final state = tester.state<_MixinHarnessState>(find.byType(_MixinHarness));
|
||||
|
||||
// No pin yet.
|
||||
expect(state.hasPinned, isFalse);
|
||||
expect(state.pinnedEntry, isNull);
|
||||
|
||||
// Push 'A', then pin it.
|
||||
state.historyPush('A');
|
||||
await tester.pump();
|
||||
state.pinCurrent();
|
||||
await tester.pump();
|
||||
|
||||
expect(state.hasPinned, isTrue);
|
||||
expect(state.pinnedEntry, 'A');
|
||||
|
||||
// Push 'B', jump to pin → should get 'A'.
|
||||
state.historyPush('B');
|
||||
await tester.pump();
|
||||
final pinEntry = state.jumpToPin();
|
||||
jumpedTo = pinEntry;
|
||||
expect(jumpedTo, 'A');
|
||||
});
|
||||
|
||||
testWidgets('pin replaces previous pin', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: _MixinHarness(onJump: (_) {}),
|
||||
),
|
||||
);
|
||||
|
||||
final state = tester.state<_MixinHarnessState>(find.byType(_MixinHarness));
|
||||
state.historyPush('A');
|
||||
state.pinCurrent();
|
||||
await tester.pump();
|
||||
expect(state.pinnedEntry, 'A');
|
||||
|
||||
state.historyPush('B');
|
||||
state.pinCurrent();
|
||||
await tester.pump();
|
||||
expect(state.pinnedEntry, 'B'); // replaced
|
||||
});
|
||||
|
||||
testWidgets('historyBack / historyForward returns correct entries', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: _MixinHarness(onJump: (_) {}),
|
||||
),
|
||||
);
|
||||
|
||||
final state = tester.state<_MixinHarnessState>(find.byType(_MixinHarness));
|
||||
state.historyPush('X');
|
||||
state.historyPush('Y');
|
||||
await tester.pump();
|
||||
|
||||
expect(state.canGoBack, isTrue);
|
||||
expect(state.canGoForward, isFalse);
|
||||
|
||||
final back = state.historyBack();
|
||||
await tester.pump();
|
||||
expect(back, 'X');
|
||||
expect(state.canGoBack, isFalse);
|
||||
expect(state.canGoForward, isTrue);
|
||||
|
||||
final fwd = state.historyForward();
|
||||
await tester.pump();
|
||||
expect(fwd, 'Y');
|
||||
});
|
||||
|
||||
testWidgets('pinCurrent with empty history is a no-op', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: _MixinHarness(onJump: (_) {}),
|
||||
),
|
||||
);
|
||||
|
||||
final state = tester.state<_MixinHarnessState>(find.byType(_MixinHarness));
|
||||
state.pinCurrent(); // no current entry — must not throw
|
||||
await tester.pump();
|
||||
expect(state.hasPinned, isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Minimal harness widget that mixes in ReaderHistoryMixin.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class _MixinHarness extends StatefulWidget {
|
||||
const _MixinHarness({required this.onJump});
|
||||
final void Function(String?) onJump;
|
||||
|
||||
@override
|
||||
State<_MixinHarness> createState() => _MixinHarnessState();
|
||||
}
|
||||
|
||||
class _MixinHarnessState extends State<_MixinHarness> with ReaderHistoryMixin<String, _MixinHarness> {
|
||||
@override
|
||||
Widget build(BuildContext context) => const SizedBox.shrink();
|
||||
}
|
||||
Reference in New Issue
Block a user