Files
plezy/test/widgets/tv_virtual_keyboard_test.dart
T

374 lines
12 KiB
Dart

import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:plezy/focus/dpad_navigator.dart';
import 'package:plezy/utils/platform_detector.dart';
import 'package:plezy/widgets/tv_virtual_keyboard.dart';
void main() {
tearDown(() {
TvDetectionService.debugSetAppleTVOverride(null);
});
testWidgets('keyboard enter submits without inserting highlighted key', (tester) async {
final controller = TextEditingController();
String? submitted;
addTearDown(controller.dispose);
await _pumpKeyboard(tester, controller: controller, onSubmitted: (value) => submitted = value);
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await tester.pumpAndSettle();
expect(controller.text, isEmpty);
expect(submitted, isEmpty);
expect(find.byType(Dialog), findsNothing);
});
testWidgets('engine-synthesized select activates highlighted key', (tester) async {
final controller = TextEditingController(text: 'query');
String? submitted;
addTearDown(controller.dispose);
await _pumpKeyboard(tester, controller: controller, onSubmitted: (value) => submitted = value);
await tester.sendKeyEvent(LogicalKeyboardKey.select);
await tester.pumpAndSettle();
expect(controller.text, 'query1');
expect(submitted, isNull);
expect(find.byType(Dialog), findsOneWidget);
});
testWidgets('held select activates the highlighted key once', (tester) async {
final controller = TextEditingController();
addTearDown(controller.dispose);
await _pumpKeyboard(tester, controller: controller);
await tester.sendKeyDownEvent(LogicalKeyboardKey.select);
await tester.sendKeyRepeatEvent(LogicalKeyboardKey.select);
await tester.sendKeyUpEvent(LogicalKeyboardKey.select);
await tester.pump();
expect(controller.text, '1');
expect(find.byType(Dialog), findsOneWidget);
});
testWidgets('unowned keyboard keys remain available to ancestor handlers', (tester) async {
final controller = TextEditingController();
addTearDown(controller.dispose);
await _pumpKeyboard(tester, controller: controller);
final result = _dispatchKey(
const KeyDownEvent(
physicalKey: PhysicalKeyboardKey.f12,
logicalKey: LogicalKeyboardKey.f12,
timeStamp: Duration.zero,
),
);
expect(result, KeyEventResult.ignored);
expect(controller.text, isEmpty);
expect(find.byType(Dialog), findsOneWidget);
});
testWidgets('directional pad enter activates highlighted key', (tester) async {
final controller = TextEditingController();
addTearDown(controller.dispose);
await _pumpKeyboard(tester, controller: controller);
_dispatchKey(
const KeyDownEvent(
physicalKey: PhysicalKeyboardKey.enter,
logicalKey: LogicalKeyboardKey.enter,
timeStamp: Duration.zero,
deviceType: ui.KeyEventDeviceType.directionalPad,
),
);
await tester.pump();
expect(controller.text, '1');
expect(find.byType(Dialog), findsOneWidget);
});
testWidgets('directional navigation selects the expected key and wraps around spacers', (tester) async {
final controller = TextEditingController();
addTearDown(controller.dispose);
await _pumpKeyboard(tester, controller: controller);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
await tester.sendKeyEvent(LogicalKeyboardKey.select);
await tester.pump();
expect(controller.text, '2');
// Re-open at the first key, then wrap left past the leading spacer to 0.
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
await tester.pumpAndSettle();
await _pumpKeyboard(tester, controller: controller);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft);
await tester.sendKeyEvent(LogicalKeyboardKey.select);
await tester.pump();
expect(controller.text, '20');
});
testWidgets('external controller changes rebuild the preview', (tester) async {
final controller = TextEditingController();
addTearDown(controller.dispose);
await _pumpKeyboard(tester, controller: controller);
controller.text = 'updated externally';
await tester.pump();
expect(find.text('updated externally'), findsOneWidget);
});
testWidgets('keyboard enter inserts newline for multiline input', (tester) async {
final controller = TextEditingController(text: 'a');
addTearDown(controller.dispose);
await _pumpKeyboard(tester, controller: controller, keyboardType: TextInputType.multiline, maxLines: 2);
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await tester.pumpAndSettle();
expect(controller.text, 'a\n');
expect(find.byType(Dialog), findsNothing);
});
testWidgets('physical keyboard character inserts text and hides keyboard', (tester) async {
final controller = TextEditingController();
addTearDown(controller.dispose);
await _pumpKeyboard(tester, controller: controller);
await tester.sendKeyEvent(LogicalKeyboardKey.keyA, character: 'a');
await tester.pumpAndSettle();
expect(controller.text, 'a');
expect(find.byType(Dialog), findsNothing);
});
testWidgets('physical keyboard backspace deletes existing text from end and hides keyboard', (tester) async {
final controller = TextEditingController(text: 'query');
controller.selection = const TextSelection.collapsed(offset: 0);
addTearDown(controller.dispose);
await _pumpKeyboard(tester, controller: controller);
await tester.sendKeyEvent(LogicalKeyboardKey.backspace);
await tester.pumpAndSettle();
expect(controller.text, 'quer');
expect(find.byType(Dialog), findsNothing);
});
testWidgets('keyboard is compact and bottom aligned on TV', (tester) async {
final controller = TextEditingController();
addTearDown(controller.dispose);
await _pumpKeyboard(tester, controller: controller);
final panelRect = tester.getRect(find.byKey(const Key('tv_virtual_keyboard_panel')));
expect(panelRect.height, lessThanOrEqualTo(330));
expect(panelRect.width, lessThanOrEqualTo(650));
expect(panelRect.bottom, greaterThan(680));
});
testWidgets('keyboard keeps equals on main page and exposes symbols page', (tester) async {
final controller = TextEditingController();
addTearDown(controller.dispose);
await _pumpKeyboard(tester, controller: controller);
expect(find.text('='), findsOneWidget);
expect(find.byIcon(Symbols.functions_rounded), findsOneWidget);
await tester.tap(find.byIcon(Symbols.functions_rounded));
await tester.pumpAndSettle();
expect(find.text('ABC'), findsOneWidget);
expect(find.text('!'), findsOneWidget);
expect(find.text('='), findsOneWidget);
});
testWidgets('back key dismisses on key up without leaking to underlying route', (tester) async {
TvDetectionService.debugSetAppleTVOverride(true);
await tester.binding.setSurfaceSize(const Size(1280, 720));
addTearDown(() => tester.binding.setSurfaceSize(null));
final controller = TextEditingController();
final underlyingFocusNode = FocusNode(debugLabel: 'underlying_route');
var underlyingBackEvents = 0;
late BuildContext context;
addTearDown(controller.dispose);
addTearDown(underlyingFocusNode.dispose);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Focus(
focusNode: underlyingFocusNode,
autofocus: true,
onKeyEvent: (_, event) {
if (event.logicalKey.isBackKey) {
underlyingBackEvents++;
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
},
child: Builder(
builder: (builderContext) {
context = builderContext;
return const SizedBox.shrink();
},
),
),
),
),
);
showTvVirtualKeyboard(context: context, controller: controller);
await tester.pumpAndSettle();
expect(find.byType(Dialog), findsOneWidget);
await tester.sendKeyDownEvent(LogicalKeyboardKey.escape);
await tester.pump();
expect(find.byType(Dialog), findsOneWidget);
expect(underlyingBackEvents, 0);
await tester.sendKeyUpEvent(LogicalKeyboardKey.escape);
await tester.pumpAndSettle();
expect(find.byType(Dialog), findsNothing);
expect(underlyingBackEvents, 0);
});
testWidgets('rendered backspace deletes whole graphemes and keeps the keyboard open', (tester) async {
final controller = TextEditingController();
addTearDown(controller.dispose);
for (final grapheme in ['😀', 'e\u0301', '🇯🇵', '👨‍👩‍👧‍👦']) {
controller.value = TextEditingValue(
text: 'A${grapheme}B',
selection: TextSelection.collapsed(offset: 1 + grapheme.length),
);
await _pumpKeyboard(tester, controller: controller);
await tester.tap(find.byIcon(Symbols.backspace_rounded));
await tester.pump();
expect(controller.text, 'AB');
expect(controller.selection, const TextSelection.collapsed(offset: 1));
expect(find.byType(Dialog), findsOneWidget);
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
await tester.pumpAndSettle();
}
});
testWidgets('physical delete removes a whole grapheme and dismisses the keyboard', (tester) async {
final controller = TextEditingController();
addTearDown(controller.dispose);
for (final grapheme in ['😀', 'e\u0301', '🇯🇵', '👨‍👩‍👧‍👦']) {
controller.value = TextEditingValue(text: 'A${grapheme}B', selection: const TextSelection.collapsed(offset: 1));
await _pumpKeyboard(tester, controller: controller);
await tester.sendKeyEvent(LogicalKeyboardKey.delete);
await tester.pumpAndSettle();
expect(controller.text, 'AB');
expect(controller.selection, const TextSelection.collapsed(offset: 1));
expect(find.byType(Dialog), findsNothing);
}
});
testWidgets('grapheme selection deletion preserves formatter and callback ordering', (tester) async {
const grapheme = '🇯🇵';
final controller = TextEditingController()
..value = TextEditingValue(
text: 'A${grapheme}B',
selection: TextSelection(baseOffset: grapheme.length, extentOffset: 1),
);
final formatterCandidates = <TextEditingValue>[];
final changes = <String>[];
addTearDown(controller.dispose);
await _pumpKeyboard(
tester,
controller: controller,
maxLength: 8,
inputFormatters: [
TextInputFormatter.withFunction((_, nextValue) {
formatterCandidates.add(nextValue);
return nextValue;
}),
],
onChanged: changes.add,
);
await tester.tap(find.byIcon(Symbols.backspace_rounded));
await tester.pump();
expect(formatterCandidates.single.text, 'AB');
expect(controller.text, 'AB');
expect(controller.selection, const TextSelection.collapsed(offset: 1));
expect(changes, ['AB']);
});
}
Future<void> _pumpKeyboard(
WidgetTester tester, {
required TextEditingController controller,
TextInputType? keyboardType,
int? maxLines,
List<TextInputFormatter>? inputFormatters,
int? maxLength,
ValueChanged<String>? onChanged,
ValueChanged<String>? onSubmitted,
}) async {
TvDetectionService.debugSetAppleTVOverride(true);
await tester.binding.setSurfaceSize(const Size(1280, 720));
addTearDown(() => tester.binding.setSurfaceSize(null));
late BuildContext context;
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (builderContext) {
context = builderContext;
return const SizedBox.shrink();
},
),
),
);
showTvVirtualKeyboard(
context: context,
controller: controller,
keyboardType: keyboardType,
maxLines: maxLines,
inputFormatters: inputFormatters,
maxLength: maxLength,
onChanged: onChanged,
onSubmitted: onSubmitted,
);
await tester.pumpAndSettle();
}
KeyEventResult _dispatchKey(KeyEvent event) {
FocusNode? node = FocusManager.instance.primaryFocus;
while (node != null) {
final result = node.onKeyEvent?.call(node, event) ?? KeyEventResult.ignored;
if (result == KeyEventResult.handled) return result;
node = node.parent;
}
return KeyEventResult.ignored;
}