import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:marianum_mobile/widget/route_transition_gate.dart'; void main() { const gated = Key('gated-child'); const placeholder = Key('gate-placeholder'); Widget gatedPage() => Scaffold( body: RouteTransitionGate( placeholder: const SizedBox(key: placeholder), // Hit-testable like the real viewer, so pointer tracking sees it. builder: (_) => const ColoredBox( key: gated, color: Color(0x00000000), child: SizedBox.expand(), ), ), ); Future pumpApp(WidgetTester tester) async { await tester.pumpWidget( MaterialApp(home: Builder(builder: (_) => const Scaffold())), ); } NavigatorState navigator(WidgetTester tester) => tester.state(find.byType(Navigator)); testWidgets('shows placeholder during push, child once settled', ( tester, ) async { await pumpApp(tester); unawaited( navigator( tester, ).push(MaterialPageRoute(builder: (_) => gatedPage())), ); await tester.pump(); await tester.pump(const Duration(milliseconds: 50)); expect(find.byKey(placeholder), findsOneWidget); expect(find.byKey(gated), findsNothing); await tester.pumpAndSettle(); expect(find.byKey(gated), findsOneWidget); expect(find.byKey(placeholder), findsNothing); }); testWidgets('swaps back to placeholder while the route pops', (tester) async { await pumpApp(tester); unawaited( navigator( tester, ).push(MaterialPageRoute(builder: (_) => gatedPage())), ); await tester.pumpAndSettle(); expect(find.byKey(gated), findsOneWidget); navigator(tester).pop(); await tester.pump(); await tester.pump(const Duration(milliseconds: 50)); expect(find.byKey(gated), findsNothing); expect(find.byKey(placeholder), findsOneWidget); }); testWidgets('gates during a secondary transition and recovers after pop', ( tester, ) async { await pumpApp(tester); unawaited( navigator( tester, ).push(MaterialPageRoute(builder: (_) => gatedPage())), ); await tester.pumpAndSettle(); expect(find.byKey(gated), findsOneWidget); unawaited( navigator( tester, ).push(MaterialPageRoute(builder: (_) => const Scaffold())), ); await tester.pump(); await tester.pump(const Duration(milliseconds: 50)); // Mid secondary transition the fragile subtree must be unmounted. expect(find.byKey(gated), findsNothing); await tester.pumpAndSettle(); navigator(tester).pop(); await tester.pumpAndSettle(); // Back at rest on the gated route: child is mounted again. expect(find.byKey(gated), findsOneWidget); expect(find.byKey(placeholder), findsNothing); }); testWidgets('re-gates when the settled subtree moves under a fresh Transform', ( tester, ) async { // Android's predictive back moves the route content (GlobalKey) under a // brand-new Transform.scale without any animation status change. The // padding changes the constraints so the moved subtree is laid out nested // under the transform's first layout, which is when `localToGlobal` throws. final subtree = GlobalKey(); var wrapped = false; late StateSetter rebuild; await tester.pumpWidget( MaterialApp( home: StatefulBuilder( builder: (context, setState) { rebuild = setState; final child = KeyedSubtree( key: subtree, child: Scaffold( body: RouteTransitionGate( placeholder: const SizedBox(key: placeholder), builder: (_) => const _LocalToGlobalProbe(key: gated), ), ), ); if (!wrapped) return child; return Padding( padding: const EdgeInsets.all(1), child: Transform.scale(scale: 1, child: child), ); }, ), ), ); await tester.pump(); await tester.pump(); expect(find.byKey(gated), findsOneWidget); rebuild(() => wrapped = true); await tester.pump(); expect(tester.takeException(), isNull); expect(find.byKey(placeholder), findsOneWidget); await tester.pump(); expect(find.byKey(gated), findsOneWidget); expect(tester.takeException(), isNull); }); testWidgets('keeps the child mounted while a back swipe drives the pop', ( tester, ) async { // iOS back swipe: the edge detector is translucent, so the finger that // drives the pop animation is also down on the viewer. Unmounting it now // would route the next pointer move into a disposed State. (A programmatic // pop is harmless — the Navigator cancels active pointers first.) await pumpApp(tester); unawaited( navigator( tester, ).push(MaterialPageRoute(builder: (_) => gatedPage())), ); await tester.pumpAndSettle(); expect(find.byKey(gated), findsOneWidget); final gesture = await tester.startGesture(const Offset(5, 300)); await gesture.moveBy(const Offset(60, 0)); await tester.pump(); await gesture.moveBy(const Offset(60, 0)); await tester.pump(); // Pop animation is running (status reverse), finger still down. expect(find.byKey(gated), findsOneWidget); expect(find.byKey(placeholder), findsNothing); expect(tester.takeException(), isNull); await gesture.up(); await tester.pump(); expect(find.byKey(gated), findsNothing); await tester.pumpAndSettle(); expect(tester.takeException(), isNull); }, variant: TargetPlatformVariant.only(TargetPlatform.iOS)); testWidgets('holds the child back for one frame without a route animation', ( tester, ) async { // The tab case: no route animation ever runs, but the ancestors (and any // freshly inserted tab-transition Transform) do their first layout in this // very frame, so the fragile subtree must not be part of it. await tester.pumpWidget(MaterialApp(home: gatedPage())); expect(find.byKey(placeholder), findsOneWidget); expect(find.byKey(gated), findsNothing); await tester.pump(); expect(find.byKey(gated), findsOneWidget); expect(find.byKey(placeholder), findsNothing); }); } /// Mimics SfPdfViewer: a stateful widget whose LayoutBuilder resolves a /// child's global position during layout. class _LocalToGlobalProbe extends StatefulWidget { const _LocalToGlobalProbe({super.key}); @override State<_LocalToGlobalProbe> createState() => _LocalToGlobalProbeState(); } class _LocalToGlobalProbeState extends State<_LocalToGlobalProbe> { final _viewport = GlobalKey(); @override Widget build(BuildContext context) => LayoutBuilder( builder: (context, constraints) { final box = _viewport.currentContext?.findRenderObject() as RenderBox?; if (box != null && box.hasSize) box.localToGlobal(Offset.zero); return SizedBox.expand(key: _viewport); }, ); }