diff --git a/pubspec.yaml b/pubspec.yaml index cfa1d757..7e9d82a4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -18,7 +18,7 @@ repository: https://github.com/postmeridiem/clide # Pre-push line-coverage floor. Ratchets up only — see D-66. # Reading: `awk -F: '/^coverage_floor:/ {gsub(/ /,"",$2); print $2}' pubspec.yaml`. -coverage_floor: 70 +coverage_floor: 71 # Project metadata (was project.yaml, folded in per D-056). # version: above is the single source of truth. The Makefile reads diff --git a/test/terminal/custom_text_edit_test.dart b/test/terminal/custom_text_edit_test.dart new file mode 100644 index 00000000..966c2dbb --- /dev/null +++ b/test/terminal/custom_text_edit_test.dart @@ -0,0 +1,287 @@ +/// Widget tests for `lib/src/terminal/src/ui/custom_text_edit.dart` — +/// covers the focus / connection lifecycle, the editing-value +/// callbacks (insert / delete / composing / action), the input-state +/// helpers (setEditingState, setEditableRect, closeKeyboard), and the +/// TextInputClient stub overrides. +library; + +import 'package:clide/src/terminal/src/ui/custom_text_edit.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class _Recorder { + final inserts = []; + var deletes = 0; + final composings = []; + final actions = []; + final keys = []; +} + +Widget _host(Widget child) => Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(), + child: Center(child: SizedBox(width: 200, height: 100, child: child)), + ), + ); + +CustomTextEdit _build({ + required FocusNode focusNode, + required _Recorder r, + bool autofocus = false, + bool readOnly = false, + bool deleteDetection = false, + KeyEventResult Function(FocusNode, KeyEvent)? onKeyEvent, +}) { + return CustomTextEdit( + focusNode: focusNode, + autofocus: autofocus, + readOnly: readOnly, + deleteDetection: deleteDetection, + onInsert: r.inserts.add, + onDelete: () => r.deletes++, + onComposing: r.composings.add, + onAction: r.actions.add, + onKeyEvent: onKeyEvent ?? + (node, event) { + r.keys.add(event); + return KeyEventResult.ignored; + }, + child: const SizedBox.expand(), + ); +} + +void main() { + group('CustomTextEdit — focus + input-connection lifecycle', () { + testWidgets('autofocus opens an input connection on first frame', (tester) async { + final r = _Recorder(); + final focus = FocusNode(); + addTearDown(focus.dispose); + await tester.pumpWidget(_host(_build(focusNode: focus, r: r, autofocus: true))); + await tester.pump(); + final state = tester.state(find.byType(CustomTextEdit)); + expect(state.hasInputConnection, isTrue); + }); + + testWidgets('losing focus closes the connection', (tester) async { + final r = _Recorder(); + final focus = FocusNode(); + addTearDown(focus.dispose); + await tester.pumpWidget(_host(_build(focusNode: focus, r: r, autofocus: true))); + await tester.pump(); + final state = tester.state(find.byType(CustomTextEdit)); + expect(state.hasInputConnection, isTrue); + focus.unfocus(); + await tester.pump(); + expect(state.hasInputConnection, isFalse); + }); + + testWidgets('readOnly=true skips connection on focus', (tester) async { + final r = _Recorder(); + final focus = FocusNode(); + addTearDown(focus.dispose); + await tester.pumpWidget(_host(_build(focusNode: focus, r: r, autofocus: true, readOnly: true))); + await tester.pump(); + final state = tester.state(find.byType(CustomTextEdit)); + expect(state.hasInputConnection, isFalse); + }); + + testWidgets('didUpdateWidget readOnly:true→false while focused re-opens the connection', (tester) async { + final r = _Recorder(); + final focus = FocusNode(); + addTearDown(focus.dispose); + // Start read-only + focused → no connection. + await tester.pumpWidget(_host(_build(focusNode: focus, r: r, autofocus: true, readOnly: true))); + await tester.pump(); + final state = tester.state(find.byType(CustomTextEdit)); + expect(state.hasInputConnection, isFalse); + // Flip to read-write — didUpdateWidget should re-open. + await tester.pumpWidget(_host(_build(focusNode: focus, r: r, autofocus: true, readOnly: false))); + await tester.pump(); + expect(state.hasInputConnection, isTrue); + }); + + testWidgets('didUpdateWidget readOnly:false→true closes the connection', (tester) async { + final r = _Recorder(); + final focus = FocusNode(); + addTearDown(focus.dispose); + await tester.pumpWidget(_host(_build(focusNode: focus, r: r, autofocus: true, readOnly: false))); + await tester.pump(); + final state = tester.state(find.byType(CustomTextEdit)); + expect(state.hasInputConnection, isTrue); + // Flip to read-only — _shouldCreateInputConnection becomes false. + await tester.pumpWidget(_host(_build(focusNode: focus, r: r, autofocus: true, readOnly: true))); + await tester.pump(); + expect(state.hasInputConnection, isFalse); + }); + + testWidgets('didUpdateWidget swapping focusNode rewires the listener', (tester) async { + final r = _Recorder(); + final focusA = FocusNode(); + final focusB = FocusNode(); + addTearDown(focusA.dispose); + addTearDown(focusB.dispose); + await tester.pumpWidget(_host(_build(focusNode: focusA, r: r, autofocus: true))); + await tester.pump(); + await tester.pumpWidget(_host(_build(focusNode: focusB, r: r))); + await tester.pump(); + final state = tester.state(find.byType(CustomTextEdit)); + expect(state.mounted, isTrue); + }); + }); + + group('CustomTextEdit — keyboard helpers', () { + testWidgets('requestKeyboard opens a connection when focused; otherwise requests focus', (tester) async { + final r = _Recorder(); + final focus = FocusNode(); + addTearDown(focus.dispose); + await tester.pumpWidget(_host(_build(focusNode: focus, r: r))); + final state = tester.state(find.byType(CustomTextEdit)); + // Unfocused → requestKeyboard requests focus. + state.requestKeyboard(); + await tester.pump(); + expect(focus.hasFocus, isTrue); + // Already-focused → requestKeyboard opens the connection directly. + state.requestKeyboard(); + await tester.pump(); + expect(state.hasInputConnection, isTrue); + }); + + testWidgets('closeKeyboard closes an active connection', (tester) async { + final r = _Recorder(); + final focus = FocusNode(); + addTearDown(focus.dispose); + await tester.pumpWidget(_host(_build(focusNode: focus, r: r, autofocus: true))); + await tester.pump(); + final state = tester.state(find.byType(CustomTextEdit)); + expect(state.hasInputConnection, isTrue); + state.closeKeyboard(); + await tester.pump(); + // hasInputConnection reflects the underlying connection state — close + // returns it to false. + expect(state.hasInputConnection, isFalse); + }); + + testWidgets('closeKeyboard is a safe no-op when no connection exists', (tester) async { + final r = _Recorder(); + final focus = FocusNode(); + addTearDown(focus.dispose); + await tester.pumpWidget(_host(_build(focusNode: focus, r: r))); + final state = tester.state(find.byType(CustomTextEdit)); + state.closeKeyboard(); + expect(state.hasInputConnection, isFalse); + }); + + testWidgets('setEditingState updates the cached value and forwards to the connection', (tester) async { + final r = _Recorder(); + final focus = FocusNode(); + addTearDown(focus.dispose); + await tester.pumpWidget(_host(_build(focusNode: focus, r: r, autofocus: true))); + await tester.pump(); + final state = tester.state(find.byType(CustomTextEdit)); + const value = TextEditingValue(text: 'edit', selection: TextSelection.collapsed(offset: 4)); + state.setEditingState(value); + expect(state.currentTextEditingValue, value); + }); + + testWidgets('setEditableRect early-returns when there is no connection', (tester) async { + final r = _Recorder(); + final focus = FocusNode(); + addTearDown(focus.dispose); + await tester.pumpWidget(_host(_build(focusNode: focus, r: r))); + final state = tester.state(find.byType(CustomTextEdit)); + // No connection → must not throw. + state.setEditableRect(const Rect.fromLTWH(0, 0, 10, 10), const Rect.fromLTWH(0, 0, 1, 10)); + }); + + testWidgets('setEditableRect forwards size + caret when a connection is open', (tester) async { + final r = _Recorder(); + final focus = FocusNode(); + addTearDown(focus.dispose); + await tester.pumpWidget(_host(_build(focusNode: focus, r: r, autofocus: true))); + await tester.pump(); + final state = tester.state(find.byType(CustomTextEdit)); + // Just verify no throw — the connection is real (testTextInput) and + // accepts the calls. + state.setEditableRect(const Rect.fromLTWH(0, 0, 50, 20), const Rect.fromLTWH(0, 0, 1, 20)); + }); + }); + + group('CustomTextEdit — TextInputClient surface', () { + testWidgets('updateEditingValue routes inserts to onInsert', (tester) async { + final r = _Recorder(); + final focus = FocusNode(); + addTearDown(focus.dispose); + await tester.pumpWidget(_host(_build(focusNode: focus, r: r, autofocus: true))); + await tester.pump(); + tester.testTextInput.enterText('abc'); + await tester.pump(); + expect(r.inserts.any((s) => s.contains('abc')), isTrue); + }); + + testWidgets('updateEditingValue routes a delete to onDelete (deleteDetection mode)', (tester) async { + final r = _Recorder(); + final focus = FocusNode(); + addTearDown(focus.dispose); + // deleteDetection=true means the init editing-state has 2 spaces; + // shrinking the value below that triggers the onDelete path. + await tester.pumpWidget(_host(_build(focusNode: focus, r: r, autofocus: true, deleteDetection: true))); + await tester.pump(); + final state = tester.state(find.byType(CustomTextEdit)); + // Send a value shorter than the init state (2 chars) directly. + state.updateEditingValue(const TextEditingValue( + text: '', + selection: TextSelection.collapsed(offset: 0), + )); + expect(r.deletes, 1); + }); + + testWidgets('updateEditingValue with active composing forwards the composing text', (tester) async { + final r = _Recorder(); + final focus = FocusNode(); + addTearDown(focus.dispose); + await tester.pumpWidget(_host(_build(focusNode: focus, r: r, autofocus: true))); + await tester.pump(); + final state = tester.state(find.byType(CustomTextEdit)); + state.updateEditingValue(const TextEditingValue( + text: 'hi', + selection: TextSelection.collapsed(offset: 2), + composing: TextRange(start: 0, end: 2), + )); + expect(r.composings, contains('hi')); + }); + + testWidgets('performAction routes the action to onAction', (tester) async { + final r = _Recorder(); + final focus = FocusNode(); + addTearDown(focus.dispose); + await tester.pumpWidget(_host(_build(focusNode: focus, r: r, autofocus: true))); + await tester.pump(); + final state = tester.state(find.byType(CustomTextEdit)); + state.performAction(TextInputAction.done); + expect(r.actions, contains(TextInputAction.done)); + }); + + testWidgets('TextInputClient stubs are no-throw', (tester) async { + final r = _Recorder(); + final focus = FocusNode(); + addTearDown(focus.dispose); + await tester.pumpWidget(_host(_build(focusNode: focus, r: r, autofocus: true))); + await tester.pump(); + final state = tester.state(find.byType(CustomTextEdit)); + // Touch every TextInputClient override that's a documented no-op so + // they end up in the coverage map. + state.updateFloatingCursor(RawFloatingCursorPoint(state: FloatingCursorDragState.Start)); + state.showAutocorrectionPromptRect(0, 1); + state.connectionClosed(); + state.performPrivateCommand('cmd', const {}); + state.insertTextPlaceholder(const Size(10, 10)); + state.removeTextPlaceholder(); + state.showToolbar(); + // Getters + expect(state.currentTextEditingValue, isNotNull); + expect(state.currentAutofillScope, isNull); + }); + }); +}