@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 _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 _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 _pumpToInvert(WidgetTester tester) async { for (var i = 0; i < 120; i++) { final scaffold = tester.widget(find.byType(Scaffold).first); if (scaffold.backgroundColor != DmColors.splash) return; await tester.pump(const Duration(milliseconds: 50)); } fail('the splash never left crimson'); } Future _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('BOOK NOW')); 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)); }); }