Add `colorForRole` switch-arm tests (every role → token mapping plus the unknown-role fallback). Introduce a DI seam in `TreeSitterService` and `TreeSitterLib` so tests can substitute the FFI surface and asset loaders without dlopen'ing `libtree-sitter.so` — `TreeSitterLib.testing(...)` takes named per-function overrides with safe no-op defaults, and `TreeSitterLib.fromDynamicLibrary(...)` lets the smoke test load the vendored library explicitly. Production paths (`TreeSitterService.shared`, `TreeSitterLib.instance`) are unchanged. Fake-FFI tests walk every branch of `_init`, `_loadGrammar`, `highlight`, and `dispose`. The smoke test catches FFI-signature regressions the fakes can't, by exercising the real native library end-to-end on Linux. Together this takes `tree_sitter_service.dart` from 17% to 96% and crosses the global 95% target — closing out the D-66 line-coverage epic. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
165 lines
6.3 KiB
Dart
165 lines
6.3 KiB
Dart
/// Tests the graceful-fallback paths of `TreeSitterService` when the
|
|
/// native `libtree-sitter` library isn't dlopen-resolvable. In that
|
|
/// environment the service short-circuits every public method without
|
|
/// throwing — which is also how the running app behaves on platforms
|
|
/// where the library hasn't been bundled.
|
|
library;
|
|
|
|
import 'package:clide/kernel/src/syntax/tree_sitter_service.dart';
|
|
import 'package:clide/kernel/src/theme/tokens.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
|
|
import '../../../helpers/kernel_fixture.dart';
|
|
|
|
void main() {
|
|
TestWidgetsFlutterBinding.ensureInitialized();
|
|
|
|
group('TreeSitterService — fallback paths', () {
|
|
final svc = TreeSitterService.shared;
|
|
|
|
test('hasGrammar(path) returns false for an unknown extension', () async {
|
|
expect(await svc.hasGrammar('foo.unknownextension'), isFalse);
|
|
});
|
|
|
|
test('hasGrammar(path) returns false when the library cannot load', () async {
|
|
// 'foo.dart' resolves to grammar "dart", which still requires the
|
|
// native library + asset to actually load. In the test runner the
|
|
// native lib isn't on the dlopen search path, so _init() fails and
|
|
// hasGrammar reports false.
|
|
expect(await svc.hasGrammar('foo.dart'), isFalse);
|
|
});
|
|
|
|
test('languageFor(path) returns null when the language is unknown', () async {
|
|
expect(await svc.languageFor('foo.unknownextension'), isNull);
|
|
});
|
|
|
|
test('languageFor(path) returns null when the library cannot load', () async {
|
|
expect(await svc.languageFor('foo.dart'), isNull);
|
|
});
|
|
|
|
test('highlight returns an empty result for unknown extensions', () async {
|
|
final r = await svc.highlight('foo.unknownextension', 'whatever');
|
|
expect(r.spans, isEmpty);
|
|
});
|
|
|
|
test('highlight returns an empty result when the library cannot load', () async {
|
|
final r = await svc.highlight('foo.dart', 'void main() {}');
|
|
expect(r.spans, isEmpty);
|
|
});
|
|
|
|
test('loadedLanguages getter returns a list (may be empty)', () {
|
|
// Just exercise the getter — the actual contents depend on the
|
|
// environment.
|
|
expect(svc.loadedLanguages, isA<List<String>>());
|
|
});
|
|
});
|
|
|
|
group('SyntaxResult / SyntaxSpan', () {
|
|
test('SyntaxResult.empty has no spans', () {
|
|
expect(SyntaxResult.empty.spans, isEmpty);
|
|
});
|
|
|
|
test('SyntaxSpan stores start / end / role', () {
|
|
const s = SyntaxSpan(start: 3, end: 7, role: 'keyword');
|
|
expect(s.start, 3);
|
|
expect(s.end, 7);
|
|
expect(s.role, 'keyword');
|
|
});
|
|
});
|
|
|
|
group('TreeSitterService.colorForRole', () {
|
|
late SurfaceTokens tokens;
|
|
|
|
setUpAll(() async {
|
|
final f = await KernelFixture.create();
|
|
tokens = f.services.theme.current.surface;
|
|
await f.dispose();
|
|
});
|
|
|
|
test('every keyword-flavoured role maps to syntaxKeyword', () {
|
|
for (final role in ['keyword', 'repeat', 'conditional', 'include', 'exception', 'operator']) {
|
|
expect(TreeSitterService.colorForRole(role, tokens), tokens.syntaxKeyword, reason: role);
|
|
}
|
|
});
|
|
|
|
test('type-flavoured roles map to syntaxType', () {
|
|
for (final role in ['type', 'type.builtin', 'constructor']) {
|
|
expect(TreeSitterService.colorForRole(role, tokens), tokens.syntaxType, reason: role);
|
|
}
|
|
});
|
|
|
|
test('string-flavoured roles map to syntaxString', () {
|
|
for (final role in ['string', 'string.special']) {
|
|
expect(TreeSitterService.colorForRole(role, tokens), tokens.syntaxString, reason: role);
|
|
}
|
|
});
|
|
|
|
test('number-flavoured roles map to syntaxNumber', () {
|
|
for (final role in ['number', 'float', 'boolean']) {
|
|
expect(TreeSitterService.colorForRole(role, tokens), tokens.syntaxNumber, reason: role);
|
|
}
|
|
});
|
|
|
|
test('comment maps to syntaxComment', () {
|
|
expect(TreeSitterService.colorForRole('comment', tokens), tokens.syntaxComment);
|
|
});
|
|
|
|
test('function-flavoured roles map to syntaxMethod', () {
|
|
for (final role in ['function', 'function.builtin', 'function.method', 'method']) {
|
|
expect(TreeSitterService.colorForRole(role, tokens), tokens.syntaxMethod, reason: role);
|
|
}
|
|
});
|
|
|
|
test('punctuation roles map to syntaxPunct', () {
|
|
for (final role in ['punctuation.bracket', 'punctuation.delimiter', 'punctuation.special']) {
|
|
expect(TreeSitterService.colorForRole(role, tokens), tokens.syntaxPunct, reason: role);
|
|
}
|
|
});
|
|
|
|
test('variable roles fall back to globalForeground', () {
|
|
for (final role in ['variable', 'variable.builtin', 'variable.parameter']) {
|
|
expect(TreeSitterService.colorForRole(role, tokens), tokens.globalForeground, reason: role);
|
|
}
|
|
});
|
|
|
|
test('property / field share syntaxMethod with functions', () {
|
|
for (final role in ['property', 'field']) {
|
|
expect(TreeSitterService.colorForRole(role, tokens), tokens.syntaxMethod, reason: role);
|
|
}
|
|
});
|
|
|
|
test('constant roles share syntaxNumber with numbers', () {
|
|
for (final role in ['constant', 'constant.builtin']) {
|
|
expect(TreeSitterService.colorForRole(role, tokens), tokens.syntaxNumber, reason: role);
|
|
}
|
|
});
|
|
|
|
test('tag / attribute share syntaxKeyword', () {
|
|
for (final role in ['tag', 'attribute']) {
|
|
expect(TreeSitterService.colorForRole(role, tokens), tokens.syntaxKeyword, reason: role);
|
|
}
|
|
});
|
|
|
|
test('namespace / module share syntaxType', () {
|
|
for (final role in ['namespace', 'module']) {
|
|
expect(TreeSitterService.colorForRole(role, tokens), tokens.syntaxType, reason: role);
|
|
}
|
|
});
|
|
|
|
test('markdown text.* roles distribute across keyword / string / type tokens', () {
|
|
expect(TreeSitterService.colorForRole('text.title', tokens), tokens.syntaxKeyword);
|
|
for (final role in ['text.literal', 'text.reference', 'text.uri']) {
|
|
expect(TreeSitterService.colorForRole(role, tokens), tokens.syntaxString, reason: role);
|
|
}
|
|
for (final role in ['text.emphasis', 'text.strong']) {
|
|
expect(TreeSitterService.colorForRole(role, tokens), tokens.syntaxType, reason: role);
|
|
}
|
|
});
|
|
|
|
test('unknown roles fall through to globalForeground', () {
|
|
expect(TreeSitterService.colorForRole('definitely-not-a-real-role', tokens), tokens.globalForeground);
|
|
expect(TreeSitterService.colorForRole('', tokens), tokens.globalForeground);
|
|
});
|
|
});
|
|
}
|