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 = []; final changes = []; 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 _pumpKeyboard( WidgetTester tester, { required TextEditingController controller, TextInputType? keyboardType, int? maxLines, List? inputFormatters, int? maxLength, ValueChanged? onChanged, ValueChanged? 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; }