Files
doormile_milderapp/test/auth_design_test.dart
2026-08-11 13:16:33 +05:30

354 lines
13 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:miler/views/helpers/constants/app_theme.dart';
import 'package:miler/views/helpers/constants/design_constants.dart';
import 'package:miler/views/onboardscreens/auth_scaffold.dart';
/// The onboarding flow's shared frame. These pin the two things that were
/// actually broken in the five hand-rolled versions it replaced: code boxes
/// sized in fixed `.w` units inside a `spaceBetween` row, and a CTA inheriting
/// the app theme's full-width-form metrics.
void main() {
Future<void> pump(
WidgetTester tester,
Widget child, {
Size screen = const Size(390, 844),
}) async {
tester.view.physicalSize = Size(screen.width * 3, screen.height * 3);
tester.view.devicePixelRatio = 3.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
ScreenUtilInit(
designSize: const Size(390, 844),
builder: (_, _) => MaterialApp(
theme: AppTheme.light,
home: child,
),
),
);
await tester.pumpAndSettle();
}
Widget codeRow(int count, {bool error = false}) {
final controllers =
List.generate(count, (_) => TextEditingController());
final nodes = List.generate(count, (_) => FocusNode());
return Scaffold(
body: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: AuthCodeRow(
controllers: controllers,
nodes: nodes,
hasError: error,
onChanged: (_, _) {},
),
),
);
}
group('AuthCodeRow', () {
// The old OTP row hard-coded 46.w boxes and the MPIN rows hard-coded 70.w,
// inside `MainAxisAlignment.spaceBetween`. That only fits because the design
// width happens to divide that way; it has no headroom and cannot adapt.
for (final count in [4, 6]) {
for (final width in [320.0, 360.0, 390.0, 430.0]) {
testWidgets('$count boxes fit a ${width.toInt()}pt screen',
(tester) async {
await pump(tester, codeRow(count), screen: Size(width, 780));
expect(tester.takeException(), isNull);
expect(find.byType(TextField), findsNCount(count));
// Every box inside the 24pt gutters, none overlapping.
final boxes = tester
.widgetList<TextField>(find.byType(TextField))
.toList();
expect(boxes.length, count);
final rects = find.byType(TextField).evaluate().map((e) {
final ro = e.renderObject! as RenderBox;
final pos = ro.localToGlobal(Offset.zero);
return Rect.fromLTWH(pos.dx, pos.dy, ro.size.width, ro.size.height);
}).toList();
for (final r in rects) {
expect(r.left, greaterThanOrEqualTo(-0.01));
expect(r.right, lessThanOrEqualTo(width + 0.01));
expect(r.width, greaterThan(0));
}
});
}
}
testWidgets('an error turns the boxes red', (tester) async {
await pump(tester, codeRow(6, error: true));
expect(tester.takeException(), isNull);
});
});
group('AuthPrimaryButton', () {
testWidgets('the label is not squeezed by the theme form metrics',
(tester) async {
await pump(
tester,
const Scaffold(
body: Padding(
padding: EdgeInsets.all(24),
child: AuthPrimaryButton(label: 'Verify & Continue', onPressed: _noop),
),
),
);
expect(tester.takeException(), isNull);
final button = tester.getRect(find.byType(ElevatedButton));
final icon = tester.getRect(find.byType(Icon));
final text = tester.getRect(find.text('Verify & Continue'));
// The app theme's `vertical: 18` padding would leave a 20pt content band
// in a 56pt button and crush both of these; see AuthPrimaryButton's note.
expect(icon.height, greaterThan(14));
expect(text.height, greaterThan(12));
for (final part in [icon, text]) {
expect(part.top, greaterThanOrEqualTo(button.top - 0.01));
expect(part.bottom, lessThanOrEqualTo(button.bottom + 0.01));
}
});
testWidgets('a null callback disables it', (tester) async {
await pump(
tester,
const Scaffold(
body: Padding(
padding: EdgeInsets.all(24),
child: AuthPrimaryButton(label: 'Continue', onPressed: null),
),
),
);
final b = tester.widget<ElevatedButton>(find.byType(ElevatedButton));
expect(b.onPressed, isNull);
});
});
group('AuthScaffold', () {
testWidgets('pins the footer below the scrolling body', (tester) async {
await pump(
tester,
AuthScaffold(
onBack: _noop,
step: 2,
title: 'Enter the 6-digit code',
subtitle: 'Sent by SMS.',
footer: const AuthPrimaryButton(label: 'Verify', onPressed: _noop),
children: [Container(height: 200, color: Colors.white)],
),
);
expect(tester.takeException(), isNull);
expect(find.text('STEP 2 OF 3'), findsOneWidget);
expect(find.text('Enter the 6-digit code'), findsOneWidget);
final body = tester.getRect(find.byType(SingleChildScrollView));
final footer = tester.getRect(find.byType(ElevatedButton));
expect(footer.top, greaterThanOrEqualTo(body.bottom - 0.01),
reason: 'the CTA sits below the scroll area, not inside it');
});
testWidgets('the back target clears the 44dp floor', (tester) async {
await pump(
tester,
AuthScaffold(
onBack: _noop,
title: 'T',
footer: const AuthPrimaryButton(label: 'Go', onPressed: _noop),
children: const [],
),
);
final back = tester.getRect(find.byIcon(Icons.arrow_back_rounded));
// The glyph is small; the tappable box around it must not be.
final target = tester.getRect(find.ancestor(
of: find.byIcon(Icons.arrow_back_rounded),
matching: find.byType(InkResponse),
));
expect(back.width, lessThan(target.width));
expect(target.width, greaterThanOrEqualTo(ButtonSizes.minTapTarget));
expect(target.height, greaterThanOrEqualTo(ButtonSizes.minTapTarget));
});
testWidgets('no step rail when the screen is outside the flow',
(tester) async {
await pump(
tester,
AuthScaffold(
title: 'Enter your MPIN',
footer: const AuthPrimaryButton(label: 'Unlock', onPressed: _noop),
children: const [],
),
);
expect(find.textContaining('STEP'), findsNothing);
});
// ── The banner is opt-in ──
//
// Only Sign In passes one. The other three screens in the flow must keep
// the plain top-anchored frame, because a brand block there would spend
// keyboard room on a screen whose whole job is to accept six digits.
testWidgets('no banner unless a screen asks for one', (tester) async {
await pump(
tester,
AuthScaffold(
title: 'Enter the 6-digit code',
footer: const AuthPrimaryButton(label: 'Verify', onPressed: _noop),
children: const [],
),
);
expect(find.byType(AuthBrandBanner), findsNothing);
});
});
/// ── The brand block on Sign In ──
///
/// The two things that can actually break here: the block eating the field on
/// a short screen with the keyboard up, and the wordmark being squashed by a
/// layout change. Both are asserted rather than eyeballed.
group('AuthBrandBanner', () {
Widget bannered() => AuthScaffold(
step: 1,
banner: const AuthBrandBanner(),
title: 'Enter your number',
subtitle: 'We use it to sign you in.',
footer: const AuthPrimaryButton(label: 'Continue', onPressed: _noop),
children: [Container(height: 56, color: Colors.white)],
);
for (final width in [320.0, 360.0, 390.0, 430.0]) {
testWidgets('lays out on a ${width.toInt()}pt screen', (tester) async {
await pump(tester, bannered(), screen: Size(width, 720));
expect(tester.takeException(), isNull);
expect(find.byType(AuthBrandBanner), findsOneWidget);
expect(find.text('MILER · RIDER APP'), findsOneWidget);
// The block must not run away with the screen. Half the height is the
// point at which the sheet stops being the subject of the page.
final banner = tester.getRect(find.byType(AuthBrandBanner));
expect(banner.height, lessThan(720 * 0.5),
reason: 'the brand block is a header, not the page');
});
}
testWidgets('the wordmark keeps its aspect ratio', (tester) async {
await pump(tester, bannered());
final mark = tester.getRect(find.image(
const AssetImage('assets/images/doormile-logo.png'),
));
// Source is 748×100. A squashed mark is the classic symptom of a banner
// being re-laid-out without the ratio being held explicitly.
expect(mark.width / mark.height, closeTo(748 / 100, 0.05));
});
testWidgets('it gets out of the way when the keyboard opens',
(tester) async {
await pump(tester, bannered());
final atRest = tester.getRect(find.byType(AuthBrandBanner)).height;
// A keyboard is a bottom view inset; that is the only signal the widget
// reads, so it is the only one the test needs to fake.
//
// This is also the regression guard for a real bug: `Scaffold` with
// `resizeToAvoidBottomInset` strips the bottom inset from everything in
// its `body`, so the banner saw zero however high the keyboard was. It
// only works because AuthScaffold re-provides its own MediaQuery.
tester.view.viewInsets = FakeViewPadding(bottom: 300 * 3);
addTearDown(tester.view.resetViewInsets);
await tester.pumpAndSettle();
final typing = tester.getRect(find.byType(AuthBrandBanner)).height;
expect(typing, lessThan(atRest),
reason: 'the block halves itself so the field stays visible');
// And the mark is still there — it shrinks, it does not disappear.
expect(find.byType(AuthBrandBanner), findsOneWidget);
});
// The screens that focus a box on the first frame must not render the full
// block and then animate it away — that is the deleted splash, rebuilt.
testWidgets('compact starts small and stays small', (tester) async {
await pump(
tester,
AuthScaffold(
banner: const AuthBrandBanner(compact: true),
title: 'Enter your MPIN',
footer: const AuthPrimaryButton(label: 'Unlock', onPressed: _noop),
children: const [],
),
);
final first = tester.getRect(find.byType(AuthBrandBanner)).height;
tester.view.viewInsets = FakeViewPadding(bottom: 300 * 3);
addTearDown(tester.view.resetViewInsets);
await tester.pumpAndSettle();
expect(tester.getRect(find.byType(AuthBrandBanner)).height, first,
reason: 'a compact banner has nowhere to shrink to');
});
// ── The banner used to eat the back button ──
//
// The plain layout renders `onBack` in its own row above the content. The
// bannered one has no such row, so a screen passing `onBack` lost its only
// way out — which is three of the four screens in this flow.
testWidgets('back survives the banner', (tester) async {
await pump(
tester,
AuthScaffold(
onBack: _noop,
banner: const AuthBrandBanner(compact: true),
title: 'Enter the 6-digit code',
footer: const AuthPrimaryButton(label: 'Verify', onPressed: _noop),
children: const [],
),
);
expect(find.byIcon(Icons.arrow_back_rounded), findsOneWidget);
final target = tester.getRect(find.ancestor(
of: find.byIcon(Icons.arrow_back_rounded),
matching: find.byType(InkResponse),
));
expect(target.width, greaterThanOrEqualTo(ButtonSizes.minTapTarget));
expect(target.height, greaterThanOrEqualTo(ButtonSizes.minTapTarget));
// On the banner, not below it.
final banner = tester.getRect(find.byType(AuthBrandBanner));
expect(target.center.dy, lessThan(banner.bottom));
});
});
group('AuthInlineError', () {
testWidgets('takes no space when there is nothing to say', (tester) async {
await pump(
tester,
const Scaffold(body: Center(child: AuthInlineError())),
);
expect(tester.getSize(find.byType(AuthInlineError)).height, 0);
});
testWidgets('shows the message when there is', (tester) async {
await pump(
tester,
const Scaffold(
body: Center(child: AuthInlineError(message: 'That code is wrong.')),
),
);
expect(find.text('That code is wrong.'), findsOneWidget);
expect(tester.getSize(find.byType(AuthInlineError)).height,
greaterThan(0));
});
});
}
void _noop() {}
/// `findsNWidgets` reads badly inline at these call sites.
Matcher findsNCount(int n) => findsNWidgets(n);