Files
clide/test/terminal/custom_text_edit_test.dart
jpmschweitzerandClaude Opus 4.8 6d0ebab721 chore: adopt Dart 3.9 toolchain — honest floor + tall-style reformat (T-353)
Raise the declared minimums in pubspec.yaml to what our deps already
require: Flutter >=3.35.0 / Dart >=3.9.0 (was 3.19.0 / 3.5.0). alchemist
0.12 needs Flutter 3.32; Dart 3.9 first ships in Flutter 3.35, so 3.35 is
the binding floor. Pin the exact build toolchain in .fvmrc (Flutter
3.44.1).

Moving to the Dart 3.9 language level switches `dart format` to the new
"tall" style and enables two new lints. This commit is the resulting
mechanical churn, isolated from any behaviour change:
  - whole-tree `dart format` reformat (tall style)
  - `dart fix` for unnecessary_underscores + use_null_aware_elements

No runtime behaviour change; `make test` green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 12:11:53 +02:00

282 lines
12 KiB
Dart

/// 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 = <String>[];
var deletes = 0;
final composings = <String?>[];
final actions = <TextInputAction>[];
final keys = <KeyEvent>[];
}
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<CustomTextEditState>(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<CustomTextEditState>(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<CustomTextEditState>(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<CustomTextEditState>(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<CustomTextEditState>(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<CustomTextEditState>(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<CustomTextEditState>(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<CustomTextEditState>(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<CustomTextEditState>(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<CustomTextEditState>(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<CustomTextEditState>(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<CustomTextEditState>(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<CustomTextEditState>(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<CustomTextEditState>(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<CustomTextEditState>(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<CustomTextEditState>(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);
});
});
}