Files
plezy/test/focus/focusable_text_field_test.dart
T
edde746 09e0b06e49 fix(tv): unbreak collapsed-rail D-pad traversal, action-bar focus survival, and text-field back handling
With the rail collapsed and Libraries expanded, DOWN from the header landed on a mounted but focus-excluded library node and was swallowed, trapping traversal; the focus order now matches the render exclusion and traversal scans past non-focusable candidates.

An action bar rebuilding on count change disposed every node, silently dropping focus and never notifying the TV host; bindings are now reused by stable identity (external focus node, else debugLabel) and the transition is reported. Positional reuse remains the only option for an unlabeled action, but it applies only while the action-list shape is provably unchanged - catalog detail enrichment inserting Request before a focused Trailer used to hand Trailer's focused binding, focus ring and next Select included, to Request. Those actions also carry stable debugLabels so their focus survives enrichment.

The text field consumed back on KeyDown outside the shared handler, letting one press act twice. It now marks the back coordinator like handleBackKeyAction does and keeps the down-only shape solely on TV, where the closing IME session swallows the matching KeyUp. Because onBack can move focus (empty search field -> sidebar), the orphaned KeyUp would otherwise drive app-level back on the new focus chain, so the field arms BackKeyUpSuppressor; the suppressor gained a hardware-level KeyUp observer that clears the armed state in a microtask after the press ends, removing the long-documented pinning hazard where an unconsumed KeyUp left it swallowing the next press.
2026-08-21 19:23:41 +02:00

142 lines
4.7 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/focus/focusable_text_field.dart';
import 'package:plezy/focus/input_mode_tracker.dart';
import 'package:plezy/focus/key_event_utils.dart';
void main() {
testWidgets('unwired single-line fields traverse with arrow keys', (tester) async {
final first = FocusNode(debugLabel: 'first');
final second = FocusNode(debugLabel: 'second');
addTearDown(first.dispose);
addTearDown(second.dispose);
final c1 = TextEditingController();
final c2 = TextEditingController();
addTearDown(c1.dispose);
addTearDown(c2.dispose);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Column(
children: [
FocusableTextField(
controller: c1,
focusNode: first,
tvTextInputPresentation: TvTextInputPresentation.platform,
),
FocusableTextField(
controller: c2,
focusNode: second,
tvTextInputPresentation: TvTextInputPresentation.platform,
),
],
),
),
),
);
await tester.pump();
first.requestFocus();
await tester.pump();
expect(FocusManager.instance.primaryFocus?.debugLabel, 'first');
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await tester.pump();
expect(FocusManager.instance.primaryFocus?.debugLabel, 'second');
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
await tester.pump();
expect(FocusManager.instance.primaryFocus?.debugLabel, 'first');
});
testWidgets('unwired multiline field keeps arrow keys for the caret', (tester) async {
final first = FocusNode(debugLabel: 'multiline');
final second = FocusNode(debugLabel: 'below');
addTearDown(first.dispose);
addTearDown(second.dispose);
final c1 = TextEditingController(text: 'line1\nline2');
final c2 = TextEditingController();
addTearDown(c1.dispose);
addTearDown(c2.dispose);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Column(
children: [
FocusableTextField(
controller: c1,
focusNode: first,
maxLines: 4,
tvTextInputPresentation: TvTextInputPresentation.platform,
),
FocusableTextField(
controller: c2,
focusNode: second,
tvTextInputPresentation: TvTextInputPresentation.platform,
),
],
),
),
),
);
await tester.pump();
first.requestFocus();
c1.selection = const TextSelection.collapsed(offset: 0);
await tester.pump();
expect(FocusManager.instance.primaryFocus?.debugLabel, 'multiline');
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await tester.pump();
expect(FocusManager.instance.primaryFocus?.debugLabel, 'multiline');
});
testWidgets('back on a field with onBack fires once on key up and marks the frame handled', (tester) async {
// The field must route back through the shared handler like every other
// focusable: consume KeyDown, run onBack on KeyUp, and mark the frame so
// a parallel back dispatch (the layer focus lands on next) dedupes.
addTearDown(BackKeyCoordinator.clear);
final node = FocusNode(debugLabel: 'field');
addTearDown(node.dispose);
final controller = TextEditingController();
addTearDown(controller.dispose);
var backs = 0;
await tester.pumpWidget(
InputModeTracker(
child: MaterialApp(
home: Scaffold(
body: FocusableTextField(
controller: controller,
focusNode: node,
onBack: () => backs++,
tvTextInputPresentation: TvTextInputPresentation.platform,
),
),
),
),
);
await tester.pump();
node.requestFocus();
await tester.pump();
// Enter keyboard mode; the sole field has no neighbour so focus stays put.
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await tester.pump();
await tester.sendKeyDownEvent(LogicalKeyboardKey.escape);
await tester.pump();
expect(backs, 0, reason: 'KeyDown is consumed without firing so a pop cannot double-run on KeyUp');
await tester.sendKeyUpEvent(LogicalKeyboardKey.escape);
expect(backs, 1);
// Same-frame dedupe mark: assert before pumping, which runs the
// coordinator's post-frame clear.
expect(BackKeyCoordinator.consumeIfHandled(), isTrue);
await tester.pump();
});
}