import 'dart:async'; import 'package:doormile_cx/data/doormile_api.dart'; import 'package:doormile_cx/data/location_service.dart'; import 'package:doormile_cx/data/models.dart'; import 'package:doormile_cx/main.dart'; import 'package:doormile_cx/state/app_state.dart'; import 'package:doormile_cx/ui/widgets/buttons.dart'; import 'package:doormile_cx/ui/widgets/map_panel.dart'; import 'package:doormile_cx/ui/widgets/chrome.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:doormile_cx/data/dev_doormile_api.dart'; /// The app runs continuous animations (radar, live dots, map pulse, skeletons), /// so `pumpAndSettle` would never return. Pump fixed slices instead. Future settle(WidgetTester tester, [int ms = 800]) async { await tester.pump(); await tester.pump(Duration(milliseconds: ms)); await tester.pump(const Duration(milliseconds: 400)); } /// Drains toast and fare-estimate timers so a test ends with nothing pending. Future drainToasts(WidgetTester tester) async { await tester.pump(const Duration(seconds: 3)); } /// The street line currently shown under the pickup map. Empty while the pin /// is still being resolved, which is itself the thing worth asserting. String pickupAddress(WidgetTester tester) { final title = find.byKey(const ValueKey('pickupAddressTitle')); return title.evaluate().isEmpty ? '' : tester.widget(title).data ?? ''; } Future signIn(WidgetTester tester, {AppState? state}) async { // A tall surface so whole screens fit without scrolling in the harness. tester.view.physicalSize = const Size(1230, 9000); tester.view.devicePixelRatio = 3.0; addTearDown(tester.view.reset); await tester.pumpWidget(DoormileApp(initialState: state)); await settle(tester); await tester.enterText(find.byType(TextField).first, '9876543210'); // The CTA only enables once the field rebuilds with a valid number. await settle(tester, 200); await tester.tap(find.text('Continue')); await settle(tester); // The OTP screen verifies as soon as the fourth digit lands. final boxes = find.byType(TextField); for (var i = 0; i < 4; i++) { await tester.enterText(boxes.at(i), '1'); await tester.pump(); } await settle(tester); } /// Moves a booking on the **server**, then lets the app read it back. /// /// This is the only way a stage changes now: the app has no local stage /// machine, so a test cannot step a booking without the backend doing it /// first. Fire-and-pump rather than await — the fake answers on a delay the /// test clock only advances while pumping. Future serverAdvance( WidgetTester tester, AppState state, String reference, JourneyStage stage, ) async { final booking = state.byReference(reference)!; unawaited(state.advanceStage(booking, stage)); await settle(tester, 1400); } /// Home ▸ BOOK ▸ confirm the pin ▸ destination step. Future openDestination(WidgetTester tester) async { await tester.tap(find.text('Start booking')); await settle(tester); await tester.tap(find.text('Next')); await settle(tester); } /// State chips scroll horizontally; drag later ones fully into view first. Future tapState(WidgetTester tester, String name) async { final width = tester.view.physicalSize.width / tester.view.devicePixelRatio; final chips = find .ancestor(of: find.text('Tamil Nadu'), matching: find.byType(Scrollable)) .first; for (var i = 0; i < 8; i++) { final finder = find.text(name); if (finder.evaluate().isNotEmpty) { final rect = tester.getRect(finder); if (rect.left >= 0 && rect.right <= width) break; } await tester.drag(chips, const Offset(-160, 0)); await tester.pump(); } await tester.tap(find.text(name)); await settle(tester); } Future pickPlace(WidgetTester tester, String state, String district) async { await tapState(tester, state); await tester.tap(find.text(district)); await settle(tester); } /// The fake standing in for the backend in this file. /// /// A fresh one per test, installed as the singleton so the widgets under test /// reach it exactly as they would reach the real API. It is the **only** source /// of bookings here: nothing plants one in [AppState]. late DevDoormileApi api; void main() { setUp(() { api = DevDoormileApi(); DoormileApi.overrideInstance(api); // Widget tests run on a fake clock a real platform-channel reply never // reaches, so the device GPS is replaced with a fixed fix. Without this the // pickup screen would sit on "Getting address…" forever. LocationService.instance = FixedLocationService(); }); tearDown(() => DoormileApi.overrideInstance(null)); testWidgets('signs in and lands on Home', (tester) async { await signIn(tester); expect(find.text('Send a parcel'), findsOneWidget); expect(find.text('Start booking'), findsOneWidget); // The seeded active booking surfaces on Home. expect(find.text('Miler assigned'), findsOneWidget); await drainToasts(tester); }); testWidgets('dragging the map re-resolves the pickup address', (tester) async { await signIn(tester); await tester.tap(find.text('Start booking')); await settle(tester); // The pin resolved to a street, not a placeholder. expect(find.text('Getting address…'), findsNothing); final address = pickupAddress(tester); expect(address, isNotEmpty); // Move the map under the fixed pin, the way a customer corrects the door. await tester.drag(find.byType(DmMapPanel), const Offset(-90, -70)); await tester.pump(); // The address goes quiet while the new coordinates are resolved… expect(find.text('Getting address…'), findsOneWidget); // …and comes back as a different place. await settle(tester, 900); expect(find.text('Getting address…'), findsNothing); expect(pickupAddress(tester), isNot(address)); // Which is what Next now carries forward. await tester.tap(find.text('Next')); await settle(tester); expect(find.text('Where are you sending it?'), findsOneWidget); await drainToasts(tester); }); testWidgets('GPS, map drag and search all write one pickup point', (tester) async { final state = AppState( location: FixedLocationService(lat: 11.0183, lng: 76.9725), ); await signIn(tester, state: state); await tester.tap(find.text('Start booking')); await settle(tester); DmMapPanel map() => tester.widget(find.byType(DmMapPanel)); // 1 — the device fix. Address and coordinates land together. final located = state.pickup!; expect(state.hasPickupPoint, isTrue); expect(located.lat, closeTo(11.0183, 0.0001)); expect(map().lat, located.lat); expect(pickupAddress(tester), located.title); // 2 — dragging the map moves the same point. await tester.drag(find.byType(DmMapPanel), const Offset(-140, -110)); await settle(tester, 900); final dragged = state.pickup!; expect(dragged.lat, isNot(located.lat)); expect(dragged.hasLocation, isTrue); expect(map().lat, dragged.lat); expect(pickupAddress(tester), dragged.title); // 3 — a search result overwrites it, coordinates included, and the map // follows the address rather than staying where the drag left it. await tester.tap(find.text('Change')); await settle(tester); await tester.tap(find.text('Brookefields Mall')); await settle(tester); final searched = state.pickup!; expect(searched.title, 'Brookefields Mall'); expect(searched.lat, closeTo(10.9987, 0.0001)); expect(searched.lng, closeTo(76.9628, 0.0001)); expect(map().lat, searched.lat); expect(map().lng, searched.lng); expect(pickupAddress(tester), 'Brookefields Mall'); // …and that one point is what the booking carries forward. await tester.tap(find.text('Next')); await settle(tester); expect(find.text('Where are you sending it?'), findsOneWidget); expect(state.draftPickup!.lat, searched.lat); expect(state.draftPickup!.lng, searched.lng); expect(state.draftPickup!.title, 'Brookefields Mall'); await drainToasts(tester); }); testWidgets('the customer is never shown raw coordinates', (tester) async { final state = AppState( location: FixedLocationService(lat: 11.0183, lng: 76.9725), ); await signIn(tester, state: state); await tester.tap(find.text('Start booking')); await settle(tester); // Whatever the pin resolves to, it reads as a place — never a decimal pair. final coordinate = RegExp(r'-?\d{1,3}\.\d{3,}'); for (final text in tester.widgetList(find.byType(Text))) { final data = text.data; if (data == null) continue; expect( coordinate.hasMatch(data), isFalse, reason: 'coordinates leaked into the UI: "$data"', ); } expect(state.pickup!.hasLocation, isTrue); await drainToasts(tester); }); testWidgets('the customer sees their own position as a blue dot', (tester) async { final state = AppState( location: FixedLocationService( lat: 11.0183, lng: 76.9725, accuracy: 22, ), ); await signIn(tester, state: state); await tester.tap(find.text('Start booking')); await settle(tester); final dot = find.byKey(const ValueKey('deviceLocationDot')); expect(dot, findsOneWidget); expect(state.hasDeviceLocation, isTrue); expect(state.deviceLat, closeTo(11.0183, 0.0001)); expect(state.deviceAccuracy, 22); // Dragging moves the pickup pin. It must not move the customer: the dot // stays where they actually are, which is the whole point of showing it. await tester.drag(find.byType(DmMapPanel), const Offset(-150, -120)); await settle(tester, 900); expect(dot, findsOneWidget); expect(state.deviceLat, closeTo(11.0183, 0.0001)); expect(state.deviceLng, closeTo(76.9725, 0.0001)); expect(state.pickup!.lat, isNot(closeTo(11.0183, 0.0001))); await drainToasts(tester); }); testWidgets('no blue dot when the device refused a position', (tester) async { LocationService.instance = FixedLocationService.denied(LocationDenial.serviceOff); final state = AppState(); await signIn(tester, state: state); await tester.tap(find.text('Start booking')); await settle(tester); // A dot in the wrong place is worse than no dot. expect(find.byKey(const ValueKey('deviceLocationDot')), findsNothing); expect(state.hasDeviceLocation, isFalse); await drainToasts(tester); }); testWidgets('a refused location still lets the customer book', (tester) async { LocationService.instance = FixedLocationService.denied(LocationDenial.denied); await signIn(tester); await tester.tap(find.text('Start booking')); await settle(tester); // The map falls back to the service area rather than an empty ocean, and // the refusal is explained instead of silently swallowed. expect( find.textContaining('Allow location access'), findsOneWidget, ); expect(find.text('Search'), findsOneWidget); // Booking is not blocked by it. expect( tester.widget(find.widgetWithText(DmButton, 'Next')).onPressed, isNotNull, ); await drainToasts(tester); }); testWidgets('one destination, one package, every optional field skipped', (tester) async { final state = AppState(); await signIn(tester, state: state); await openDestination(tester); expect(find.text('Where are you sending it?'), findsOneWidget); await pickPlace(tester, 'Tamil Nadu', 'Chennai'); // Settles into one card carrying the place and nothing else demanded. // // The package stepper and the optional delivery-details row were removed: // one package to one door is the whole of the common path, so the step now // asks for the destination and stops. `packageCount` still defaults to 1 in // the model, which the read-only screens go on showing. expect(find.text('Chennai, Tamil Nadu'), findsOneWidget); expect(find.text('How many packages?'), findsNothing); expect(find.text('Add exact delivery details'), findsNothing); expect(find.text('Send to another place'), findsOneWidget); await tester.tap(find.text('Next')); await settle(tester); expect(find.text('When should we come?'), findsOneWidget); await tester.tap(find.text('2:00 – 4:00 PM').first); await tester.pump(); await tester.tap(find.text('Review booking')); await settle(tester); expect(find.text('Review pickup details'), findsOneWidget); expect(find.text('SENDING TO'), findsOneWidget); expect(find.text('1 package'), findsOneWidget); // Optional omissions are stated calmly, never as an error. expect( find.text('Not added — the Miler can confirm this at pickup.'), findsOneWidget, ); // A single destination shows no multi-destination summary. expect(find.textContaining('destinations'), findsNothing); await tester.tap(find.text('Book pickup')); await settle(tester); expect(find.text('Pickup booked'), findsOneWidget); final booking = state.trackedBooking!; expect(booking.destinations, hasLength(1)); expect(booking.totalPackages, 1); expect(booking.destinations.first.trackingId, isNull); await drainToasts(tester); }); // ── These three used to drive the package stepper ── // // The stepper is gone from the destination step: one package to one door is // the whole common path, so the screen no longer asks. The *logic* it drove // is untouched — `setPackageCount` still clamps to `limits.maxPackages`, the // totals still roll up, and the booking payload still carries them — so the // counts are set on the state here and the assertions that matter (totals, // the cap, what review and the booking show) are kept exactly as they were. testWidgets('one destination, four packages', (tester) async { final state = AppState(); await signIn(tester, state: state); await openDestination(tester); await pickPlace(tester, 'Tamil Nadu', 'Coimbatore'); state.setPackageCount(0, 4); await tester.pump(); expect(state.draftTotalPackages, 4); await tester.tap(find.text('Next')); await settle(tester); await tester.tap(find.text('2:00 – 4:00 PM').first); await tester.pump(); await tester.tap(find.text('Review booking')); await settle(tester); expect(find.text('4 packages'), findsOneWidget); await drainToasts(tester); }); testWidgets('three destinations with mixed counts total correctly on review', (tester) async { final state = AppState(); await signIn(tester, state: state); await openDestination(tester); await pickPlace(tester, 'Tamil Nadu', 'Chennai'); state.setPackageCount(0, 2); await tester.pump(); await tester.tap(find.text('Send to another place')); await settle(tester); await pickPlace(tester, 'Kerala', 'Ernakulam'); await tester.tap(find.text('Send to another place')); await settle(tester); await pickPlace(tester, 'Karnataka', 'Bengaluru Urban'); state.setPackageCount(2, 3); await tester.pump(); expect(state.draftDestinations, hasLength(3)); expect(state.draftTotalPackages, 6); await tester.tap(find.text('Next')); await settle(tester); await tester.tap(find.text('2:00 – 4:00 PM').first); await tester.pump(); await tester.tap(find.text('Review booking')); await settle(tester); expect(find.text('6 packages · 3 destinations'), findsOneWidget); expect(find.text('Chennai, Tamil Nadu'), findsOneWidget); expect(find.text('Ernakulam, Kerala'), findsOneWidget); expect(find.text('Bengaluru Urban, Karnataka'), findsOneWidget); await tester.tap(find.text('Book pickup')); await settle(tester); final booking = state.trackedBooking!; expect(booking.destinations, hasLength(3)); expect(booking.totalPackages, 6); expect(booking.packageSummary, '6 packages · 3 destinations'); // Before collection it is one pickup, however many places it serves. expect( state .entriesOf(BookingStatus.active) .where((e) => e.booking.reference == booking.reference) .length, 1, ); await drainToasts(tester); }); testWidgets('the package cap holds', (tester) async { final state = AppState(); await signIn(tester, state: state); await openDestination(tester); await pickPlace(tester, 'Tamil Nadu', 'Chennai'); // Push well past the cap; `setPackageCount` must clamp. state.setPackageCount(0, 999); await tester.pump(); expect(state.draftTotalPackages, state.limits.maxPackages); // A full pickup cannot take another place either. expect(state.canAddDestination, isFalse); expect( find.text('Up to ${state.limits.maxPackages} packages in one pickup.'), findsOneWidget, ); await drainToasts(tester); }); testWidgets('the destination cap holds', (tester) async { final state = AppState(); await signIn(tester, state: state); await openDestination(tester); const places = [ ('Tamil Nadu', 'Chennai'), ('Kerala', 'Ernakulam'), ('Karnataka', 'Mysuru'), ('Tamil Nadu', 'Erode'), ('Kerala', 'Thrissur'), ]; for (var i = 0; i < places.length; i++) { if (i > 0) { await tester.tap(find.text('Send to another place')); await settle(tester); } await pickPlace(tester, places[i].$1, places[i].$2); } expect(state.draftDestinations, hasLength(state.limits.maxDestinations)); expect(state.canAddDestination, isFalse); expect(find.text('Send to another place'), findsNothing); expect( find.text( 'Up to ${state.limits.maxDestinations} places in one pickup. ' 'Book another pickup for more.', ), findsOneWidget, ); await drainToasts(tester); }); testWidgets('the picker offers serviceable districts only', (tester) async { await signIn(tester); await openDestination(tester); // Nothing until a state is picked. expect(find.text('Chennai'), findsNothing); await tapState(tester, 'Tamil Nadu'); expect(find.text('Chennai'), findsOneWidget); expect(find.text('Coimbatore'), findsOneWidget); expect(find.text('Ernakulam'), findsNothing); // Unsupported places are never disabled tiles — they are named once, // quietly, and only because they are genuinely coming. expect(find.text('Madurai'), findsNothing); expect(find.text('Coming soon: Madurai'), findsOneWidget); await tapState(tester, 'Kerala'); expect(find.text('Ernakulam'), findsOneWidget); expect(find.text('Palakkad'), findsOneWidget); expect(find.text('Chennai'), findsNothing); expect(find.text('Coming soon: Kozhikode'), findsOneWidget); await drainToasts(tester); }); testWidgets('a state with nothing open is not offered at all', (tester) async { await signIn(tester); await openDestination(tester); // Puducherry is in the backend response but has no open district, so the // customer never has to read past it. expect(find.text('Puducherry'), findsNothing); expect(find.text('Tamil Nadu'), findsOneWidget); await drainToasts(tester); }); testWidgets('orders split into their own rows once collected', (tester) async { final state = AppState(); await signIn(tester, state: state); await tester.tap(find.text('Orders')); await settle(tester); // Before collection: one row, owned by the pickup. expect(find.text('DM-482913'), findsOneWidget); // After collection: the seeded multi-destination pickup is in transit, so // each destination stands on its own with a quiet line back to the visit. final booking = state.byReference('DM-471200')!; expect(booking.hasOrders, isTrue); expect(find.text('Pickup DM-471200'), findsNothing); expect(find.text('Ernakulam, Kerala'), findsOneWidget); expect(find.text('Coimbatore, Tamil Nadu'), findsOneWidget); expect( find.text('Collected on DM-471200'), findsNWidgets(booking.destinations.length), ); // Independently tracked. expect( booking.destinations.map((d) => d.trackingId).toSet().length, booking.destinations.length, ); // Opening one follows that order, not the whole visit. await tester.tap(find.text('Ernakulam, Kerala')); await settle(tester); expect(find.text('To Ernakulam, Kerala'), findsOneWidget); expect(find.text('YOUR PACKAGES'), findsOneWidget); await drainToasts(tester); }); testWidgets('the customer timeline groups the operational states', (tester) async { final state = AppState(); await signIn(tester, state: state); await tester.tap(find.text('Miler assigned')); await settle(tester); // Tracking opens on the three most recent updates; the rest of the journey // is one tap away. await tester.tap(find.text('All 7 stages')); await settle(tester); // Seven customer milestones, not nine backend states. expect(find.text('Pickup booked'), findsWidgets); expect(find.text('Pickup in progress'), findsOneWidget); expect(find.text('Package collected'), findsOneWidget); // Operational states are never timeline rows of their own. expect(find.text('Miler arrived'), findsNothing); expect(find.text('Order created'), findsNothing); // Advancing to "on the way" keeps the milestone and moves the detail. It // reads twice: once on the timeline, and once as the caption on the live // card's own progress rail. await serverAdvance(tester, state, 'DM-482913', JourneyStage.onTheWay); expect(find.text('Pickup in progress'), findsNWidgets(2)); expect(find.textContaining('Miler on the way'), findsWidgets); // The pill speaks customer language, not the operational stage. expect(find.text('Order created'), findsNothing); await drainToasts(tester); }); testWidgets('packages stay together until the pickup is actually collected', (tester) async { final state = AppState(); await signIn(tester, state: state); await tester.tap(find.text('Miler assigned')); await settle(tester); // The live card, not an eyebrow: a pickup in flight is the screen's one // crimson surface. expect(find.text('LIVE'), findsOneWidget); expect(find.text('Cancel this pickup'), findsOneWidget); // No per-package breakdown before collection. expect(find.text('YOUR PACKAGES'), findsNothing); await serverAdvance(tester, state, 'DM-482913', JourneyStage.pickedUp); // Custody has changed hands: cancellation is no longer offered. expect(find.text('Cancel this pickup'), findsNothing); await serverAdvance(tester, state, 'DM-482913', JourneyStage.orderCreated); final booking = state.byReference('DM-482913')!; expect(booking.stage, JourneyStage.orderCreated); expect(booking.destinations.first.trackingId, isNotNull); await drainToasts(tester); }); testWidgets('cancels the whole pickup from tracking', (tester) async { final state = AppState(); await signIn(tester, state: state); await tester.tap(find.text('Miler assigned')); await settle(tester); await tester.tap(find.text('Cancel this pickup')); await settle(tester); expect(find.text('Cancel this pickup?'), findsOneWidget); await tester.tap(find.text('Package not ready')); await tester.pump(); await tester.tap(find.text('Yes, cancel pickup')); await settle(tester); expect(find.text('Booking cancelled'), findsOneWidget); final booking = state.byReference('DM-482913')!; expect(booking.status, BookingStatus.cancelled); await drainToasts(tester); }); testWidgets('shows the empty service-area state', (tester) async { await signIn(tester); api.flags.emptyServiceAreas = true; await openDestination(tester); expect(find.text('No service areas available'), findsOneWidget); await drainToasts(tester); }); testWidgets('shows the network error state with retry', (tester) async { await signIn(tester); api.flags.networkError = true; await openDestination(tester); expect(find.text("Couldn't load this"), findsOneWidget); api.flags.networkError = false; await tester.tap(find.text('Retry')); await settle(tester); expect(find.text('Tamil Nadu'), findsOneWidget); await drainToasts(tester); }); testWidgets('a delivered order opens its receipt', (tester) async { await signIn(tester); await tester.tap(find.text('Orders')); await settle(tester); await tester.tap(find.text('Completed')); await settle(tester); await tester.tap(find.text('Salem, Tamil Nadu')); await settle(tester); expect(find.text('Delivered'), findsWidgets); expect(find.text('Total paid'), findsOneWidget); expect(find.text('₹64'), findsWidgets); // What the Miler recorded at the door. expect(find.text('COLLECTED AT YOUR DOOR'), findsOneWidget); expect(find.text('Miler verified'), findsOneWidget); // Once on the parcel block, once in the payment breakdown. expect(find.text('1.6 kg'), findsNWidgets(2)); expect(find.text('Verified weight'), findsOneWidget); expect(find.text('1 of 1'), findsOneWidget); expect(find.textContaining('Weighed and photographed by'), findsOneWidget); await drainToasts(tester); }); testWidgets('key screens survive large accessibility text', (tester) async { // Any overflow throws, so simply building these screens is the assertion. tester.platformDispatcher.textScaleFactorTestValue = 1.8; addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); await signIn(tester); // login + OTP expect(find.text('Start booking'), findsOneWidget); // home await openDestination(tester); // pickup point + destination await pickPlace(tester, 'Tamil Nadu', 'Chennai'); expect(find.text('Chennai, Tamil Nadu'), findsOneWidget); await tester.tap(find.text('Next')); await settle(tester); // pickup time await tester.tap(find.text('2:00 – 4:00 PM').first); await tester.pump(); await tester.tap(find.text('Review booking')); await settle(tester); // review expect(find.text('Review pickup details'), findsOneWidget); await tester.tap(find.text('Book pickup')); await settle(tester); // confirmation await tester.tap(find.text('Track pickup')); await settle(tester); // tracking expect(find.text('Pickup booked'), findsWidgets); await drainToasts(tester); }); testWidgets('sign up survives large accessibility text', (tester) async { tester.platformDispatcher.textScaleFactorTestValue = 1.8; addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); tester.view.physicalSize = const Size(1230, 9000); tester.view.devicePixelRatio = 3.0; addTearDown(tester.view.reset); await tester.pumpWidget(const DoormileApp()); await settle(tester); await tester.tap(find.text('Create account')); await settle(tester); // Building it is the assertion: any overflow in the hero or the form // throws. expect(find.text('Create your account.'), findsOneWidget); await drainToasts(tester); }); testWidgets('orders and account survive large accessibility text', (tester) async { tester.platformDispatcher.textScaleFactorTestValue = 1.8; addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); await signIn(tester); await tester.tap(find.text('Orders')); await settle(tester); await tester.tap(find.text('Completed')); await settle(tester); await tester.tap(find.text('Salem, Tamil Nadu')); await settle(tester); // receipt await drainToasts(tester); }); testWidgets('orders tabs filter by status', (tester) async { final state = AppState(); await signIn(tester, state: state); await tester.tap(find.text('Orders')); await settle(tester); // Each tab carries its own count, inside the header's segmented control — // and the figure is the rows that tab will actually show, which for a // collected multi-destination pickup is one per destination. expect( find.descendant( of: find.byType(DmSegmented), matching: find.text('${state.entriesOf(BookingStatus.active).length}'), ), findsOneWidget, ); await tester.tap(find.text('Cancelled')); await settle(tester); expect(find.text('Erode, Tamil Nadu'), findsOneWidget); await drainToasts(tester); }); }