── What was actually broken ── The SMS gateway was switched off, and `POST /auth/otp/request` does not fail when that happens: it still answers `sent: true`, still issues a valid 4-digit code, and writes it to the **server log**. So the phone path walked customers to a code screen for a code that could not arrive, and every digit they eventually typed was wrong. The failure read to them as "I entered it wrong". Phone sign-in is now a PIN, which needs no gateway. ── Email still sends codes, so email is untouched ── Email OTP goes over SMTP and works. Deleting a working way in to tidy up a broken one is a net loss for anyone with an email on their account, so "Use email instead" and the code screen stay exactly as they were. `login_otp_guard_test` moves to that path — the `sent: false` guard still matters there, and that is now the only place it can fire. ── One screen, three entrances ── `POST /auth/login` says which of them a number is before anything is asked, so the app never guesses. Guessing is not cosmetic: offer "create a PIN" to a returning customer and the server answers `pin_already_set` on a screen that cannot succeed; offer "enter your PIN" to somebody who has never set one and every attempt is wrong. The separate sign-up screen is deleted rather than hidden. It asked for a name and then sent an SMS code — a second entrance asking the same questions and posting a letter that never lands. A new number now gives its name and PIN on the same screen. ── A second sign-in path found a latent bug ── `AppState.signIn` only started `refreshOrders`, and the OTP screen called `detectPickupLocation` itself afterwards to make up the difference. That held exactly as long as there was one sign-in screen. PIN sign-in did not know about the extra call, so Home opened with no pickup and no serviceable cities. The work belongs to signing in, not to whichever screen happened to be last, so it moved into `signIn` and the OTP screen's copy is gone. ── What the screen deliberately does not do ── It does not greet by name. `POST /auth/login` returns the account holder's name, which tells anybody who types a number who owns it; the field is read but never displayed, so it disappears quietly when the backend drops it. It does not say whether the number or the PIN was wrong — the server answers identically for both on purpose, and narrowing it here would turn sign-in into a way of testing whether a number has an account. "Forgot your PIN?" renders only when a support contact is configured. There is no reset endpoint, so it can only point at a human — and telling somebody locked out that help exists without saying where is worse than silence. ── The handover note does not reach the Miler ── The app said "we pass this to your Miler as a note". It does not: `remarks` reaches the admin console and stops, because the rider app reads a `notes` field per stop that the backend never sends. A customer could hand their parcel to a neighbour believing the Miler had been told. Both screens now say it is recorded on the booking, and that the Miler still calls the account's number. ── Also ── DmTextField gains `obscure`, and PinScreen carries a back button — without one the only correction for a mistyped digit was killing the app.
284 lines
11 KiB
Dart
284 lines
11 KiB
Dart
@Tags(['snapshot'])
|
|
library;
|
|
|
|
import 'package:doormile_cx/data/dev_doormile_api.dart';
|
|
import 'package:doormile_cx/data/doormile_api.dart';
|
|
import 'package:doormile_cx/data/location_service.dart';
|
|
import 'package:doormile_cx/main.dart';
|
|
import 'package:doormile_cx/state/app_state.dart';
|
|
import 'package:doormile_cx/ui/tokens.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
|
|
/// Renders each screen at phone size and writes it to `test/snapshots/`.
|
|
///
|
|
/// This is a **design** check, not a regression check. A deliberate redesign
|
|
/// would fail every one of these, which is worthless as a test signal, so the
|
|
/// `snapshot` tag is skipped in a normal run (see `dart_test.yaml`) and the
|
|
/// pictures are rendered on demand:
|
|
///
|
|
/// flutter test test/design_snapshot_test.dart --run-skipped --update-goldens
|
|
///
|
|
/// It exists because the only honest way to review a visual rebuild is to look
|
|
/// at it, and this produces the same pictures on any machine, with the real
|
|
/// fonts, without a device attached.
|
|
///
|
|
/// The real fonts are loaded first — without that, Flutter's test harness draws
|
|
/// everything in Ahem's black boxes and the pictures are worthless.
|
|
Future<void> _loadFonts() async {
|
|
for (final (family, asset) in const [
|
|
('PlusJakartaSans', 'assets/fonts/PlusJakartaSans-Variable.ttf'),
|
|
('Switzer', 'assets/fonts/Switzer-Variable.ttf'),
|
|
('GeistMono', 'assets/fonts/GeistMono-VF.ttf'),
|
|
// The icon font too, or every glyph in the picture is a hollow box.
|
|
('packages/lucide_icons_flutter/Lucide',
|
|
'packages/lucide_icons_flutter/assets/lucide.ttf'),
|
|
]) {
|
|
final loader = FontLoader(family)..addFont(rootBundle.load(asset));
|
|
await loader.load();
|
|
}
|
|
}
|
|
|
|
/// Runs the clock forward until a screen has stopped moving.
|
|
///
|
|
/// Four pumps, not two. An entrance animation is started by a zero-delay
|
|
/// `Future.delayed` inside `flutter_animate`, and under the test binding's fake
|
|
/// clock that timer only fires on the pump *after* the widget was built — so
|
|
/// the controller begins life part-way through the first elapse and a two-pump
|
|
/// settle photographs the screen mid-fade. Everything here is under a second,
|
|
/// so the extra frames cost nothing and the pictures are of settled screens.
|
|
Future<void> _settle(WidgetTester tester, [int ms = 800]) async {
|
|
await tester.pump();
|
|
await tester.pump(Duration(milliseconds: ms));
|
|
for (var i = 0; i < 4; i++) {
|
|
await tester.pump(const Duration(milliseconds: 400));
|
|
}
|
|
}
|
|
|
|
/// Pumps until the splash's ground has left [DmColors.splash].
|
|
///
|
|
/// Returns on the first frame of the invert, so a caller can step into the
|
|
/// middle of it by a known amount. Gives up after six seconds rather than
|
|
/// spinning forever if the splash never starts.
|
|
Future<void> _pumpToInvert(WidgetTester tester) async {
|
|
for (var i = 0; i < 120; i++) {
|
|
final scaffold = tester.widget<Scaffold>(find.byType(Scaffold).first);
|
|
if (scaffold.backgroundColor != DmColors.splash) return;
|
|
await tester.pump(const Duration(milliseconds: 50));
|
|
}
|
|
fail('the splash never left crimson');
|
|
}
|
|
|
|
Future<void> _shot(WidgetTester tester, String name) async {
|
|
await expectLater(
|
|
find.byType(MaterialApp),
|
|
matchesGoldenFile('snapshots/$name.png'),
|
|
);
|
|
}
|
|
|
|
void main() {
|
|
late DevDoormileApi api;
|
|
|
|
setUp(() {
|
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
|
.setMockMethodCallHandler(
|
|
const MethodChannel('plugins.flutter.io/path_provider'),
|
|
(call) async => '/tmp',
|
|
);
|
|
api = DevDoormileApi();
|
|
DoormileApi.overrideInstance(api);
|
|
LocationService.instance = FixedLocationService();
|
|
});
|
|
|
|
tearDown(() => DoormileApi.overrideInstance(null));
|
|
|
|
testWidgets('screens', (tester) async {
|
|
await _loadFonts();
|
|
|
|
// One phone, portrait: 390x844 at 3x, the frame the design was drawn in.
|
|
tester.view.physicalSize = const Size(1170, 2532);
|
|
tester.view.devicePixelRatio = 3.0;
|
|
addTearDown(tester.view.reset);
|
|
|
|
final state = AppState();
|
|
await tester.pumpWidget(DoormileApp(initialState: state));
|
|
|
|
// Image.asset resolves off the frame loop, so in a test it never paints
|
|
// unless it is decoded first — without this the mark and the wordmark are
|
|
// blank gaps in every picture of the entrance.
|
|
await tester.runAsync(() async {
|
|
for (final asset in const [
|
|
'assets/images/doormile-mark.png',
|
|
'assets/images/doormile-logo.png',
|
|
]) {
|
|
await precacheImage(
|
|
AssetImage(asset),
|
|
tester.element(find.byType(MaterialApp)),
|
|
);
|
|
}
|
|
});
|
|
|
|
// All three beats, with raw pumps: `_settle` always runs four extra 400ms
|
|
// frames at the end, which is 2.5s — past the moment the splash hands
|
|
// over, so it would photograph the login screen instead of any of them.
|
|
// The clock does not start until the engine says it has rasterized a
|
|
// frame and the composition has parsed, which under the test binding is
|
|
// about 1.2s. Every shot is taken relative to that, not to pumpWidget.
|
|
await tester.pump();
|
|
await tester.pump(const Duration(milliseconds: 2000));
|
|
await _shot(tester, '00-splash-truck');
|
|
|
|
// ── The next two are found, not counted to ──
|
|
//
|
|
// The splash's clock does not start at `pumpWidget`: it waits for the
|
|
// composition to parse and for the engine to report a rasterized frame,
|
|
// and how long that takes depends on what else is in the tree. Two
|
|
// attempts at counting milliseconds from here photographed the invert
|
|
// 25ms in, and then the login screen. So this watches for the beat
|
|
// instead — the ground leaving crimson is unambiguous and needs no
|
|
// estimate of anything.
|
|
await _pumpToInvert(tester);
|
|
await tester.pump(const Duration(milliseconds: 160));
|
|
await _shot(tester, '00b-splash-invert');
|
|
|
|
// Past the invert, and far enough into the mark's entrance to photograph
|
|
// the mark rather than its fade. `_markHold` is what leaves room for this.
|
|
await tester.pump(const Duration(milliseconds: 320));
|
|
await tester.pump(const Duration(milliseconds: 620));
|
|
await _shot(tester, '00c-splash-mark');
|
|
|
|
// The splash holds for its own minimum — and the waits in front of it — before handing over.
|
|
await _settle(tester, 4400);
|
|
await _shot(tester, '01-sign-in');
|
|
|
|
// ── Sign-in is a phone number and a PIN now ──
|
|
//
|
|
// The "Create account" link and its screen are gone: a new number is
|
|
// recognised by `POST /auth/login` and the PIN screen asks for the name
|
|
// and the PIN in one step. An unknown number therefore photographs the
|
|
// create-account state of that screen, and the known one photographs the
|
|
// returning-customer state.
|
|
await tester.enterText(find.byType(TextField).first, '9111122223');
|
|
await _settle(tester, 200);
|
|
await tester.tap(find.text('Continue'));
|
|
await _settle(tester);
|
|
await _shot(tester, '01b-create-account');
|
|
await tester.tap(find.byIcon(LucideIcons.arrowLeft).first);
|
|
await _settle(tester);
|
|
|
|
await tester.enterText(find.byType(TextField).first, '9876543210');
|
|
await _settle(tester, 200);
|
|
await tester.tap(find.text('Continue'));
|
|
await _settle(tester);
|
|
await _shot(tester, '02-pin');
|
|
|
|
// A PIN is submitted, not auto-verified: it is a credential the customer
|
|
// keeps, so the screen waits for them to say they are done.
|
|
await tester.enterText(find.byType(TextField).first, '1234');
|
|
await _settle(tester, 200);
|
|
await tester.tap(find.text('Sign in'));
|
|
await _settle(tester);
|
|
await _shot(tester, '03-home');
|
|
|
|
// The pickup address is a live value in the header, so the search that
|
|
// changes it is reachable from Home rather than only from the booking.
|
|
await tester.tap(find.text('38 Mettupalayam Road'));
|
|
await _settle(tester);
|
|
await _shot(tester, '03b-pickup-search');
|
|
await tester.tapAt(const Offset(200, 40)); // through the scrim
|
|
await _settle(tester);
|
|
|
|
await tester.tap(find.bySemanticsLabel('Orders'));
|
|
await _settle(tester);
|
|
await _shot(tester, '04-orders');
|
|
|
|
await tester.tap(find.bySemanticsLabel('Account'));
|
|
await _settle(tester);
|
|
await _shot(tester, '05-account');
|
|
|
|
await tester.tap(find.bySemanticsLabel('Home'));
|
|
await _settle(tester);
|
|
// The live card, by its reference. Home's quick-dispatch chips are
|
|
// named after cities too, so a bare 'Chennai' finder now hits a
|
|
// shortcut that starts a new booking instead.
|
|
await tester.tap(find.text('DM-482913'));
|
|
await _settle(tester);
|
|
await _shot(tester, '06-tracking');
|
|
|
|
// `ensureVisible`, not `dragUntilVisible`.
|
|
//
|
|
// `IndexedStack` keeps all three tab roots alive under the pushed route,
|
|
// so a positional `Scrollable` finder picks Home's list — which the
|
|
// tracking screen is covering. `ensureVisible` finds the target's own
|
|
// scrollable ancestor and needs no finder at all.
|
|
await tester.ensureVisible(find.text('Cancel this pickup'));
|
|
await tester.pump();
|
|
await tester.tap(find.text('Cancel this pickup'));
|
|
await _settle(tester);
|
|
await _shot(tester, '06b-cancel-sheet');
|
|
await tester.tapAt(const Offset(200, 40));
|
|
await _settle(tester);
|
|
|
|
await tester.tap(find.byIcon(LucideIcons.arrowLeft).first);
|
|
await _settle(tester);
|
|
|
|
// ── Booking: one sheet, two questions, then the review ──
|
|
//
|
|
// It was two sheets, and the shots followed them: states, then the
|
|
// districts inside a state, then a separate window sheet. There are no
|
|
// states to open now — the list is flat and the state is a heading — and
|
|
// the window is the same sheet's second step.
|
|
await tester.tap(find.text('ONE TOUCH'));
|
|
await _settle(tester);
|
|
await _shot(tester, '07-pickup-where');
|
|
|
|
await tester.tap(
|
|
find
|
|
.descendant(
|
|
of: find.byType(BottomSheet),
|
|
matching: find.text('Chennai'),
|
|
)
|
|
.first,
|
|
);
|
|
await _settle(tester);
|
|
// The dev backend allows several destinations, so the row ticked rather
|
|
// than advancing — the choice is confirmed.
|
|
await tester.tap(find.textContaining('Send to ').first);
|
|
await _settle(tester);
|
|
// Same surface, second question. The customer chooses their slot; it is
|
|
// never chosen for them.
|
|
await _shot(tester, '07b-pickup-when');
|
|
await tester.tap(find.text('Confirm pickup'));
|
|
await _settle(tester);
|
|
await _shot(tester, '08-send');
|
|
|
|
// The map editor behind the FROM row: the one screen in the flow whose
|
|
// subject is a picture.
|
|
await tester.tap(find.text('PICKUP'));
|
|
await _settle(tester);
|
|
await _shot(tester, '10-pickup-map');
|
|
await tester.tap(find.byIcon(LucideIcons.arrowLeft).first);
|
|
await _settle(tester);
|
|
|
|
await tester.tap(find.text('Confirm Booking'));
|
|
await _settle(tester);
|
|
await _shot(tester, '11-booked');
|
|
|
|
// A finished order, for the receipt treatment.
|
|
await tester.tap(find.text('Back to home'));
|
|
await _settle(tester);
|
|
await tester.tap(find.bySemanticsLabel('Orders'));
|
|
await _settle(tester);
|
|
await tester.tap(find.textContaining('Past'));
|
|
await _settle(tester);
|
|
await _shot(tester, '04b-orders-completed');
|
|
await tester.tap(find.text('Salem'));
|
|
await _settle(tester);
|
|
await _shot(tester, '12-receipt');
|
|
|
|
await tester.pump(const Duration(seconds: 3));
|
|
});
|
|
}
|