Files
plezy/test/widgets/overlay_sheet_test.dart
T
edde746 8e1904ddee fix(sheets): let system back dismiss a hosted sheet on touch platforms
Back left the Manage Libraries sheet open on Android with no way to
dismiss it. The host answered the platform pop with
`BackKeyCoordinator.consumeIfHandled()`, which dedups the focused key
path against the platform pop. Only TV routes one Back through both;
touch platforms never deliver Back to the sheet's key handler, verified
on device — a physical Back produced only `popRoute` and no key event.
So there was nothing to dedup against, and the global one-shot marker,
once set by any other handler, silently swallowed the only signal that
closes the sheet.

Scopes the dedup to TV. The TV regression that guarded this never set
the TV override, so it asserted the swallow on every platform and hid
the defect; it now enables the override and a touch counterpart pins the
dismissal.

Also blocks semantics behind the barrier. The barrier takes every
pointer event but left the screen underneath in the semantics tree, so
assistive tech and UI automation still saw rows that could not be
activated — Maestro read an occluded settings row as visible and tapped
its stale coordinates into the sheet. Flutter's own ModalBarrier blocks
semantics for the same reason.

Verified by replaying the failing Maestro sequence
(.maestro/subflows/settings_deep_checks.yaml lines 42-60) on a device:
back dismisses the sheet, Services opens, and back returns to settings.
2026-07-27 01:11:23 +02:00

