From d9d06cf98e302a1a9078a65fd4f0c112f758e23a Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Mon, 13 Jul 2026 18:45:10 +0200 Subject: [PATCH] test(coverage): bring the backlinks panel under test; close preset gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pre-push coverage gate was failing at 94.84% — below the 95% floor even before the T-511 feature landed (the feature files sit at 97%; the tree without them was at 94.80%). The bulk of the debt was the pql backlinks panel: controller and view carried ~100 lines with zero tests. Covers controller fetch/error/event-refresh/dispose and view empty/error/group/row-navigation states, plus the T-511 stragglers a formatter-conflicted edit dropped (presetLookupRoot) and the env.path production default seams. Gate now passes at 95.26%. Co-Authored-By: Claude Fable 5 --- test/builtin/pql/backlinks_test.dart | 211 ++++++++++++++++++++++++ test/daemon/env_path_commands_test.dart | 28 ++++ test/src/env/path_preset_test.dart | 15 ++ 3 files changed, 254 insertions(+) create mode 100644 test/builtin/pql/backlinks_test.dart diff --git a/test/builtin/pql/backlinks_test.dart b/test/builtin/pql/backlinks_test.dart new file mode 100644 index 00000000..4dae6eff --- /dev/null +++ b/test/builtin/pql/backlinks_test.dart @@ -0,0 +1,211 @@ +/// Tests for the backlinks panel — [BacklinksController] (active-file +/// tracking, pql fetches, event-driven refresh) and [BacklinksView] +/// (empty/loading/error states, link groups, row navigation). Previously +/// untested; brought under test when the T-511 coverage sweep exposed the +/// gap. +library; + +import 'package:clide/builtin/pql/src/backlinks_controller.dart'; +import 'package:clide/builtin/pql/src/backlinks_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'; + +IpcResponse _ok(Map data) => IpcResponse.ok(id: '', data: data); +IpcResponse _err(String m) => IpcResponse.err( + id: '', + error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: m), +); + +void main() { + late KernelFixture f; + + setUp(() async => f = await KernelFixture.create()); + tearDown(() async => f.dispose()); + + void stubLinks({Object? backlinks = const [], Object? outlinks = const [], bool fail = false}) { + f.ipc.stub('pql.backlinks', (_) async => fail ? _err('vault gone') : _ok({'links': backlinks})); + f.ipc.stub('pql.outlinks', (_) async => fail ? _err('vault gone') : _ok({'links': outlinks})); + } + + DaemonEvent activeChanged(String path) => DaemonEvent(subsystem: 'editor', kind: 'editor.active-changed', data: {'path': path}, ts: DateTime.now().toUtc()); + + group('BacklinksController', () { + late BacklinksController c; + + setUp(() => c = BacklinksController(ipc: f.ipc, events: f.services.events)); + tearDown(() => c.dispose()); + + test('loadForPath populates both link lists and toggles loading', () async { + stubLinks( + backlinks: [ + {'source': 'notes/in.md'}, + ], + outlinks: [ + {'target': 'notes/out.md', 'alias': 'Out'}, + ], + ); + final loadingSeen = []; + c.addListener(() => loadingSeen.add(c.loading)); + + await c.loadForPath('notes/a.md'); + + expect(c.activePath, 'notes/a.md'); + expect(loadingSeen, [true, false]); + expect(c.backlinks.single['source'], 'notes/in.md'); + expect(c.outlinks.single['alias'], 'Out'); + expect(c.error, isNull); + }); + + test('both fetches failing surfaces the error; lists stay empty', () async { + stubLinks(fail: true); + await c.loadForPath('a.md'); + expect(c.error, 'vault gone'); + expect(c.backlinks, isEmpty); + expect(c.outlinks, isEmpty); + }); + + test('one side failing degrades to an empty list without an error', () async { + f.ipc.stub('pql.backlinks', (_) async => _err('half down')); + f.ipc.stub( + 'pql.outlinks', + (_) async => _ok({ + 'links': [ + {'target': 'b.md'}, + ], + }), + ); + await c.loadForPath('a.md'); + expect(c.error, isNull); + expect(c.backlinks, isEmpty); + expect(c.outlinks, hasLength(1)); + }); + + test('a malformed links payload is tolerated as empty', () async { + f.ipc.stub('pql.backlinks', (_) async => _ok(const {'links': 'nonsense'})); + f.ipc.stub('pql.outlinks', (_) async => _ok(const {})); + await c.loadForPath('a.md'); + expect(c.backlinks, isEmpty); + expect(c.outlinks, isEmpty); + }); + + test('editor.active-changed refreshes; same path and foreign events do not', () async { + var calls = 0; + f.ipc.stub('pql.backlinks', (_) async { + calls++; + return _ok(const {'links': []}); + }); + f.ipc.stub('pql.outlinks', (_) async => _ok(const {'links': []})); + + f.services.events.emit(activeChanged('a.md')); + await Future.delayed(Duration.zero); + expect(calls, 1); + + f.services.events.emit(activeChanged('a.md')); // same path — no refetch + f.services.events.emit(DaemonEvent(subsystem: 'git', kind: 'changed', data: const {}, ts: DateTime.now().toUtc())); + f.services.events.emit(DaemonEvent(subsystem: 'editor', kind: 'editor.saved', data: const {'path': 'b.md'}, ts: DateTime.now().toUtc())); + await Future.delayed(Duration.zero); + expect(calls, 1); + + f.services.events.emit(activeChanged('b.md')); + await Future.delayed(Duration.zero); + expect(calls, 2); + }); + + test('dispose stops listening to the bus', () async { + var calls = 0; + f.ipc.stub('pql.backlinks', (_) async { + calls++; + return _ok(const {'links': []}); + }); + f.ipc.stub('pql.outlinks', (_) async => _ok(const {'links': []})); + c.dispose(); + f.services.events.emit(activeChanged('a.md')); + await Future.delayed(Duration.zero); + expect(calls, 0); + c = BacklinksController(ipc: f.ipc, events: f.services.events); // tearDown disposes a live one + }); + }); + + group('BacklinksView', () { + Future pump(WidgetTester tester) => tester.pumpWidget( + harness( + f, + const Align( + alignment: Alignment.center, + child: SizedBox(width: 320, height: 400, child: BacklinksView()), + ), + ), + ); + + testWidgets('no active file → empty-state prompt', (tester) async { + await pump(tester); + await tester.pump(); + expect(find.textContaining('Open a file'), findsOneWidget); + }); + + testWidgets('an active-file change renders the file name, groups, and rows', (tester) async { + stubLinks( + backlinks: [ + {'source': 'notes/in.md'}, + ], + outlinks: [ + {'target': 'https://example.com', 'alias': 'Site'}, + ], + ); + await pump(tester); + f.services.events.emit(activeChanged('notes/active.md')); + await tester.pump(); + await tester.pump(); + + expect(find.text('active.md'), findsOneWidget, reason: 'header shows the basename'); + expect(find.text('Backlinks (1)'), findsOneWidget); + expect(find.text('Outlinks (1)'), findsOneWidget); + expect(find.text('notes/in.md'), findsOneWidget); + expect(find.text('Site'), findsOneWidget, reason: 'alias wins over the raw target'); + expect(find.text('None'), findsNothing); + }); + + testWidgets('empty groups say None; a failed fetch surfaces the error', (tester) async { + stubLinks(fail: true); + await pump(tester); + f.services.events.emit(activeChanged('a.md')); + await tester.pump(); + await tester.pump(); + expect(find.text('vault gone'), findsOneWidget); + expect(find.text('None'), findsNWidgets(2)); + }); + + testWidgets('tapping a vault link opens it in the editor; an http link does not', (tester) async { + final opened = []; + f.ipc.stub('editor.open', (args) async { + opened.add(args['path']! as String); + return _ok(const {}); + }); + stubLinks( + backlinks: [ + {'source': 'notes/in.md'}, + ], + outlinks: [ + {'target': 'https://example.com'}, + ], + ); + await pump(tester); + f.services.events.emit(activeChanged('a.md')); + await tester.pump(); + await tester.pump(); + + await tester.tap(find.text('notes/in.md')); + await tester.pump(); + expect(opened, ['notes/in.md']); + + await tester.tap(find.text('https://example.com')); + await tester.pump(); + expect(opened, ['notes/in.md'], reason: 'http links never route to the editor'); + }); + }); +} diff --git a/test/daemon/env_path_commands_test.dart b/test/daemon/env_path_commands_test.dart index 3d4f26dd..ed61f9af 100644 --- a/test/daemon/env_path_commands_test.dart +++ b/test/daemon/env_path_commands_test.dart @@ -189,6 +189,34 @@ void main() { }); }); + test('un-injected production seams (HOME / dir probe / process PATH) work', () async { + store = _FakeStore(); + d = DaemonDispatcher(); + registerEnvPathCommands(d, () => store, workspaceCwd: () => '/repo'); + final set = await d.dispatch( + IpcRequest( + id: '1', + cmd: 'env.path', + args: const { + 'positional': ['set', '/definitely-missing-dir'], + }, + ), + ); + expect(set.ok, isTrue, reason: set.error?.message); + expect(set.data['missing'], ['/definitely-missing-dir'], reason: 'real Directory probe ran'); + final cap = await d.dispatch( + IpcRequest( + id: '2', + cmd: 'env.path', + args: const { + 'positional': ['capture'], + }, + ), + ); + expect(cap.ok, isTrue); + expect(cap.data['processPath'], isA()); + }); + test('no workspace / no store / unknown action error clearly', () async { wire(cwd: null); expect((await run(['list'])).error?.message, contains('no workspace')); diff --git a/test/src/env/path_preset_test.dart b/test/src/env/path_preset_test.dart index 5b9c0283..91e2d94e 100644 --- a/test/src/env/path_preset_test.dart +++ b/test/src/env/path_preset_test.dart @@ -166,6 +166,21 @@ void main() { }); }); + group('presetLookupRoot', () { + test('a cwd at or below the workspace keys off the workspace', () { + expect(presetLookupRoot('/repo', '/repo'), '/repo'); + expect(presetLookupRoot('/repo/', '/repo'), '/repo'); + expect(presetLookupRoot('/repo/lib/src', '/repo'), '/repo'); + expect(presetLookupRoot(null, '/repo'), '/repo'); + expect(presetLookupRoot('', '/repo'), '/repo'); + }); + + test('an unrelated cwd keys off itself; sibling-prefix dirs are not confused', () { + expect(presetLookupRoot('/elsewhere', '/repo'), '/elsewhere'); + expect(presetLookupRoot('/repo-other/x', '/repo'), '/repo-other/x'); + }); + }); + group('presetDirsFrom', () { List read(Object? stored) => presetDirsFrom((_) => stored, '/repo', isFile: (_) => false, readFile: (_) => null);