test: cover editor edit/save/close paths + SyntaxTextController
test / unit + widget + golden + a11y (push) Failing after 25s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
test / dart doc (lib API) (push) Failing after 24s
test / unit + widget + golden + a11y (push) Failing after 25s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
test / dart doc (lib API) (push) Failing after 24s
Follow-up to the multi-tab editor: extends the controller and view tests to the close-tab, type-to-edit, and Ctrl+S-save paths, plus a handful of controller edge branches (read errors, null-id events, the suppressed self-echo, sibling-dirty marking). Adds a SyntaxTextController suite driving a fake TreeSitterService so the span→TextSpan rendering and byte→char mapping (including surrogate pairs) are exercised without the native grammar. Restores total line coverage above the 95% floor. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -200,26 +200,53 @@ void main() {
|
||||
expect(c.dirty, isFalse);
|
||||
});
|
||||
|
||||
test('editor.edited marks the buffer dirty', () async {
|
||||
test('editor.edited marks the right buffer dirty, leaving siblings alone', () async {
|
||||
ipc.stub(
|
||||
'editor.list',
|
||||
(_) async => _ok({
|
||||
'buffers': [_buf('b_1', 'a.dart')]
|
||||
'buffers': [_buf('b_1', 'a.dart'), _buf('b_2', 'b.dart')]
|
||||
}));
|
||||
ipc.stub(
|
||||
'editor.active',
|
||||
(_) async => _ok({
|
||||
'active': {'id': 'b_1'}
|
||||
}));
|
||||
ipc.stub('editor.read', (_) async => _ok(_read('b_1', 'a.dart', 'x')));
|
||||
ipc.stub('editor.read', (a) async => _ok(_read(a['id'] as String, 'a.dart', 'x')));
|
||||
await c.hydrate();
|
||||
expect(c.buffers.single.dirty, isFalse);
|
||||
expect(c.buffers.every((b) => !b.dirty), isTrue);
|
||||
|
||||
// A remote edit (not our own — no set-content was issued).
|
||||
emitEditor(bus, 'editor.edited', {'id': 'b_1'});
|
||||
// A remote edit of b_2 (not our own). b_1 stays clean — exercises
|
||||
// the untouched-sibling branch of the dirty marker.
|
||||
emitEditor(bus, 'editor.edited', {'id': 'b_2'});
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(c.buffers.single.dirty, isTrue);
|
||||
expect(c.buffers.firstWhere((b) => b.id == 'b_2').dirty, isTrue);
|
||||
expect(c.buffers.firstWhere((b) => b.id == 'b_1').dirty, isFalse);
|
||||
});
|
||||
|
||||
test('editor.active-changed to a different buffer loads its content', () async {
|
||||
ipc.stub(
|
||||
'editor.list',
|
||||
(_) async => _ok({
|
||||
'buffers': [_buf('b_1', 'a.dart'), _buf('b_2', 'b.dart')]
|
||||
}));
|
||||
ipc.stub(
|
||||
'editor.active',
|
||||
(_) async => _ok({
|
||||
'active': {'id': 'b_1'}
|
||||
}));
|
||||
ipc.stub('editor.read', (a) async {
|
||||
final id = a['id'] as String;
|
||||
return _ok(_read(id, id == 'b_1' ? 'a.dart' : 'b.dart', 'body-$id'));
|
||||
});
|
||||
await c.hydrate();
|
||||
expect(c.activeId, 'b_1');
|
||||
|
||||
emitEditor(bus, 'editor.active-changed', {'id': 'b_2'});
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(c.activeId, 'b_2');
|
||||
expect(c.content, 'body-b_2');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -251,5 +278,155 @@ void main() {
|
||||
expect(setArgs?['id'], 'b_1');
|
||||
expect(setArgs?['text'], 'xy');
|
||||
});
|
||||
|
||||
test('save() issues editor.save for the active buffer', () async {
|
||||
ipc.stub(
|
||||
'editor.list',
|
||||
(_) async => _ok({
|
||||
'buffers': [_buf('b_1', 'a.dart')]
|
||||
}));
|
||||
ipc.stub(
|
||||
'editor.active',
|
||||
(_) async => _ok({
|
||||
'active': {'id': 'b_1'}
|
||||
}));
|
||||
ipc.stub('editor.read', (_) async => _ok(_read('b_1', 'a.dart', 'x')));
|
||||
await c.hydrate();
|
||||
|
||||
String? saved;
|
||||
ipc.stub('editor.save', (a) async {
|
||||
saved = a['id'] as String?;
|
||||
return _ok(const {});
|
||||
});
|
||||
await c.save();
|
||||
expect(saved, 'b_1');
|
||||
});
|
||||
|
||||
test('save() is a no-op with no active buffer', () async {
|
||||
var calls = 0;
|
||||
ipc.stub('editor.save', (_) async {
|
||||
calls++;
|
||||
return _ok(const {});
|
||||
});
|
||||
await c.save(); // never hydrated → no active
|
||||
expect(calls, 0);
|
||||
});
|
||||
});
|
||||
|
||||
group('edge cases', () {
|
||||
test('editor.active-changed to null clears the active buffer', () async {
|
||||
ipc.stub(
|
||||
'editor.list',
|
||||
(_) async => _ok({
|
||||
'buffers': [_buf('b_1', 'a.dart')]
|
||||
}));
|
||||
ipc.stub(
|
||||
'editor.active',
|
||||
(_) async => _ok({
|
||||
'active': {'id': 'b_1'}
|
||||
}));
|
||||
ipc.stub('editor.read', (_) async => _ok(_read('b_1', 'a.dart', 'x')));
|
||||
await c.hydrate();
|
||||
expect(c.activeId, 'b_1');
|
||||
|
||||
emitEditor(bus, 'editor.active-changed', const {}); // no id
|
||||
await pumpEventQueue();
|
||||
expect(c.activeId, isNull);
|
||||
expect(c.content, '');
|
||||
});
|
||||
|
||||
test('hydrate surfaces an error when editor.active fails', () async {
|
||||
ipc.stub('editor.list', (_) async => _ok({'buffers': const []}));
|
||||
ipc.stub(
|
||||
'editor.active',
|
||||
(_) async => IpcResponse.err(
|
||||
id: '',
|
||||
error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'boom'),
|
||||
),
|
||||
);
|
||||
await c.hydrate();
|
||||
expect(c.error, 'boom');
|
||||
});
|
||||
|
||||
test('a missing-stub editor.list (not ok) leaves the buffer list empty', () async {
|
||||
// No editor.list stub registered → FakeDaemonClient returns a
|
||||
// notFound error; _refreshList bails without crashing.
|
||||
ipc.stub('editor.active', (_) async => _ok(const {}));
|
||||
await c.hydrate();
|
||||
expect(c.buffers, isEmpty);
|
||||
});
|
||||
|
||||
test('editor.opened with no id clears the active buffer', () async {
|
||||
ipc.stub(
|
||||
'editor.list',
|
||||
(_) async => _ok({
|
||||
'buffers': [_buf('b_1', 'a.dart')]
|
||||
}));
|
||||
ipc.stub(
|
||||
'editor.active',
|
||||
(_) async => _ok({
|
||||
'active': {'id': 'b_1'}
|
||||
}));
|
||||
ipc.stub('editor.read', (_) async => _ok(_read('b_1', 'a.dart', 'x')));
|
||||
await c.hydrate();
|
||||
expect(c.activeId, 'b_1');
|
||||
|
||||
emitEditor(bus, 'editor.opened', const {}); // no id
|
||||
await pumpEventQueue();
|
||||
expect(c.activeId, isNull);
|
||||
});
|
||||
|
||||
test('a failing editor.read surfaces the error', () async {
|
||||
ipc.stub(
|
||||
'editor.list',
|
||||
(_) async => _ok({
|
||||
'buffers': [_buf('b_1', 'a.dart')]
|
||||
}));
|
||||
ipc.stub(
|
||||
'editor.active',
|
||||
(_) async => _ok({
|
||||
'active': {'id': 'b_1'}
|
||||
}));
|
||||
ipc.stub(
|
||||
'editor.read',
|
||||
(_) async => IpcResponse.err(
|
||||
id: '',
|
||||
error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'read failed'),
|
||||
),
|
||||
);
|
||||
await c.hydrate();
|
||||
expect(c.error, 'read failed');
|
||||
});
|
||||
|
||||
test('our own edit echo (editor.edited) is suppressed once, not reloaded', () async {
|
||||
ipc.stub(
|
||||
'editor.list',
|
||||
(_) async => _ok({
|
||||
'buffers': [_buf('b_1', 'a.dart')]
|
||||
}));
|
||||
ipc.stub(
|
||||
'editor.active',
|
||||
(_) async => _ok({
|
||||
'active': {'id': 'b_1'}
|
||||
}));
|
||||
ipc.stub('editor.read', (_) async => _ok(_read('b_1', 'a.dart', 'original')));
|
||||
ipc.stub('editor.set-content', (_) async => _ok(const {}));
|
||||
await c.hydrate();
|
||||
|
||||
// Local edit arms _suppressNextRemoteEdit.
|
||||
c.pushLocalEdit(newContent: 'local', newSelection: const Selection.collapsed(5));
|
||||
var reads = 0;
|
||||
ipc.stub('editor.read', (_) async {
|
||||
reads++;
|
||||
return _ok(_read('b_1', 'a.dart', 'reloaded'));
|
||||
});
|
||||
// The echo of our own set-content comes back as editor.edited.
|
||||
emitEditor(bus, 'editor.edited', {'id': 'b_1'});
|
||||
await pumpEventQueue();
|
||||
|
||||
// Suppressed: no reload, local content preserved.
|
||||
expect(reads, 0);
|
||||
expect(c.content, 'local');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,11 +6,16 @@ library;
|
||||
|
||||
import 'package:clide/builtin/editor/src/editor_view.dart';
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../helpers/kernel_fixture.dart';
|
||||
import '../../helpers/widget_harness.dart';
|
||||
|
||||
Finder _closeIcons() => find.byWidgetPredicate((w) => w is ClideIcon && w.painter is CloseIcon);
|
||||
|
||||
IpcResponse _ok(Map<String, Object?> data) => IpcResponse.ok(id: '', data: data);
|
||||
|
||||
Map<String, Object?> _buf(String id, String path, {bool dirty = false}) => {'id': id, 'path': path, 'dirty': dirty};
|
||||
@@ -82,5 +87,59 @@ void main() {
|
||||
|
||||
expect(activated, 'b_2');
|
||||
});
|
||||
|
||||
testWidgets('closing a tab routes to editor.close', (tester) async {
|
||||
stubBuffers([_buf('b_1', 'lib/a.dart')], active: 'b_1');
|
||||
String? closed;
|
||||
f.ipc.stub('editor.close', (a) async {
|
||||
closed = a['id'] as String?;
|
||||
return _ok(const {});
|
||||
});
|
||||
await tester.pumpWidget(harness(f, const EditorView()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(_closeIcons(), findsWidgets);
|
||||
await tester.tap(_closeIcons().first);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(closed, 'b_1');
|
||||
});
|
||||
|
||||
testWidgets('typing in the editor mirrors to editor.set-content', (tester) async {
|
||||
stubBuffers([_buf('b_1', 'lib/a.dart')], active: 'b_1');
|
||||
Map<String, Object?>? setArgs;
|
||||
f.ipc.stub('editor.set-content', (a) async {
|
||||
setArgs = a;
|
||||
return _ok(const {});
|
||||
});
|
||||
await tester.pumpWidget(harness(f, const EditorView()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(find.byType(EditableText), 'edited body');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(setArgs?['id'], 'b_1');
|
||||
expect(setArgs?['text'], 'edited body');
|
||||
});
|
||||
|
||||
testWidgets('Ctrl+S in the editor triggers editor.save', (tester) async {
|
||||
stubBuffers([_buf('b_1', 'lib/a.dart')], active: 'b_1');
|
||||
String? saved;
|
||||
f.ipc.stub('editor.save', (a) async {
|
||||
saved = a['id'] as String?;
|
||||
return _ok(const {});
|
||||
});
|
||||
await tester.pumpWidget(harness(f, const EditorView()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byType(EditableText));
|
||||
await tester.pump();
|
||||
await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyS);
|
||||
await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(saved, 'b_1');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
/// Tests SyntaxTextController — the editor's TextEditingController that
|
||||
/// turns tree-sitter spans into styled TextSpans. A fake
|
||||
/// TreeSitterService supplies canned spans so the byte→char mapping and
|
||||
/// span-rendering logic can be exercised without the native grammar.
|
||||
library;
|
||||
|
||||
import 'package:clide/builtin/editor/src/syntax_text_controller.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/kernel/src/syntax/tree_sitter_service.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../helpers/kernel_fixture.dart';
|
||||
import '../../helpers/widget_harness.dart';
|
||||
|
||||
/// TreeSitterService stub returning canned spans (constructor does no
|
||||
/// FFI work — `_init` is lazy and never reached here).
|
||||
class _FakeSyntax extends TreeSitterService {
|
||||
_FakeSyntax(this.spans);
|
||||
final List<SyntaxSpan> spans;
|
||||
@override
|
||||
Future<SyntaxResult> highlight(String path, String source) async => SyntaxResult(spans);
|
||||
}
|
||||
|
||||
/// Counts highlight invocations so a test can assert a same-path
|
||||
/// updatePath doesn't kick off a redundant highlight.
|
||||
class _CountingSyntax extends TreeSitterService {
|
||||
int calls = 0;
|
||||
@override
|
||||
Future<SyntaxResult> highlight(String path, String source) async {
|
||||
calls++;
|
||||
return const SyntaxResult([]);
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('SyntaxTextController', () {
|
||||
late KernelFixture f;
|
||||
setUp(() async => f = await KernelFixture.create());
|
||||
tearDown(() => f.dispose());
|
||||
|
||||
/// Pump a themed context, run [body] with a controller already
|
||||
/// given tokens from the active theme.
|
||||
Future<void> withController(
|
||||
WidgetTester tester,
|
||||
SyntaxTextController c,
|
||||
Future<void> Function(BuildContext ctx) body,
|
||||
) async {
|
||||
late BuildContext ctx;
|
||||
await tester.pumpWidget(harness(f, Builder(builder: (context) {
|
||||
ctx = context;
|
||||
return const SizedBox();
|
||||
})));
|
||||
c.tokens = ClideTheme.of(ctx).surface;
|
||||
await body(ctx);
|
||||
}
|
||||
|
||||
testWidgets('renders highlighted spans as styled TextSpan children', (tester) async {
|
||||
final c = SyntaxTextController(
|
||||
syntax: _FakeSyntax(const [
|
||||
SyntaxSpan(start: 0, end: 5, role: 'keyword'), // "class"
|
||||
SyntaxSpan(start: 6, end: 9, role: 'type'), // "Foo"
|
||||
]));
|
||||
await withController(tester, c, (ctx) async {
|
||||
c.text = 'class Foo {}';
|
||||
c.updatePath('a.dart');
|
||||
await tester.pumpAndSettle();
|
||||
final span = c.buildTextSpan(context: ctx, withComposing: false);
|
||||
expect(span.children, isNotNull);
|
||||
// keyword + gap + type + trailing → several children.
|
||||
expect(span.children!.length, greaterThan(2));
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('maps byte offsets across multi-byte (surrogate) characters', (tester) async {
|
||||
// '😀' is a surrogate pair (4 UTF-8 bytes). A span after it must
|
||||
// still land on the right character offset.
|
||||
final c = SyntaxTextController(
|
||||
syntax: _FakeSyntax(const [
|
||||
SyntaxSpan(start: 5, end: 8, role: 'type'), // "Foo" after "😀 "
|
||||
]));
|
||||
await withController(tester, c, (ctx) async {
|
||||
c.text = '😀 Foo';
|
||||
c.updatePath('a.dart');
|
||||
await tester.pumpAndSettle();
|
||||
final span = c.buildTextSpan(context: ctx, withComposing: false);
|
||||
expect(span.children, isNotNull);
|
||||
expect(span.toPlainText(), '😀 Foo');
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('with no spans falls back to a plain TextSpan', (tester) async {
|
||||
final c = SyntaxTextController(syntax: _FakeSyntax(const []));
|
||||
await withController(tester, c, (ctx) async {
|
||||
c.text = 'plain text';
|
||||
final span = c.buildTextSpan(context: ctx, withComposing: false);
|
||||
expect(span.children, isNull);
|
||||
expect(span.text, 'plain text');
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('updatePath to the same path is a no-op', (tester) async {
|
||||
final syntax = _CountingSyntax();
|
||||
final c = SyntaxTextController(syntax: syntax);
|
||||
await withController(tester, c, (ctx) async {
|
||||
c.text = 'x';
|
||||
c.updatePath('a.dart');
|
||||
await tester.pumpAndSettle();
|
||||
final first = syntax.calls;
|
||||
expect(first, greaterThan(0));
|
||||
c.updatePath('a.dart'); // same path → early return
|
||||
await tester.pumpAndSettle();
|
||||
expect(syntax.calls, first); // no new highlight request
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('a path with no known grammar skips highlighting', (tester) async {
|
||||
final c = SyntaxTextController(syntax: _FakeSyntax(const [SyntaxSpan(start: 0, end: 1, role: 'x')]));
|
||||
await withController(tester, c, (ctx) async {
|
||||
c.text = 'data';
|
||||
c.updatePath('notes.unknownext');
|
||||
await tester.pumpAndSettle();
|
||||
final span = c.buildTextSpan(context: ctx, withComposing: false);
|
||||
// No grammar → no spans applied → plain text.
|
||||
expect(span.children, isNull);
|
||||
expect(span.text, 'data');
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user