test(coverage): lift os/code-block/deeplink for gate headroom
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) <noreply@anthropic.com>
This commit is contained in:
@@ -15,11 +15,16 @@ class OsLifecycleEvent extends ClideEvent {
|
|||||||
String get kind => _kind;
|
String get kind => _kind;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Runs an external command — injected so [OsBridge] is testable without
|
||||||
|
/// spawning a real `xdg-open` / `open` / `explorer`.
|
||||||
|
typedef OsProcessRunner = Future<ProcessResult> Function(String executable, List<String> arguments);
|
||||||
|
|
||||||
class OsBridge {
|
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 Logger _log;
|
||||||
final DaemonBus _events;
|
final DaemonBus _events;
|
||||||
|
final OsProcessRunner _run;
|
||||||
|
|
||||||
Future<bool> openURL(String url) async {
|
Future<bool> openURL(String url) async {
|
||||||
final cmd = _openCommand();
|
final cmd = _openCommand();
|
||||||
@@ -28,7 +33,7 @@ class OsBridge {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
try {
|
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;
|
return r.exitCode == 0;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_log.warn('os', 'openURL failed', error: e);
|
_log.warn('os', 'openURL failed', error: e);
|
||||||
@@ -40,7 +45,7 @@ class OsBridge {
|
|||||||
final cmd = _revealCommand(path);
|
final cmd = _revealCommand(path);
|
||||||
if (cmd == null) return false;
|
if (cmd == null) return false;
|
||||||
try {
|
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;
|
return r.exitCode == 0;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_log.warn('os', 'reveal failed', error: e);
|
_log.warn('os', 'reveal failed', error: e);
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ class _ClideCodeBlockState extends State<ClideCodeBlock> {
|
|||||||
if (spans == null || spans.isEmpty) {
|
if (spans == null || spans.isEmpty) {
|
||||||
textSpan = TextSpan(text: widget.source, style: style);
|
textSpan = TextSpan(text: widget.source, style: style);
|
||||||
} else {
|
} else {
|
||||||
textSpan = _buildHighlightedSpan(widget.source, spans, style, tokens);
|
textSpan = buildHighlightedSpan(widget.source, spans, style, tokens);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
@@ -78,53 +78,57 @@ class _ClideCodeBlockState extends State<ClideCodeBlock> {
|
|||||||
child: SingleChildScrollView(scrollDirection: Axis.horizontal, child: Text.rich(textSpan)),
|
child: SingleChildScrollView(scrollDirection: Axis.horizontal, child: Text.rich(textSpan)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static TextSpan _buildHighlightedSpan(String source, List<SyntaxSpan> spans, TextStyle base, dynamic tokens) {
|
/// Maps tree-sitter byte-offset [spans] onto character ranges of [source]
|
||||||
final bytes = utf8.encode(source);
|
/// (handling multi-byte UTF-8 + surrogate pairs) and colours each run. Exposed
|
||||||
final byteToChar = List<int>.filled(bytes.length + 1, source.length);
|
/// so the byte→char mapping and span clipping are unit-tested directly.
|
||||||
var bi = 0;
|
@visibleForTesting
|
||||||
for (var ci = 0; ci < source.length; ci++) {
|
TextSpan buildHighlightedSpan(String source, List<SyntaxSpan> spans, TextStyle base, dynamic tokens) {
|
||||||
byteToChar[bi] = ci;
|
final bytes = utf8.encode(source);
|
||||||
final rune = source.codeUnitAt(ci);
|
final byteToChar = List<int>.filled(bytes.length + 1, source.length);
|
||||||
if (rune < 0x80) {
|
var bi = 0;
|
||||||
bi += 1;
|
for (var ci = 0; ci < source.length; ci++) {
|
||||||
} else if (rune < 0x800) {
|
byteToChar[bi] = ci;
|
||||||
bi += 2;
|
final rune = source.codeUnitAt(ci);
|
||||||
} else if (rune >= 0xD800 && rune <= 0xDBFF) {
|
if (rune < 0x80) {
|
||||||
bi += 4;
|
bi += 1;
|
||||||
ci++;
|
} else if (rune < 0x800) {
|
||||||
} else {
|
bi += 2;
|
||||||
bi += 3;
|
} 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 sorted = List.of(spans)..sort((a, b) => a.start.compareTo(b.start));
|
||||||
final children = <TextSpan>[];
|
final children = <TextSpan>[];
|
||||||
var lastChar = 0;
|
var lastChar = 0;
|
||||||
|
|
||||||
for (final span in sorted) {
|
for (final span in sorted) {
|
||||||
final sChar = span.start < byteToChar.length ? byteToChar[span.start] : source.length;
|
final sChar = span.start < byteToChar.length ? byteToChar[span.start] : source.length;
|
||||||
final eChar = span.end < byteToChar.length ? byteToChar[span.end] : source.length;
|
final eChar = span.end < byteToChar.length ? byteToChar[span.end] : source.length;
|
||||||
final clippedStart = sChar < lastChar ? lastChar : sChar;
|
final clippedStart = sChar < lastChar ? lastChar : sChar;
|
||||||
if (clippedStart > lastChar) {
|
if (clippedStart > lastChar) {
|
||||||
children.add(TextSpan(text: source.substring(lastChar, clippedStart)));
|
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;
|
|
||||||
}
|
}
|
||||||
if (lastChar < source.length) {
|
if (eChar > clippedStart) {
|
||||||
children.add(TextSpan(text: source.substring(lastChar)));
|
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);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ library;
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:clide/builtin/deeplink/deeplink.dart';
|
import 'package:clide/builtin/deeplink/deeplink.dart';
|
||||||
|
import 'package:clide/extension/extension.dart' show CommandContribution;
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
@@ -53,4 +54,25 @@ void main() {
|
|||||||
expect(f.services.dialog.isOpen, isFalse);
|
expect(f.services.dialog.isOpen, isFalse);
|
||||||
expect(r.data['status'], 'rejected');
|
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');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = <List<String>>[];
|
||||||
|
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 = <List<String>>[];
|
||||||
|
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 = <OsLifecycleEvent>[];
|
||||||
|
final sub = bus.on<OsLifecycleEvent>().listen(seen.add);
|
||||||
|
OsBridge(log: Logger(), events: bus).fire('resume');
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
await sub.cancel();
|
||||||
|
expect(seen.single.kind, 'resume');
|
||||||
|
expect(seen.single.subsystem, 'os');
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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<SurfaceTokens> 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');
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user