Files
Client/test/widget/route_transition_gate_test.dart
T

217 lines
7.0 KiB
Dart

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<void> pumpApp(WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(home: Builder(builder: (_) => const Scaffold())),
);
}
NavigatorState navigator(WidgetTester tester) =>
tester.state<NavigatorState>(find.byType(Navigator));
testWidgets('shows placeholder during push, child once settled', (
tester,
) async {
await pumpApp(tester);
unawaited(
navigator(
tester,
).push(MaterialPageRoute<void>(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<void>(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<void>(builder: (_) => gatedPage())),
);
await tester.pumpAndSettle();
expect(find.byKey(gated), findsOneWidget);
unawaited(
navigator(
tester,
).push(MaterialPageRoute<void>(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<void>(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);
},
);
}