fix: eliminate cross-app consistency drift
This commit is contained in:
@@ -1,8 +1,18 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/focus/dpad_navigator.dart';
|
||||
import 'package:plezy/focus/key_event_utils.dart';
|
||||
import 'package:plezy/media/library_first_character.dart';
|
||||
import 'package:plezy/screens/libraries/alpha_jump_bar.dart';
|
||||
import 'package:plezy/utils/platform_detector.dart';
|
||||
|
||||
const _backKeys = [
|
||||
_BackKeyCase('Escape', LogicalKeyboardKey.escape, PhysicalKeyboardKey.escape),
|
||||
_BackKeyCase('GoBack', LogicalKeyboardKey.goBack, PhysicalKeyboardKey.escape),
|
||||
_BackKeyCase('BrowserBack', LogicalKeyboardKey.browserBack, PhysicalKeyboardKey.browserBack),
|
||||
_BackKeyCase('GameButtonB', LogicalKeyboardKey.gameButtonB, PhysicalKeyboardKey.enter),
|
||||
];
|
||||
|
||||
void main() {
|
||||
const alphabetCharacters = [
|
||||
@@ -35,6 +45,91 @@ void main() {
|
||||
LibraryFirstCharacter(key: 'Z', title: 'Z', size: 1),
|
||||
];
|
||||
|
||||
tearDown(() {
|
||||
TvDetectionService.debugSetAppleTVOverride(null);
|
||||
BackKeyUpSuppressor.clearSuppression();
|
||||
BackKeyCoordinator.clear();
|
||||
});
|
||||
|
||||
for (final backKey in _backKeys) {
|
||||
testWidgets('${backKey.label} runs back once on key up and consumes the normal press', (tester) async {
|
||||
TvDetectionService.debugSetAppleTVOverride(false);
|
||||
final harness = await _pumpBackHarness(tester);
|
||||
|
||||
final downHandled = _sendKeyDown(tester, backKey);
|
||||
expect(downHandled, isTrue);
|
||||
expect(harness.jumpBarBacks, 0);
|
||||
expect(harness.ancestorEvents, 0);
|
||||
|
||||
final repeatHandled = _sendKeyRepeat(tester, backKey);
|
||||
expect(repeatHandled, isTrue);
|
||||
expect(harness.jumpBarBacks, 0);
|
||||
expect(harness.ancestorEvents, 0);
|
||||
|
||||
final upHandled = _sendKeyUp(tester, backKey);
|
||||
expect(upHandled, isTrue);
|
||||
expect(harness.jumpBarBacks, 1);
|
||||
expect(harness.ancestorEvents, 0);
|
||||
expect(harness.ancestorBacks, 0);
|
||||
|
||||
await tester.pump();
|
||||
harness.moveFocusOnBack = true;
|
||||
|
||||
final movingDownHandled = _sendKeyDown(tester, backKey);
|
||||
final movingRepeatHandled = _sendKeyRepeat(tester, backKey);
|
||||
final movingUpHandled = _sendKeyUp(tester, backKey);
|
||||
|
||||
expect(movingDownHandled, isTrue);
|
||||
expect(movingRepeatHandled, isTrue);
|
||||
expect(movingUpHandled, isTrue);
|
||||
expect(harness.jumpBarBacks, 2);
|
||||
expect(harness.ancestorEvents, 0);
|
||||
expect(harness.ancestorBacks, 0);
|
||||
await tester.pump();
|
||||
expect(harness.nextFocusNode.hasFocus, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('${backKey.label} runs back once on Apple TV key down and consumes key up', (tester) async {
|
||||
TvDetectionService.debugSetAppleTVOverride(true);
|
||||
final harness = await _pumpBackHarness(tester);
|
||||
|
||||
final downHandled = _sendKeyDown(tester, backKey);
|
||||
expect(downHandled, isTrue);
|
||||
expect(harness.jumpBarBacks, 1);
|
||||
expect(harness.ancestorEvents, 0);
|
||||
|
||||
final repeatHandled = _sendKeyRepeat(tester, backKey);
|
||||
expect(repeatHandled, isTrue);
|
||||
expect(harness.jumpBarBacks, 1);
|
||||
expect(harness.ancestorEvents, 0);
|
||||
|
||||
final upHandled = _sendKeyUp(tester, backKey);
|
||||
expect(upHandled, isTrue);
|
||||
expect(harness.jumpBarBacks, 1);
|
||||
expect(harness.ancestorEvents, 0);
|
||||
expect(harness.ancestorBacks, 0);
|
||||
|
||||
await tester.pump();
|
||||
harness.moveFocusOnBack = true;
|
||||
|
||||
final movingDownHandled = _sendKeyDown(tester, backKey);
|
||||
expect(movingDownHandled, isTrue);
|
||||
expect(harness.jumpBarBacks, 2);
|
||||
expect(harness.ancestorEvents, 0);
|
||||
await tester.pump();
|
||||
expect(harness.nextFocusNode.hasFocus, isTrue);
|
||||
|
||||
final movingRepeatHandled = _sendKeyRepeat(tester, backKey);
|
||||
final movingUpHandled = _sendKeyUp(tester, backKey);
|
||||
|
||||
expect(movingRepeatHandled, isTrue);
|
||||
expect(movingUpHandled, isTrue);
|
||||
expect(harness.jumpBarBacks, 2);
|
||||
expect(harness.ancestorEvents, 2);
|
||||
expect(harness.ancestorBacks, 0);
|
||||
});
|
||||
}
|
||||
|
||||
testWidgets('keeps the full alphabet visible in a short TV-height bar', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
@@ -121,3 +216,114 @@ void main() {
|
||||
expect(jumpedTo, 2);
|
||||
});
|
||||
}
|
||||
|
||||
class _BackKeyCase {
|
||||
final String label;
|
||||
final LogicalKeyboardKey logicalKey;
|
||||
final PhysicalKeyboardKey physicalKey;
|
||||
|
||||
const _BackKeyCase(this.label, this.logicalKey, this.physicalKey);
|
||||
}
|
||||
|
||||
class _BackHarness {
|
||||
final jumpBarFocusNode = FocusNode(debugLabel: 'test_alpha_jump_bar_back');
|
||||
final nextFocusNode = FocusNode(debugLabel: 'test_alpha_jump_bar_back_destination');
|
||||
final ancestorFocusNode = FocusNode(debugLabel: 'test_alpha_jump_bar_back_ancestor');
|
||||
|
||||
var jumpBarBacks = 0;
|
||||
var ancestorEvents = 0;
|
||||
var ancestorBacks = 0;
|
||||
var moveFocusOnBack = false;
|
||||
|
||||
void dispose() {
|
||||
jumpBarFocusNode.dispose();
|
||||
nextFocusNode.dispose();
|
||||
ancestorFocusNode.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
Future<_BackHarness> _pumpBackHarness(WidgetTester tester) async {
|
||||
final harness = _BackHarness();
|
||||
addTearDown(harness.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Focus(
|
||||
focusNode: harness.ancestorFocusNode,
|
||||
onKeyEvent: (_, event) {
|
||||
harness.ancestorEvents++;
|
||||
return handleBackKeyAction(event, () => harness.ancestorBacks++);
|
||||
},
|
||||
child: Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 300,
|
||||
child: AlphaJumpBar(
|
||||
firstCharacters: const [
|
||||
LibraryFirstCharacter(key: 'A', title: 'A', size: 3),
|
||||
LibraryFirstCharacter(key: 'B', title: 'B', size: 4),
|
||||
LibraryFirstCharacter(key: 'C', title: 'C', size: 2),
|
||||
],
|
||||
currentLetter: 'B',
|
||||
focusNode: harness.jumpBarFocusNode,
|
||||
onJump: (_) {},
|
||||
onBack: () {
|
||||
harness.jumpBarBacks++;
|
||||
if (harness.moveFocusOnBack) {
|
||||
harness.nextFocusNode.requestFocus();
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
Focus(focusNode: harness.nextFocusNode, child: const SizedBox(width: 1, height: 1)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
harness.jumpBarFocusNode.requestFocus();
|
||||
await tester.pump();
|
||||
expect(harness.jumpBarFocusNode.hasFocus, isTrue);
|
||||
return harness;
|
||||
}
|
||||
|
||||
bool _sendKeyDown(WidgetTester _, _BackKeyCase backKey) {
|
||||
return _dispatchKeyEvent(
|
||||
KeyDownEvent(physicalKey: backKey.physicalKey, logicalKey: backKey.logicalKey, timeStamp: Duration.zero),
|
||||
);
|
||||
}
|
||||
|
||||
bool _sendKeyRepeat(WidgetTester _, _BackKeyCase backKey) {
|
||||
return _dispatchKeyEvent(
|
||||
KeyRepeatEvent(
|
||||
physicalKey: backKey.physicalKey,
|
||||
logicalKey: backKey.logicalKey,
|
||||
timeStamp: const Duration(milliseconds: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
bool _sendKeyUp(WidgetTester _, _BackKeyCase backKey) {
|
||||
return _dispatchKeyEvent(
|
||||
KeyUpEvent(
|
||||
physicalKey: backKey.physicalKey,
|
||||
logicalKey: backKey.logicalKey,
|
||||
timeStamp: const Duration(milliseconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
bool _dispatchKeyEvent(KeyEvent event) {
|
||||
final handled = HardwareKeyboard.instance.handleKeyEvent(event);
|
||||
FocusNode? node = FocusManager.instance.primaryFocus;
|
||||
while (node != null) {
|
||||
final result = node.onKeyEvent?.call(node, event) ?? KeyEventResult.ignored;
|
||||
if (result == KeyEventResult.handled) return true;
|
||||
if (result == KeyEventResult.skipRemainingHandlers) return handled;
|
||||
node = node.parent;
|
||||
}
|
||||
return handled;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/focus/focusable_button.dart';
|
||||
import 'package:plezy/focus/input_mode_tracker.dart';
|
||||
import 'package:plezy/i18n/strings.g.dart';
|
||||
import 'package:plezy/screens/libraries/state_messages.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUp(() => LocaleSettings.setLocaleSync(AppLocale.en));
|
||||
tearDown(() => LocaleSettings.setLocaleSync(AppLocale.en));
|
||||
|
||||
testWidgets('StateMessageWidget forwards action focus behavior to FocusableButton', (tester) async {
|
||||
await _pump(
|
||||
tester,
|
||||
StateMessageWidget(
|
||||
message: 'Nothing here',
|
||||
actionLabel: 'Reload',
|
||||
onAction: () {},
|
||||
actionAutofocus: true,
|
||||
actionUseBackgroundFocus: true,
|
||||
),
|
||||
);
|
||||
|
||||
final button = tester.widget<FocusableButton>(find.byType(FocusableButton));
|
||||
expect(button.autofocus, isTrue);
|
||||
expect(button.useBackgroundFocus, isTrue);
|
||||
|
||||
final focusedNode = FocusManager.instance.primaryFocus;
|
||||
expect(focusedNode, isNotNull);
|
||||
final focusedWidget = find.byWidgetPredicate((widget) => widget is Focus && widget.focusNode == focusedNode);
|
||||
expect(focusedWidget, findsOneWidget);
|
||||
expect(find.ancestor(of: focusedWidget, matching: find.byType(FocusableButton)), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('ErrorStateWidget retry supports D-pad and pointer activation', (tester) async {
|
||||
var retries = 0;
|
||||
await _pump(
|
||||
tester,
|
||||
ErrorStateWidget(
|
||||
message: 'Could not load',
|
||||
retryLabel: 'Try again',
|
||||
onRetry: () => retries++,
|
||||
actionAutofocus: true,
|
||||
actionUseBackgroundFocus: true,
|
||||
),
|
||||
);
|
||||
|
||||
final button = tester.widget<FocusableButton>(find.byType(FocusableButton));
|
||||
expect(button.autofocus, isTrue);
|
||||
expect(button.useBackgroundFocus, isTrue);
|
||||
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Try again'));
|
||||
await tester.pump();
|
||||
expect(retries, 1);
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.select);
|
||||
await tester.pump();
|
||||
expect(retries, 2);
|
||||
});
|
||||
|
||||
group('ErrorStateWidget localized defaults', () {
|
||||
setUp(() async {
|
||||
await LocaleSettings.setLocale(AppLocale.es);
|
||||
});
|
||||
|
||||
testWidgets('uses the localized default retry label', (tester) async {
|
||||
await _pump(tester, ErrorStateWidget(message: 'No se pudo cargar', onRetry: () {}));
|
||||
|
||||
expect(find.text('Reintentar'), findsOneWidget);
|
||||
expect(find.text('Retry'), findsNothing);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _pump(WidgetTester tester, Widget child) async {
|
||||
await tester.pumpWidget(
|
||||
TranslationProvider(
|
||||
child: InputModeTracker(
|
||||
child: MaterialApp(home: Scaffold(body: child)),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
Reference in New Issue
Block a user