Two reveal-on-open bugs: Decisions opened only on the second click (T-196): the detail view subscribed in didChangeDependencies, which runs after the tab is revealed, so the broadcast 'selection' that triggered the reveal was already gone. Hoist the back/forward history out of per-view State into a retained per-reader ReaderNav (kernel ChangeNotifier in a ReaderNavRegistry, D-81). The nav records selections, emits 'load' (the single channel readers display from), and survives mount/unmount — the reader grabs nav.current on mount, so the first selection lands. Both the markdown and decisions readers move to this model; the per-view ReaderHistoryMixin and the markdown post-frame forward hack are gone. The editor pane never opened (T-197): EditorExtension contributed a workspace tab but nothing activated it on editor.open. Add an activate() that reveals the tab on editor.opened / editor.active-changed; the view's hydrate() pulls the active buffer on mount. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
138 lines
3.9 KiB
Dart
138 lines
3.9 KiB
Dart
/// Unit tests for the retained right-pane nav history (T-196).
|
|
///
|
|
/// [ReaderNav] records selections (even before the reader mounts), holds
|
|
/// browser-style back/forward history, exposes the latest as [current]
|
|
/// for grab-on-mount, and re-emits every navigation on the `load`
|
|
/// channel so the reader has a single load path.
|
|
library;
|
|
|
|
import 'package:clide/kernel/src/events/message_bus.dart';
|
|
import 'package:clide/kernel/src/reader_nav.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
|
|
void main() {
|
|
late MessageBus bus;
|
|
late ReaderNav nav;
|
|
late List<String> loads;
|
|
|
|
setUp(() {
|
|
bus = MessageBus();
|
|
nav = ReaderNav(messages: bus, publisherId: 'builtin.decisions', dataKey: 'id');
|
|
loads = [];
|
|
bus.subscribe(publisher: 'builtin.decisions', channel: 'load').listen((m) {
|
|
final id = m.data['id'];
|
|
if (id is String) loads.add(id);
|
|
});
|
|
});
|
|
tearDown(() {
|
|
nav.dispose();
|
|
bus.dispose();
|
|
});
|
|
|
|
// Let the broadcast bus deliver.
|
|
Future<void> tick() => Future<void>.delayed(Duration.zero);
|
|
|
|
test('starts empty', () {
|
|
expect(nav.current, isNull);
|
|
expect(nav.canGoBack, isFalse);
|
|
expect(nav.canGoForward, isFalse);
|
|
expect(nav.hasPinned, isFalse);
|
|
});
|
|
|
|
test('open records the entry, sets current, and emits a load', () async {
|
|
nav.open('D-1');
|
|
expect(nav.current, 'D-1');
|
|
expect(nav.canGoBack, isFalse);
|
|
await tick();
|
|
expect(loads, ['D-1']);
|
|
});
|
|
|
|
test('a selection on the bus is recorded (retained) and emits a load', () async {
|
|
bus.publish('builtin.decisions', 'selection', {'id': 'D-9'});
|
|
await tick();
|
|
expect(nav.current, 'D-9');
|
|
expect(loads, ['D-9']);
|
|
});
|
|
|
|
test('two opens enable back; back re-emits the prior entry', () async {
|
|
nav.open('D-1');
|
|
nav.open('D-2');
|
|
expect(nav.canGoBack, isTrue);
|
|
expect(nav.canGoForward, isFalse);
|
|
nav.back();
|
|
expect(nav.current, 'D-1');
|
|
expect(nav.canGoForward, isTrue);
|
|
await tick();
|
|
expect(loads, ['D-1', 'D-2', 'D-1']);
|
|
});
|
|
|
|
test('forward after back re-emits the later entry', () async {
|
|
nav.open('D-1');
|
|
nav.open('D-2');
|
|
nav.back();
|
|
nav.forward();
|
|
expect(nav.current, 'D-2');
|
|
});
|
|
|
|
test('back at start / forward at end are no-ops', () async {
|
|
nav.open('D-1');
|
|
nav.back(); // canGoBack false → no-op
|
|
nav.forward(); // canGoForward false → no-op
|
|
await tick();
|
|
expect(loads, ['D-1']); // only the open emitted
|
|
expect(nav.current, 'D-1');
|
|
});
|
|
|
|
test('a new open truncates forward history', () {
|
|
nav.open('D-1');
|
|
nav.open('D-2');
|
|
nav.open('D-3');
|
|
nav.back(); // D-2
|
|
nav.back(); // D-1
|
|
nav.open('D-9'); // truncates D-2/D-3
|
|
expect(nav.current, 'D-9');
|
|
expect(nav.canGoForward, isFalse);
|
|
expect(nav.canGoBack, isTrue);
|
|
});
|
|
|
|
test('opening the current entry again re-emits but does not push', () async {
|
|
nav.open('D-1');
|
|
nav.open('D-1');
|
|
expect(nav.canGoBack, isFalse); // no duplicate pushed
|
|
await tick();
|
|
expect(loads, ['D-1', 'D-1']); // but both re-emit a load
|
|
});
|
|
|
|
test('pin + jumpToPin returns to the pinned entry and re-emits', () async {
|
|
nav.open('D-1');
|
|
nav.pin();
|
|
expect(nav.hasPinned, isTrue);
|
|
nav.open('D-2');
|
|
nav.open('D-3');
|
|
nav.jumpToPin();
|
|
expect(nav.current, 'D-1');
|
|
await tick();
|
|
expect(loads.last, 'D-1');
|
|
});
|
|
|
|
test('pin replaces the previous pin', () {
|
|
nav.open('D-1');
|
|
nav.pin();
|
|
nav.open('D-2');
|
|
nav.pin(); // replaces
|
|
nav.open('D-3');
|
|
nav.jumpToPin();
|
|
expect(nav.current, 'D-2');
|
|
});
|
|
|
|
test('registry retains one nav per reader id', () {
|
|
final reg = ReaderNavRegistry(bus);
|
|
addTearDown(reg.dispose);
|
|
final a = reg.navFor('builtin.markdown', dataKey: 'path');
|
|
final b = reg.navFor('builtin.markdown', dataKey: 'path');
|
|
expect(identical(a, b), isTrue);
|
|
final c = reg.navFor('builtin.decisions', dataKey: 'id');
|
|
expect(identical(a, c), isFalse);
|
|
});
|
|
}
|