From 1e8ee2c4127a9dd203e45aa523e0dc7001c8b11a Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 1 Jul 2026 22:01:08 +0200 Subject: [PATCH] test(coverage): lift os/code-block/deeplink for gate headroom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three worst-covered files were genuinely untested, not edge cases: os.dart 27%→~85% (inject the process runner so openURL/reveal don't spawn a real browser), clide_code_block 39%→~90% (expose the byte→char span mapper as a top-level fn + render tests), deeplink 29%→~75% (the confirm-opens and not-activated paths). Buys buffer above the 95% floor so a feature batch doesn't immediately trip the gate. 95.01% → 95.22%. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/kernel/src/os.dart | 11 ++- lib/widgets/src/clide_code_block.dart | 90 ++++++++++++----------- test/builtin/deeplink/extension_test.dart | 22 ++++++ test/kernel/src/os_test.dart | 58 +++++++++++++++ test/widgets/clide_code_block_test.dart | 72 ++++++++++++++++++ 5 files changed, 207 insertions(+), 46 deletions(-) create mode 100644 test/kernel/src/os_test.dart create mode 100644 test/widgets/clide_code_block_test.dart diff --git a/lib/kernel/src/os.dart b/lib/kernel/src/os.dart index e3e2eabd..1595c72b 100644 --- a/lib/kernel/src/os.dart +++ b/lib/kernel/src/os.dart @@ -15,11 +15,16 @@ class OsLifecycleEvent extends ClideEvent { String get kind => _kind; } +/// Runs an external command — injected so [OsBridge] is testable without +/// spawning a real `xdg-open` / `open` / `explorer`. +typedef OsProcessRunner = Future Function(String executable, List arguments); + class OsBridge { - OsBridge({required Logger log, required DaemonBus events}) : _log = log, _events = events; + OsBridge({required Logger log, required DaemonBus events, OsProcessRunner? run}) : _log = log, _events = events, _run = run ?? Process.run; final Logger _log; final DaemonBus _events; + final OsProcessRunner _run; Future openURL(String url) async { final cmd = _openCommand(); @@ -28,7 +33,7 @@ class OsBridge { return false; } try { - final r = await Process.run(cmd[0], [...cmd.skip(1), url]); + final r = await _run(cmd[0], [...cmd.skip(1), url]); return r.exitCode == 0; } catch (e) { _log.warn('os', 'openURL failed', error: e); @@ -40,7 +45,7 @@ class OsBridge { final cmd = _revealCommand(path); if (cmd == null) return false; try { - final r = await Process.run(cmd[0], cmd.skip(1).toList()); + final r = await _run(cmd[0], cmd.skip(1).toList()); return r.exitCode == 0; } catch (e) { _log.warn('os', 'reveal failed', error: e); diff --git a/lib/widgets/src/clide_code_block.dart b/lib/widgets/src/clide_code_block.dart index f693efa8..2066c5e4 100644 --- a/lib/widgets/src/clide_code_block.dart +++ b/lib/widgets/src/clide_code_block.dart @@ -64,7 +64,7 @@ class _ClideCodeBlockState extends State { if (spans == null || spans.isEmpty) { textSpan = TextSpan(text: widget.source, style: style); } else { - textSpan = _buildHighlightedSpan(widget.source, spans, style, tokens); + textSpan = buildHighlightedSpan(widget.source, spans, style, tokens); } return Container( @@ -78,53 +78,57 @@ class _ClideCodeBlockState extends State { child: SingleChildScrollView(scrollDirection: Axis.horizontal, child: Text.rich(textSpan)), ); } +} - static TextSpan _buildHighlightedSpan(String source, List spans, TextStyle base, dynamic tokens) { - final bytes = utf8.encode(source); - final byteToChar = List.filled(bytes.length + 1, source.length); - var bi = 0; - for (var ci = 0; ci < source.length; ci++) { - byteToChar[bi] = ci; - final rune = source.codeUnitAt(ci); - if (rune < 0x80) { - bi += 1; - } else if (rune < 0x800) { - bi += 2; - } else if (rune >= 0xD800 && rune <= 0xDBFF) { - bi += 4; - ci++; - } else { - bi += 3; - } +/// Maps tree-sitter byte-offset [spans] onto character ranges of [source] +/// (handling multi-byte UTF-8 + surrogate pairs) and colours each run. Exposed +/// so the byte→char mapping and span clipping are unit-tested directly. +@visibleForTesting +TextSpan buildHighlightedSpan(String source, List spans, TextStyle base, dynamic tokens) { + final bytes = utf8.encode(source); + final byteToChar = List.filled(bytes.length + 1, source.length); + var bi = 0; + for (var ci = 0; ci < source.length; ci++) { + byteToChar[bi] = ci; + final rune = source.codeUnitAt(ci); + if (rune < 0x80) { + bi += 1; + } else if (rune < 0x800) { + bi += 2; + } else if (rune >= 0xD800 && rune <= 0xDBFF) { + bi += 4; + ci++; + } else { + bi += 3; } - byteToChar[bi] = source.length; + } + byteToChar[bi] = source.length; - final sorted = List.of(spans)..sort((a, b) => a.start.compareTo(b.start)); - final children = []; - var lastChar = 0; + final sorted = List.of(spans)..sort((a, b) => a.start.compareTo(b.start)); + final children = []; + var lastChar = 0; - for (final span in sorted) { - final sChar = span.start < byteToChar.length ? byteToChar[span.start] : source.length; - final eChar = span.end < byteToChar.length ? byteToChar[span.end] : source.length; - final clippedStart = sChar < lastChar ? lastChar : sChar; - if (clippedStart > lastChar) { - children.add(TextSpan(text: source.substring(lastChar, clippedStart))); - } - if (eChar > clippedStart) { - final color = TreeSitterService.colorForRole(span.role, tokens); - children.add( - TextSpan( - text: source.substring(clippedStart, eChar), - style: base.copyWith(color: color), - ), - ); - } - if (eChar > lastChar) lastChar = eChar; + for (final span in sorted) { + final sChar = span.start < byteToChar.length ? byteToChar[span.start] : source.length; + final eChar = span.end < byteToChar.length ? byteToChar[span.end] : source.length; + final clippedStart = sChar < lastChar ? lastChar : sChar; + if (clippedStart > lastChar) { + children.add(TextSpan(text: source.substring(lastChar, clippedStart))); } - if (lastChar < source.length) { - children.add(TextSpan(text: source.substring(lastChar))); + if (eChar > clippedStart) { + final color = TreeSitterService.colorForRole(span.role, tokens); + children.add( + TextSpan( + text: source.substring(clippedStart, eChar), + style: base.copyWith(color: color), + ), + ); } + if (eChar > lastChar) lastChar = eChar; + } + if (lastChar < source.length) { + children.add(TextSpan(text: source.substring(lastChar))); + } - return TextSpan(style: base, children: children); - } + return TextSpan(style: base, children: children); } diff --git a/test/builtin/deeplink/extension_test.dart b/test/builtin/deeplink/extension_test.dart index 9eaa6f5e..70c04d0c 100644 --- a/test/builtin/deeplink/extension_test.dart +++ b/test/builtin/deeplink/extension_test.dart @@ -5,6 +5,7 @@ library; import 'dart:async'; import 'package:clide/builtin/deeplink/deeplink.dart'; +import 'package:clide/extension/extension.dart' show CommandContribution; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -53,4 +54,25 @@ void main() { expect(f.services.dialog.isOpen, isFalse); expect(r.data['status'], 'rejected'); }); + + testWidgets('confirming an allowlisted link opens the file (T-56)', (tester) async { + await tester.pumpWidget(harness(f, const SizedBox())); + await tester.pump(); + + final future = f.services.commands.execute('deeplink.invoke', args: ['clide://open?path=/x.dart&line=5']); + await tester.pump(); + expect(f.services.dialog.isOpen, isTrue); + f.services.dialog.dismiss(true); // confirm → the handler runs editor.open + await tester.pumpAndSettle(); + + final r = await future; + expect(r.data['status'], 'opened'); + expect(r.data['path'], '/x.dart'); + }); + + test('reports not-activated when invoked before activation', () async { + final ext = DeepLinkExtension(); // never activated → no context + final run = (ext.contributions.single as CommandContribution).run; + expect((await run(['clide://open?path=/x'])).data['status'], 'not-activated'); + }); } diff --git a/test/kernel/src/os_test.dart b/test/kernel/src/os_test.dart new file mode 100644 index 00000000..445139ed --- /dev/null +++ b/test/kernel/src/os_test.dart @@ -0,0 +1,58 @@ +import 'dart:io'; + +import 'package:clide/kernel/src/events/bus.dart'; +import 'package:clide/kernel/src/log.dart'; +import 'package:clide/kernel/src/os.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + OsBridge bridge(OsProcessRunner run) => OsBridge(log: Logger(), events: DaemonBus(), run: run); + + group('openURL', () { + test('runs the platform open command with the url and returns true on exit 0', () async { + final calls = >[]; + final os = bridge((exe, args) async { + calls.add([exe, ...args]); + return ProcessResult(0, 0, '', ''); + }); + expect(await os.openURL('https://example.com'), isTrue); + expect(calls.single.last, 'https://example.com'); + expect(calls.single.first, isNotEmpty); // xdg-open / open / start + }); + + test('returns false on a non-zero exit', () async { + expect(await bridge((_, _) async => ProcessResult(0, 1, '', 'nope')).openURL('x'), isFalse); + }); + + test('returns false (never throws) when the runner fails', () async { + expect(await bridge((_, _) async => throw 'boom').openURL('x'), isFalse); + }); + }); + + group('reveal', () { + test('runs the platform reveal command and returns true on exit 0', () async { + final calls = >[]; + final os = bridge((exe, args) async { + calls.add([exe, ...args]); + return ProcessResult(0, 0, '', ''); + }); + expect(await os.reveal('/tmp/some/file.txt'), isTrue); + expect(calls.single.first, isNotEmpty); + }); + + test('returns false (never throws) when the runner fails', () async { + expect(await bridge((_, _) async => throw 'x').reveal('/tmp/x'), isFalse); + }); + }); + + test('fire emits an OS lifecycle event', () async { + final bus = DaemonBus(); + final seen = []; + final sub = bus.on().listen(seen.add); + OsBridge(log: Logger(), events: bus).fire('resume'); + await Future.delayed(Duration.zero); + await sub.cancel(); + expect(seen.single.kind, 'resume'); + expect(seen.single.subsystem, 'os'); + }); +} diff --git a/test/widgets/clide_code_block_test.dart b/test/widgets/clide_code_block_test.dart new file mode 100644 index 00000000..9bbf78f0 --- /dev/null +++ b/test/widgets/clide_code_block_test.dart @@ -0,0 +1,72 @@ +import 'package:clide/kernel/src/syntax/syntax_result.dart' show SyntaxSpan; +import 'package:clide/kernel/src/theme/tokens.dart' show SurfaceTokens; +import 'package:clide/widgets/src/clide_code_block.dart'; +import 'package:clide/widgets/src/clide_settings.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../helpers/kernel_fixture.dart'; +import '../helpers/widget_harness.dart'; + +void main() { + late KernelFixture f; + setUp(() async => f = await KernelFixture.create()); + tearDown(() => f.dispose()); + + Future pumpForTokens(WidgetTester tester) async { + late SurfaceTokens tokens; + await tester.pumpWidget( + anchoredHarness( + f, + Builder( + builder: (ctx) { + tokens = ClideSettings.theme.of(ctx).surface; + return const SizedBox(); + }, + ), + ), + ); + return tokens; + } + + testWidgets('renders plain source when no language is given', (tester) async { + await tester.pumpWidget(anchoredHarness(f, const ClideCodeBlock(source: 'hello world'))); + await tester.pump(); + expect(find.byType(ClideCodeBlock), findsOneWidget); + }); + + testWidgets('renders plain source for an unknown language (no grammar)', (tester) async { + await tester.pumpWidget(anchoredHarness(f, const ClideCodeBlock(source: 'x = 1', language: 'nolang'))); + await tester.pump(); + expect(find.byType(ClideCodeBlock), findsOneWidget); + }); + + testWidgets('buildHighlightedSpan maps byte spans to char ranges across multi-byte + surrogate chars', (tester) async { + final tokens = await pumpForTokens(tester); + // 'h😀é' — h=1 byte, 😀=surrogate pair (4 bytes / 2 code units), é=2 bytes. + // Spans on the h (bytes 0–1) and the é (bytes 5–7); the emoji gap is filled + // as plain text. The children must reconstruct the whole source — no bytes + // dropped or mis-mapped. + const src = 'h😀é'; + final span = buildHighlightedSpan( + src, + const [SyntaxSpan(start: 0, end: 1, role: 'keyword'), SyntaxSpan(start: 5, end: 7, role: 'string')], + const TextStyle(), + tokens, + ); + final joined = span.children!.map((c) => (c as TextSpan).text ?? '').join(); + expect(joined, src); + }); + + testWidgets('buildHighlightedSpan clips overlapping spans without duplicating text', (tester) async { + final tokens = await pumpForTokens(tester); + final span = buildHighlightedSpan( + 'abcd', + const [SyntaxSpan(start: 0, end: 3, role: 'a'), SyntaxSpan(start: 1, end: 4, role: 'b')], // overlap + const TextStyle(), + tokens, + ); + final joined = span.children!.map((c) => (c as TextSpan).text ?? '').join(); + expect(joined, 'abcd'); + }); +}