@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: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)); } } 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-icon.png', 'assets/images/doormile-logo.png', ]) { await precacheImage( AssetImage(asset), tester.element(find.byType(MaterialApp)), ); } }); // Both 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 either one. // 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. Both shots are taken relative to that, not to pumpWidget. await tester.pump(); await tester.pump(const Duration(milliseconds: 2000)); await _shot(tester, '00-splash-animation'); // Past the handover, plus a frame for the switch to settle. await tester.pump(const Duration(milliseconds: 1200)); await tester.pump(const Duration(milliseconds: 400)); await _shot(tester, '00b-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'); await tester.tap(find.text('Create account')); await _settle(tester); await _shot(tester, '01b-sign-up'); 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-verify'); await tester.enterText(find.byType(TextField).first, '1111'); 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: state, district, window, then one screen. await tester.tap(find.text('BOOK NOW')); await _settle(tester); await _shot(tester, '07-destination-states'); await tester.tap(find.text('Tamil Nadu')); await _settle(tester); await _shot(tester, '07b-destination-districts'); // 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); // The dev backend allows several destinations, so the row ticked rather // than closing the sheet — the choice is confirmed. await tester.tap(find.textContaining('Send to ').first); await _settle(tester); // The window sheet follows the destination on its own: the customer // chooses their slot, it is never chosen for them. await _shot(tester, '09-window-sheet'); await tester.tap(find.textContaining('Use ').first); 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('ORIGIN')); 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)); }); }