diff --git a/test/builtin/editor/editor_controller_test.dart b/test/builtin/editor/editor_controller_test.dart index c4a6cf63..ecfaddfe 100644 --- a/test/builtin/editor/editor_controller_test.dart +++ b/test/builtin/editor/editor_controller_test.dart @@ -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'); + }); }); } diff --git a/test/builtin/editor/editor_view_test.dart b/test/builtin/editor/editor_view_test.dart index b8941064..cecf6ac7 100644 --- a/test/builtin/editor/editor_view_test.dart +++ b/test/builtin/editor/editor_view_test.dart @@ -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 data) => IpcResponse.ok(id: '', data: data); Map _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? 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'); + }); }); } diff --git a/test/builtin/editor/syntax_text_controller_test.dart b/test/builtin/editor/syntax_text_controller_test.dart new file mode 100644 index 00000000..20901b09 --- /dev/null +++ b/test/builtin/editor/syntax_text_controller_test.dart @@ -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 spans; + @override + Future 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 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 withController( + WidgetTester tester, + SyntaxTextController c, + Future 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'); + }); + }); + }); +}