431 lines
16 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/focus/key_event_utils.dart';
import 'package:plezy/utils/platform_detector.dart';
import 'package:plezy/widgets/overlay_sheet.dart';
void main() {
testWidgets('scrollable sheet does not attach to parent primary controller', (tester) async {
final parentController = ScrollController();
addTearDown(parentController.dispose);
await tester.pumpWidget(
MaterialApp(
theme: ThemeData(platform: TargetPlatform.android),
home: PrimaryScrollController(
controller: parentController,
child: OverlaySheetHost(
child: Scaffold(
body: CustomScrollView(
primary: true,
slivers: [
SliverFillRemaining(
child: Center(
child: Builder(
builder: (context) => ElevatedButton(
onPressed: () {
OverlaySheetController.of(context).show<void>(
builder: (_) => ListView.builder(
itemCount: 30,
itemBuilder: (_, index) => ListTile(title: Text('Item $index')),
),
);
},
child: const Text('Open'),
),
),
),
),
],
),
),
),
),
),
);
expect(parentController.positions.length, 1);
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(parentController.positions.length, 1);
expect(find.text('Item 0'), findsOneWidget);
});
testWidgets('desktop default constraints scale with window height', (tester) async {
tester.view.physicalSize = const Size(1280, 800);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.reset);
await tester.pumpWidget(
MaterialApp(
theme: ThemeData(platform: TargetPlatform.android),
home: OverlaySheetHost(
child: Scaffold(
body: Center(
child: Builder(
builder: (context) => ElevatedButton(
onPressed: () {
OverlaySheetController.of(context).show<void>(
builder: (_) => ListView.builder(
itemCount: 100,
itemBuilder: (_, index) => ListTile(title: Text('Item $index')),
),
);
},
child: const Text('Open'),
),
),
),
),
),
),
);
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
// Unbounded list content fills the default constraints: 75% of window
// height (previously a fixed 400 on desktop) capped at 700 wide.
final sheetSize = tester.getSize(find.byType(ListView));
expect(sheetSize.height, 800 * 0.75);
expect(sheetSize.width, 700);
});
testWidgets('pointer-opened sheet claims focus and handles Back before the screen', (tester) async {
final screenFocusNode = FocusNode(debugLabel: 'Screen');
addTearDown(screenFocusNode.dispose);
var screenBacks = 0;
await tester.pumpWidget(
MaterialApp(
home: Focus(
focusNode: screenFocusNode,
autofocus: true,
onKeyEvent: (_, event) {
if (event.logicalKey == LogicalKeyboardKey.gameButtonB) {
if (event is KeyUpEvent) screenBacks++;
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
},
child: OverlaySheetHost(
child: Scaffold(
body: Center(
child: Builder(
builder: (context) => ElevatedButton(
onPressed: () => OverlaySheetController.of(context).show<void>(
builder: (_) => const SizedBox(height: 120, child: Center(child: Text('SHEET'))),
),
child: const Text('Open'),
),
),
),
),
),
),
),
);
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
expect(FocusManager.instance.primaryFocus?.debugLabel, 'OverlaySheetScope');
await tester.sendKeyEvent(LogicalKeyboardKey.gameButtonB);
await tester.pumpAndSettle();
expect(find.text('SHEET'), findsNothing);
expect(screenBacks, 0);
});
testWidgets('pushAdaptive opens a root page on an idle host and returns its result', (tester) async {
final result = ValueNotifier<String>('pending');
addTearDown(result.dispose);
await tester.pumpWidget(
MaterialApp(
home: OverlaySheetHost(
child: Scaffold(
body: Builder(
builder: (context) => Column(
children: [
ElevatedButton(
onPressed: () async {
final value = await OverlaySheetController.pushAdaptive<String>(
context,
builder: (sheetContext) => SizedBox(
height: 120,
child: Column(
children: [
const Text('Adaptive root page'),
ElevatedButton(
onPressed: () => OverlaySheetController.of(sheetContext).close('root result'),
child: const Text('Close adaptive root'),
),
],
),
),
);
result.value = value ?? 'null';
},
child: const Text('Push adaptive root'),
),
ValueListenableBuilder<String>(
valueListenable: result,
builder: (_, value, _) => Text('Root result: $value'),
),
],
),
),
),
),
),
);
await tester.tap(find.text('Push adaptive root'));
await tester.pumpAndSettle();
expect(find.text('Adaptive root page'), findsOneWidget);
expect(find.text('Root result: pending'), findsOneWidget);
await tester.tap(find.text('Close adaptive root'));
await tester.pumpAndSettle();
expect(find.text('Adaptive root page'), findsNothing);
expect(find.text('Root result: root result'), findsOneWidget);
});
testWidgets('pushAdaptive pushes a nested page on an open host and pop restores the root', (tester) async {
final nestedResult = ValueNotifier<String>('pending');
addTearDown(nestedResult.dispose);
await tester.pumpWidget(
MaterialApp(
home: OverlaySheetHost(
child: Scaffold(
body: Center(
child: Builder(
builder: (context) => ElevatedButton(
onPressed: () {
OverlaySheetController.of(context).show<void>(
builder: (rootContext) => SizedBox(
height: 160,
child: Column(
children: [
const Text('Existing root page'),
ElevatedButton(
onPressed: () async {
final value = await OverlaySheetController.pushAdaptive<String>(
rootContext,
builder: (nestedContext) => SizedBox(
height: 120,
child: Column(
children: [
const Text('Adaptive nested page'),
ElevatedButton(
onPressed: () =>
OverlaySheetController.of(nestedContext).pop('nested result'),
child: const Text('Pop adaptive nested'),
),
],
),
),
);
nestedResult.value = value ?? 'null';
},
child: const Text('Push adaptive nested'),
),
ValueListenableBuilder<String>(
valueListenable: nestedResult,
builder: (_, value, _) => Text('Nested result: $value'),
),
ElevatedButton(
onPressed: () => OverlaySheetController.of(rootContext).close(),
child: const Text('Close existing root'),
),
],
),
),
);
},
child: const Text('Open existing root'),
),
),
),
),
),
),
);
await tester.tap(find.text('Open existing root'));
await tester.pumpAndSettle();
expect(find.text('Existing root page'), findsOneWidget);
await tester.tap(find.text('Push adaptive nested'));
await tester.pumpAndSettle();
expect(find.text('Adaptive nested page'), findsOneWidget);
expect(find.text('Existing root page'), findsNothing);
await tester.tap(find.text('Pop adaptive nested'));
await tester.pumpAndSettle();
expect(find.text('Adaptive nested page'), findsNothing);
expect(find.text('Existing root page'), findsOneWidget);
expect(find.text('Nested result: nested result'), findsOneWidget);
await tester.tap(find.text('Close existing root'));
await tester.pumpAndSettle();
expect(find.text('Existing root page'), findsNothing);
});
group('opt-in canPop / onSystemBack', () {
// Pushes an OverlaySheetHost route on top of a home route so we can observe
// whether a simulated system back pops the route. The host's child has an
// "Open" button that shows a sheet containing "SHEET".
Future<void> pushHost(WidgetTester tester, {required bool? canPop, VoidCallback? onSystemBack}) async {
await tester.pumpWidget(
MaterialApp(
theme: ThemeData(platform: TargetPlatform.android),
home: Scaffold(
body: Builder(
builder: (context) => Center(
child: ElevatedButton(
onPressed: () => Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => OverlaySheetHost(
canPop: canPop,
onSystemBack: onSystemBack,
child: Scaffold(
body: Builder(
builder: (sheetContext) => Center(
child: ElevatedButton(
onPressed: () => OverlaySheetController.of(sheetContext).show<void>(
builder: (_) => const SizedBox(height: 120, child: Center(child: Text('SHEET'))),
),
child: const Text('Open'),
),
),
),
),
),
),
),
child: const Text('Push'),
),
),
),
),
),
);
await tester.tap(find.text('Push'));
await tester.pumpAndSettle();
expect(find.text('Open'), findsOneWidget, reason: 'host route is shown');
}
testWidgets('canPop null installs no PopScope (system back pops the route)', (tester) async {
await pushHost(tester, canPop: null);
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
expect(find.text('Open'), findsNothing, reason: 'route popped back to home');
expect(find.text('Push'), findsOneWidget);
});
testWidgets('canPop true pops the route natively on system back', (tester) async {
await pushHost(tester, canPop: true);
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
expect(find.text('Open'), findsNothing, reason: 'route popped');
});
testWidgets('canPop false blocks the pop and runs onSystemBack', (tester) async {
var backs = 0;
await pushHost(tester, canPop: false, onSystemBack: () => backs++);
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
expect(find.text('Open'), findsOneWidget, reason: 'route not popped');
expect(backs, 1);
});
testWidgets('system back closes an open sheet instead of popping or running onSystemBack', (tester) async {
var backs = 0;
await pushHost(tester, canPop: false, onSystemBack: () => backs++);
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
expect(find.text('SHEET'), findsOneWidget);
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
expect(find.text('SHEET'), findsNothing, reason: 'sheet closed');
expect(find.text('Open'), findsOneWidget, reason: 'screen not popped');
expect(backs, 0, reason: 'onSystemBack not called while a sheet was open');
});
testWidgets('system back does not duplicate a handled TV key Back on an open sheet', (tester) async {
// The dedup is TV-only, so the override has to be on for this to be the
// TV contract rather than an accidental cross-platform assertion.
TvDetectionService.debugSetAppleTVOverride(true);
addTearDown(() => TvDetectionService.debugSetAppleTVOverride(null));
var backs = 0;
await pushHost(tester, canPop: false, onSystemBack: () => backs++);
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
BackKeyCoordinator.markHandled();
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
expect(find.text('SHEET'), findsOneWidget);
expect(backs, 0);
await tester.sendKeyEvent(LogicalKeyboardKey.gameButtonB);
await tester.pumpAndSettle();
expect(find.text('SHEET'), findsNothing);
});
testWidgets('touch system back closes the sheet despite an unrelated handled mark', (tester) async {
// Regression: the handled marker is global, and touch platforms never
// deliver Back to the sheet's key handler, so there is nothing here to
// dedup against. Consuming a mark left by any other handler stranded the
// sheet open with no way to dismiss it.
var backs = 0;
await pushHost(tester, canPop: false, onSystemBack: () => backs++);
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
BackKeyCoordinator.markHandled();
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
expect(find.text('SHEET'), findsNothing, reason: 'stale mark must not swallow the close');
expect(find.text('Open'), findsOneWidget, reason: 'screen not popped');
expect(backs, 0);
});
testWidgets('system back in a later frame is not mistaken for a duplicate TV key', (tester) async {
// TV-only: the dedup this exercises is skipped on touch platforms, so
// without the override the pop would never consult the marker and the
// post-frame expiry would go untested.
TvDetectionService.debugSetAppleTVOverride(true);
addTearDown(() => TvDetectionService.debugSetAppleTVOverride(null));
var backs = 0;
await pushHost(tester, canPop: false, onSystemBack: () => backs++);
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
BackKeyCoordinator.markHandled();
await tester.pump();
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
expect(find.text('SHEET'), findsNothing);
expect(find.text('Open'), findsOneWidget);
expect(backs, 0);
});
});
}