fix: scope navigation callbacks to active owners

This commit is contained in:
edde746
2026-07-12 18:59:27 +02:00
parent cd88f73f05
commit 0ef1e773bb
7 changed files with 134 additions and 64 deletions
+39 -30
View File
@@ -8,7 +8,7 @@ import 'package:plezy/services/gamepad_service.dart';
/// Tests stage [tabCount] focus nodes and read the resulting controller state
/// after [initTabNavigation] runs.
class _Probe extends StatefulWidget {
const _Probe({required this.tabCount, required this.onState, this.initialIndex = 0});
const _Probe({super.key, required this.tabCount, required this.onState, this.initialIndex = 0});
final int tabCount;
final int initialIndex;
@@ -60,16 +60,9 @@ void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('TabNavigationMixin', () {
setUp(() {
// Static gamepad callbacks are global state; isolate each test.
GamepadService.onL1Pressed = null;
GamepadService.onR1Pressed = null;
});
setUp(GamepadService.debugClearTabNavigationHandlers);
tearDown(() {
GamepadService.onL1Pressed = null;
GamepadService.onR1Pressed = null;
});
tearDown(GamepadService.debugClearTabNavigationHandlers);
testWidgets('initTabNavigation creates a TabController with the right length', (tester) async {
late _ProbeState state;
@@ -83,30 +76,45 @@ void main() {
expect(state.suppressAutoFocus, isFalse);
});
testWidgets('initTabNavigation registers L1/R1 gamepad callbacks', (tester) async {
testWidgets('initTabNavigation registers owner-scoped bumper navigation', (tester) async {
late _ProbeState state;
await tester.pumpWidget(_Probe(tabCount: 3, onState: (s) => state = s));
// Mixin wires its private goToPreviousTab/goToNextTab to the static
// callbacks; we can only assert non-null wiring (the closures are the
// mixin's bound methods, not directly comparable).
expect(GamepadService.onL1Pressed, isNotNull);
expect(GamepadService.onR1Pressed, isNotNull);
// Sanity: invoking R1 advances the tab via the mixin's goToNextTab.
GamepadService.onR1Pressed!.call();
GamepadService.debugDispatchTabNavigation(previous: false);
await tester.pump();
expect(state.tabController.index, 1);
});
testWidgets('disposeTabNavigation clears the static gamepad callbacks', (tester) async {
await tester.pumpWidget(_Probe(tabCount: 2, onState: (_) {}));
expect(GamepadService.onL1Pressed, isNotNull);
expect(GamepadService.onR1Pressed, isNotNull);
testWidgets('disposing one tab screen preserves another owner registration', (tester) async {
late _ProbeState first;
late _ProbeState second;
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: Column(
children: [
_Probe(key: const ValueKey('first'), tabCount: 2, onState: (s) => first = s),
_Probe(key: const ValueKey('second'), tabCount: 2, onState: (s) => second = s),
],
),
),
);
// Replace the widget tree to fire dispose.
await tester.pumpWidget(const SizedBox.shrink());
expect(GamepadService.onL1Pressed, isNull);
expect(GamepadService.onR1Pressed, isNull);
GamepadService.debugDispatchTabNavigation(previous: false);
await tester.pump();
expect(first.tabController.index, 1);
expect(second.tabController.index, 0);
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: _Probe(key: const ValueKey('second'), tabCount: 2, onState: (s) => second = s),
),
);
GamepadService.debugDispatchTabNavigation(previous: false);
await tester.pump();
expect(second.tabController.index, 1);
});
testWidgets('tabChipFocusNodes drives tabCount; getTabChipFocusNode returns the right node', (tester) async {
@@ -183,10 +191,11 @@ void main() {
expect(state.tabController.length, 3);
expect(state.tabController.index, 0);
// The original is disposed; touching it would throw — but the
// mixin's references all point at the new instance now.
expect(GamepadService.onL1Pressed, isNotNull);
expect(GamepadService.onR1Pressed, isNotNull);
// The original is disposed, while the owner-scoped registry points at
// the newly initialized controller.
GamepadService.debugDispatchTabNavigation(previous: false);
await tester.pump();
expect(state.tabController.index, 1);
});
testWidgets('onTabChanged fires when tabController.index changes', (tester) async {