── The circle says what pressing it does ── "ONE TOUCH" named the *mechanism* — one gesture, no form — which is something the product team knows and a customer has to be taught. Nobody arrives at that screen wanting a touch; they want a parcel collected. The caption under the sphere still carries what makes it different from the form below. Renamed in the comments too. A codebase explaining "One Touch" against a button that says BOOK NOW is a trap for whoever reads it next. ── OpenStreetMap everywhere ── One line: the default provider was CARTO, it is `osm`. Nothing else moves — `DmMapTiles` already reads the template, subdomains, retina flag and attribution off the provider, so the credit line follows on its own. One thing recorded on the provider itself rather than left to be discovered: these are donated servers and the OSM Foundation's tile policy does not permit a distributed app to lean on them. A block looks like every tile turning into the ground colour at once, with no other symptom. Moving off it is one define — `DM_MAP_PROVIDER=carto|maptiler|stadia`, all serving OpenStreetMap data — and the map_config test now asserts the identifying User-Agent rather than only the URL, because that is what attributable traffic depends on. ── Orders had a second copy of the segmented control ── Its own `_Tab`, a pill radius, 3pt of padding and the count folded into the label's text — beside the pickup window's day switcher, which is DmChoiceChip in a rounded groove with 4pt of padding and the count in a bubble. Two controls doing one job, drifting apart a padding value at a time. It is the same control now, and `_Tab` is gone. DmChoiceChip's horizontal padding drops 12 → 9: three of them split a 390pt phone and "Cancelled" truncated to "Cancell…" at the old value. The day switcher has two chips and acres of room, so it loses nothing.
1042 lines
40 KiB
Dart
1042 lines
40 KiB
Dart
import 'dart:async';
|
||
|
||
import 'package:doormile_cx/ui/widgets/inputs.dart';
|
||
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:flutter/material.dart';
|
||
import 'package:lucide_icons_flutter/lucide_icons.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<void> 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<void> 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<Text>(title).data ?? '';
|
||
}
|
||
|
||
Future<void> 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));
|
||
// The splash holds for its own minimum — and the waits in front of it — before handing over, so
|
||
// the entrance is waited out once, here, rather than by lengthening every
|
||
// settle in the suite.
|
||
await settle(tester, 4400);
|
||
|
||
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);
|
||
|
||
// ── A PIN now, not a posted code ──
|
||
//
|
||
// The SMS gateway is off: `otp/request` still answers `sent: true` and
|
||
// writes the code to the server log, so the old four-box screen could never
|
||
// be completed by a customer. 9876543210 is a number the fake backend knows
|
||
// with a PIN already set, so this walks the returning-customer path and the
|
||
// button submits rather than the field auto-verifying.
|
||
await tester.enterText(find.byType(TextField).first, '1234');
|
||
await settle(tester, 200);
|
||
await tester.tap(find.text('Sign in'));
|
||
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<void> 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 ▸ the BOOK orb ▸ the destination sheet.
|
||
///
|
||
/// BOOK asks one question — where is it going — and the review screen is what
|
||
/// the answer leads to, so every booking test is [openSend] then [pickCity].
|
||
/// The sheet is left open: tests that are about the sheet itself go no
|
||
/// further.
|
||
Future<void> openSend(WidgetTester tester) async {
|
||
await tester.tap(find.text('BOOK NOW'));
|
||
await settle(tester);
|
||
}
|
||
|
||
/// A finder restricted to the open bottom sheet.
|
||
///
|
||
/// Home's live card heads with the district alone — "Chennai", not
|
||
/// "Chennai, Tamil Nadu" — so a bare `find.text('Chennai')` matches the screen
|
||
/// behind the scrim as well as the row in the sheet. Every assertion about
|
||
/// what a sheet is offering goes through this.
|
||
Finder inSheet(Finder matching) =>
|
||
find.descendant(of: find.byType(BottomSheet), matching: matching);
|
||
|
||
/// Answers both questions on the pickup sheet.
|
||
///
|
||
/// ── One sheet now, not two ──
|
||
///
|
||
/// It used to be a destination sheet that closed so a window sheet could open
|
||
/// behind it, and this helper drove both. `showPickupSheet` asks them on one
|
||
/// surface, so the second half is a step rather than a new sheet and its
|
||
/// button reads "Confirm pickup" rather than "Use 2:00 – 4:00 PM".
|
||
///
|
||
/// The `takeWindow: false` branch went with the change: dismissing now drops
|
||
/// the destination too, because there is only one sheet to dismiss, so a
|
||
/// booking that reaches Review without a slot is no longer reachable from
|
||
/// Home. Nothing passed it.
|
||
Future<void> pickCity(WidgetTester tester, String name) async {
|
||
await tester.enterText(find.byType(TextField).first, name);
|
||
await settle(tester, 250);
|
||
// A `Text` inside the sheet, explicitly.
|
||
//
|
||
// `find.text` also matches `EditableText`, so once the city's name is in the
|
||
// search box the plain finder returns the box as well — and Home, behind the
|
||
// scrim, heads its live card with the district alone, so an unscoped finder
|
||
// can land on a widget the sheet is covering. Scoping to the modal route
|
||
// fixes both.
|
||
await tester.tap(
|
||
inSheet(find.byWidgetPredicate((w) => w is Text && w.data == name)).first,
|
||
);
|
||
await settle(tester);
|
||
|
||
// Where the server allows several destinations the row ticks rather than
|
||
// closing the sheet, so the choice has to be confirmed. Where it caps the
|
||
// booking at one there is no button and the tap already committed.
|
||
final send = find.textContaining('Send to ');
|
||
if (send.evaluate().isNotEmpty) {
|
||
await tester.tap(send.first);
|
||
await settle(tester);
|
||
}
|
||
|
||
// The same sheet, now asking when.
|
||
final confirm = find.text('Confirm pickup');
|
||
if (confirm.evaluate().isNotEmpty) {
|
||
await tester.tap(confirm.first);
|
||
await settle(tester);
|
||
}
|
||
}
|
||
|
||
/// Opens the pickup-point editor from the send screen's FROM row.
|
||
Future<void> openPickupEditor(WidgetTester tester) async {
|
||
await openSend(tester);
|
||
await pickCity(tester, 'Chennai');
|
||
await tester.tap(find.text('PICKUP'));
|
||
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('a phone number is asked about before a PIN is asked for',
|
||
(tester) async {
|
||
// ── The regression this locks ──
|
||
//
|
||
// PIN sign-in has three entrances that look identical to a customer: an
|
||
// unknown number, a known number with no PIN, and a known number with one.
|
||
// The app asks `POST /auth/login` which it is, and renders that screen.
|
||
//
|
||
// 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 they make is wrong.
|
||
tester.view.physicalSize = const Size(1230, 9000);
|
||
tester.view.devicePixelRatio = 3.0;
|
||
addTearDown(tester.view.reset);
|
||
|
||
await tester.pumpWidget(const DoormileApp());
|
||
await settle(tester, 4400);
|
||
|
||
// A number the fake backend knows, with a PIN already set.
|
||
await tester.enterText(find.byType(TextField).first, '9876543210');
|
||
await settle(tester, 200);
|
||
await tester.tap(find.text('Continue'));
|
||
await settle(tester);
|
||
|
||
expect(find.text('Enter your PIN'), findsOneWidget);
|
||
// One field, and it is masked — a PIN is a credential the customer keeps,
|
||
// unlike a posted code they use once.
|
||
expect(find.byType(TextField), findsOneWidget);
|
||
expect(
|
||
tester.widget<DmTextField>(find.byType(DmTextField)).obscure,
|
||
isTrue,
|
||
);
|
||
|
||
// A wrong PIN says so without saying which of the two was wrong.
|
||
await tester.enterText(find.byType(TextField).first, '9999');
|
||
await settle(tester, 200);
|
||
await tester.tap(find.text('Sign in'));
|
||
await settle(tester);
|
||
expect(find.text('That number or PIN is incorrect'), findsOneWidget);
|
||
expect(find.text('BOOK NOW'), findsNothing);
|
||
|
||
// The right one lands on Home.
|
||
await tester.enterText(find.byType(TextField).first, '1234');
|
||
await settle(tester, 200);
|
||
await tester.tap(find.text('Sign in'));
|
||
await settle(tester);
|
||
expect(find.text('BOOK NOW'), findsOneWidget);
|
||
await drainToasts(tester);
|
||
});
|
||
|
||
testWidgets('a number with no account asks for a name and a new PIN',
|
||
(tester) async {
|
||
tester.view.physicalSize = const Size(1230, 9000);
|
||
tester.view.devicePixelRatio = 3.0;
|
||
addTearDown(tester.view.reset);
|
||
|
||
await tester.pumpWidget(const DoormileApp());
|
||
await settle(tester, 4400);
|
||
|
||
await tester.enterText(find.byType(TextField).first, '9111122223');
|
||
await settle(tester, 200);
|
||
await tester.tap(find.text('Continue'));
|
||
await settle(tester);
|
||
|
||
// Name, PIN, confirm — the sign-up screen's questions, without a second
|
||
// screen and without an SMS that cannot arrive.
|
||
expect(find.text('Create your account'), findsOneWidget);
|
||
expect(find.byType(TextField), findsNWidgets(3));
|
||
|
||
final fields = find.byType(TextField);
|
||
await tester.enterText(fields.at(0), 'Meera S');
|
||
await tester.enterText(fields.at(1), '4821');
|
||
await tester.enterText(fields.at(2), '4821');
|
||
await settle(tester, 200);
|
||
await tester.tap(find.text('Create PIN and continue'));
|
||
await settle(tester);
|
||
|
||
expect(find.text('BOOK NOW'), findsOneWidget);
|
||
await drainToasts(tester);
|
||
});
|
||
|
||
testWidgets('signs in and lands on Home', (tester) async {
|
||
await signIn(tester);
|
||
|
||
// Home greets by name, carries the sphere, and ends on the live card.
|
||
expect(find.text('Hello, Joe'), findsOneWidget);
|
||
expect(find.text('BOOK NOW'), findsOneWidget);
|
||
// The seeded active booking surfaces on Home. Its state is the uppercase
|
||
// micro-label beside the destination, in the chip vocabulary — "Assigned",
|
||
// not the rail's "Miler assigned".
|
||
expect(find.text('ASSIGNED'), findsOneWidget);
|
||
await drainToasts(tester);
|
||
});
|
||
|
||
testWidgets('dragging the map re-resolves the pickup address',
|
||
(tester) async {
|
||
await signIn(tester);
|
||
await openPickupEditor(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 the editor carries back to the send screen.
|
||
final corrected = pickupAddress(tester);
|
||
await tester.tap(find.text('Confirm pickup point'));
|
||
await settle(tester);
|
||
// Back on the send screen: its title is the route, not a heading.
|
||
expect(find.text('DROP'), findsOneWidget);
|
||
expect(find.text(corrected), findsOneWidget);
|
||
await drainToasts(tester);
|
||
});
|
||
|
||
testWidgets('the full form asks for the drop address; One Touch does not',
|
||
(tester) async {
|
||
final state = AppState();
|
||
await signIn(tester, state: state);
|
||
|
||
// One Touch: state, district, window, and nothing else is asked.
|
||
await openSend(tester);
|
||
await pickCity(tester, 'Chennai');
|
||
expect(state.draftDetailed, isFalse);
|
||
expect(find.text('DROP ADDRESS'), findsNothing);
|
||
expect(
|
||
tester
|
||
.widget<DmButton>(find.widgetWithText(DmButton, 'Confirm Booking'))
|
||
.onPressed,
|
||
isNotNull,
|
||
);
|
||
|
||
// Back to Home, and in again through the form's DROP row.
|
||
await tester.tap(find.byIcon(LucideIcons.arrowLeft).first);
|
||
await settle(tester);
|
||
await tester.tap(find.text('DROP'));
|
||
await settle(tester);
|
||
|
||
// ── Where and when first, the door afterwards ──
|
||
//
|
||
// The address sheet used to land between the destination and the window.
|
||
// Both paths now answer where-and-when on one sheet, and the full form's
|
||
// extra question — which door — comes after it, next to the review that
|
||
// shows it.
|
||
await pickCity(tester, 'Chennai');
|
||
|
||
expect(state.draftDetailed, isTrue);
|
||
expect(find.text('Where in Chennai?'), findsOneWidget);
|
||
|
||
// A building or a street is enough to send somebody to.
|
||
await tester.enterText(find.byType(TextField).first, '3B Lotus Apartments');
|
||
await settle(tester, 200);
|
||
await tester.tap(find.text('Save address'));
|
||
await settle(tester);
|
||
|
||
// Then the review, which carries the address as a row.
|
||
expect(find.text('DROP ADDRESS'), findsOneWidget);
|
||
|
||
final group = state.draftDestinations.first;
|
||
expect(group.details.building, '3B Lotus Apartments');
|
||
expect(group.details.exactAddress, isNotNull);
|
||
expect(
|
||
tester
|
||
.widget<DmButton>(find.widgetWithText(DmButton, 'Confirm Booking'))
|
||
.onPressed,
|
||
isNotNull,
|
||
);
|
||
await drainToasts(tester);
|
||
});
|
||
|
||
testWidgets('the collect-from sheet hands off to the map and back',
|
||
(tester) async {
|
||
final state = AppState();
|
||
await signIn(tester, state: state);
|
||
|
||
// The PICKUP row of Home's pick/drop form opens the sheet.
|
||
await tester.tap(find.text('PICKUP'));
|
||
await settle(tester);
|
||
expect(find.text('Collect from'), findsOneWidget);
|
||
|
||
// "Select on map" pushes the map screen over the sheet, which stays
|
||
// mounted underneath.
|
||
await tester.tap(find.text('Select on map'));
|
||
await settle(tester);
|
||
expect(find.text('Confirm pickup point'), findsOneWidget);
|
||
expect(find.text('PICKUP FROM'), findsOneWidget);
|
||
|
||
// Backing out leaves the customer on the search they came from, not Home.
|
||
await tester.tap(find.byIcon(LucideIcons.arrowLeft).first);
|
||
await settle(tester);
|
||
expect(find.text('Collect from'), findsOneWidget);
|
||
|
||
// Confirming closes both and writes the pin as the pickup.
|
||
await tester.tap(find.text('Select on map'));
|
||
await settle(tester);
|
||
await tester.tap(find.text('Confirm pickup point'));
|
||
await settle(tester);
|
||
expect(find.text('Collect from'), findsNothing);
|
||
expect(state.pickup, isNotNull);
|
||
expect(state.hasPickupPoint, isTrue);
|
||
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 openPickupEditor(tester);
|
||
|
||
DmMapPanel map() => tester.widget<DmMapPanel>(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('Edit'));
|
||
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('Confirm pickup point'));
|
||
await settle(tester);
|
||
expect(find.text('DROP'), 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 openPickupEditor(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<Text>(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 openPickupEditor(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 openPickupEditor(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 openPickupEditor(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<DmButton>(
|
||
find.widgetWithText(DmButton, 'Confirm pickup point'),
|
||
)
|
||
.onPressed,
|
||
isNotNull,
|
||
);
|
||
await drainToasts(tester);
|
||
});
|
||
|
||
testWidgets('one screen books a parcel, every optional field skipped',
|
||
(tester) async {
|
||
final state = AppState();
|
||
await signIn(tester, state: state);
|
||
|
||
await openSend(tester);
|
||
// BOOK asks one question, and it is the only thing on screen. Scoped to
|
||
// the sheet: Home's DROP row asks the same question behind the scrim.
|
||
expect(inSheet(find.text('Where is it going?')), findsOneWidget);
|
||
expect(find.text('Flat or house number'), findsNothing);
|
||
|
||
await pickCity(tester, 'Chennai');
|
||
|
||
// The window was the outstanding row on review, and answering it is what
|
||
// enables the button.
|
||
expect(find.text('Review and book'), findsOneWidget);
|
||
|
||
// Everything else the booking needs is on one screen, and the route to
|
||
// the top of it is two rows with no labels but FROM and TO.
|
||
expect(find.text('PICKUP'), findsOneWidget);
|
||
expect(find.text('DROP'), findsOneWidget);
|
||
// Nothing is asked for here at all any more: no address fields, and no
|
||
// disclosure hiding three more. The route, the packages and the button.
|
||
expect(find.byType(TextField), findsNothing);
|
||
expect(find.text('Add landmark or recipient details'), findsNothing);
|
||
|
||
// The customer answered the window sheet on the way in, so the booking
|
||
// already carries a slot they chose.
|
||
expect(state.draftSlotId, isNotNull);
|
||
expect(find.text('Confirm Booking'), findsOneWidget);
|
||
|
||
await tester.tap(find.text('Confirm Booking'));
|
||
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);
|
||
// Skipped fields are absent, not empty strings.
|
||
expect(booking.details.exactAddress, isNull);
|
||
await drainToasts(tester);
|
||
});
|
||
|
||
testWidgets('the pickup contact defaults to the account and can be changed',
|
||
(tester) async {
|
||
final state = AppState();
|
||
await signIn(tester, state: state);
|
||
await openPickupEditor(tester);
|
||
|
||
// The contact is folded away — the screen exists to place a pin, and the
|
||
// number is right nearly every time — so it has to be asked for.
|
||
await tester.tap(find.text('Someone else is handing it over?'));
|
||
await settle(tester);
|
||
|
||
// Opens on the signed-in customer's own number, without the country code,
|
||
// because the field carries its own prefix.
|
||
final contact = find.widgetWithText(TextField, '9876543210');
|
||
expect(contact, findsOneWidget);
|
||
|
||
// Left alone it stays null, so the request carries the account rather than
|
||
// a copy of it that can drift.
|
||
await tester.tap(find.text('Confirm pickup point'));
|
||
await settle(tester);
|
||
expect(state.draftContactPhone, isNull);
|
||
|
||
// ── Changed, it is a note — not the number the Miler's button dials ──
|
||
//
|
||
// That is what this test used to claim. The rider's number is derived by
|
||
// the backend from the booking's **account**: `GET /miler/bookings` sends
|
||
// one phone field and nothing the create request carries can change it.
|
||
// The handover person travels in `remarks` instead, with a name, and both
|
||
// this screen and Review say so rather than implying otherwise.
|
||
await tester.tap(find.text('PICKUP'));
|
||
await settle(tester);
|
||
await tester.tap(find.text('Someone else is handing it over?'));
|
||
await settle(tester);
|
||
|
||
// By value, not by position: the fold asks for a name first now, and
|
||
// `.first` quietly typed the phone number into it.
|
||
await tester.enterText(
|
||
find.widgetWithText(TextField, '9876543210'),
|
||
'9003144518',
|
||
);
|
||
await tester.enterText(find.byType(TextField).first, 'Meera S');
|
||
await settle(tester, 200);
|
||
await tester.tap(find.text('Confirm pickup point'));
|
||
await settle(tester);
|
||
expect(state.draftContactPhone, '+91 9003144518');
|
||
expect(state.draftContactName, 'Meera S');
|
||
|
||
// Review shows the account's number as the one that will be called, and
|
||
// the handover person beside it.
|
||
expect(find.text('YOUR MILER WILL CALL'), findsOneWidget);
|
||
// Whatever shape the account's number is in — the dev backend echoes the
|
||
// typed identifier, production sends E.164 — it is the one on the card.
|
||
expect(find.text('+91 9876543210'), findsWidgets);
|
||
expect(find.textContaining('Meera S'), findsWidgets);
|
||
await drainToasts(tester);
|
||
});
|
||
|
||
testWidgets('the package stepper is on the screen and clamps', (tester) async {
|
||
final state = AppState();
|
||
await signIn(tester, state: state);
|
||
|
||
await openSend(tester);
|
||
await pickCity(tester, 'Coimbatore');
|
||
|
||
expect(find.text('1 parcel'), findsOneWidget);
|
||
await tester.tap(find.byIcon(LucideIcons.plus));
|
||
await tester.pump();
|
||
await tester.tap(find.byIcon(LucideIcons.plus));
|
||
await tester.pump();
|
||
expect(find.text('3 parcels'), findsOneWidget);
|
||
expect(state.draftTotalPackages, 3);
|
||
|
||
// Push past the cap on the state; the clamp is the model's, not the UI's.
|
||
state.setPackageCount(0, 999);
|
||
await tester.pump();
|
||
expect(state.draftTotalPackages, state.limits.maxPackages);
|
||
|
||
await tester.tap(find.text('Confirm Booking'));
|
||
await settle(tester);
|
||
expect(state.trackedBooking!.totalPackages, state.limits.maxPackages);
|
||
await drainToasts(tester);
|
||
});
|
||
|
||
testWidgets('the window sheet changes the window', (tester) async {
|
||
final state = AppState();
|
||
await signIn(tester, state: state);
|
||
|
||
await openSend(tester);
|
||
await pickCity(tester, 'Chennai');
|
||
final first = state.draftSlotId;
|
||
|
||
// The window is a row on the review list, under the route. It carries no
|
||
// label — the clock glyph and the time are the whole row — so it is found
|
||
// by the window itself.
|
||
await tester.tap(find.textContaining('2:00 – 4:00 PM').first);
|
||
await settle(tester);
|
||
expect(find.text('Pickup window'), findsOneWidget);
|
||
|
||
// A different window, then commit it.
|
||
await tester.tap(find.text('4:00 – 6:00 PM').first);
|
||
await tester.pump();
|
||
await tester.tap(find.textContaining('Use 4:00 – 6:00 PM'));
|
||
await settle(tester);
|
||
|
||
expect(state.draftSlotId, isNot(first));
|
||
expect(find.text('Pickup window'), findsNothing);
|
||
await drainToasts(tester);
|
||
});
|
||
|
||
testWidgets('the destination step is one flat list, not two levels',
|
||
(tester) async {
|
||
await signIn(tester);
|
||
await openSend(tester);
|
||
|
||
// ── What this replaced ──
|
||
//
|
||
// The sheet used to open on states and hide every city until one was
|
||
// tapped, so the first thing a customer saw was a question about
|
||
// geography rather than an answer about service. The old test asserted
|
||
// that Chennai was *absent* from step one.
|
||
//
|
||
// Now the state is a heading and the cities are all there. The list is
|
||
// also the answer to "where do you deliver?", which is why Home's reach
|
||
// line opens this and not a second screen of its own.
|
||
expect(inSheet(find.text('TAMIL NADU')), findsOneWidget);
|
||
expect(inSheet(find.text('KERALA')), findsOneWidget);
|
||
expect(inSheet(find.text('Chennai')), findsOneWidget);
|
||
expect(inSheet(find.text('Ernakulam')), findsOneWidget);
|
||
|
||
// Districts that are not open are never offered, and neither is a state
|
||
// whose every district is closed.
|
||
expect(inSheet(find.text('Madurai')), findsNothing);
|
||
expect(inSheet(find.text('Puducherry')), findsNothing);
|
||
|
||
// And search still cuts across states: Ernakulam is in Kerala and the
|
||
// customer should not have to know that to find it.
|
||
await pickCity(tester, 'Ernakulam');
|
||
expect(find.textContaining('Ernakulam'), findsWidgets);
|
||
});
|
||
|
||
testWidgets('orders split into their own rows once collected', (tester) async {
|
||
final state = AppState();
|
||
await signIn(tester, state: state);
|
||
|
||
await tester.tap(find.bySemanticsLabel('Orders'));
|
||
await settle(tester);
|
||
|
||
// Before collection: one row, owned by the pickup. The reference shares a
|
||
// line with the date and the contents now, so it is a span inside a
|
||
// paragraph rather than a Text of its own.
|
||
expect(find.textContaining('DM-482913'), findsOneWidget);
|
||
|
||
// After collection: the seeded multi-destination pickup is in transit, so
|
||
// each destination stands on its own row.
|
||
final booking = state.byReference('DM-471200')!;
|
||
expect(booking.hasOrders, isTrue);
|
||
expect(find.text('Pickup DM-471200'), findsNothing);
|
||
expect(find.text('Ernakulam'), findsOneWidget);
|
||
expect(find.text('Coimbatore'), findsOneWidget);
|
||
|
||
// Each row is identified by its OWN tracking number, not by the visit that
|
||
// produced it. "Collected on DM-471200" used to run under both rows; it
|
||
// answers a question asked about an order, not while scanning a list of
|
||
// them, so it moved to the order's own screen.
|
||
expect(find.text('Collected on DM-471200'), findsNothing);
|
||
for (final group in booking.destinations) {
|
||
expect(find.textContaining(group.trackingId!), findsOneWidget);
|
||
}
|
||
// Independently tracked.
|
||
expect(
|
||
booking.destinations.map((d) => d.trackingId).toSet().length,
|
||
booking.destinations.length,
|
||
);
|
||
|
||
// Opening one follows that order, not the whole visit: the head card's
|
||
// route ends at that destination, and the reference is its own tracking id.
|
||
await tester.tap(find.text('Ernakulam'));
|
||
await settle(tester);
|
||
expect(find.text('Ernakulam, Kerala'), findsWidgets);
|
||
expect(find.text('YOUR PACKAGES'), findsOneWidget);
|
||
expect(find.text('YOUR PACKAGES'), findsOneWidget);
|
||
await drainToasts(tester);
|
||
});
|
||
|
||
testWidgets('the timeline is the shipment, not the pickup visit',
|
||
(tester) async {
|
||
final state = AppState();
|
||
await signIn(tester, state: state);
|
||
|
||
// 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 whole shipment is on screen the moment tracking opens — five rows
|
||
// fit where the three-row summary and its "Full timeline" action used to.
|
||
expect(find.text('Full timeline'), findsNothing);
|
||
|
||
// Five shipment milestones. Everything before the order exists is one
|
||
// row — the whole pickup visit, which the live card above narrates by
|
||
// name, with a distance and an ETA.
|
||
expect(find.text('Pickup booked'), findsOneWidget);
|
||
expect(find.text('Order created'), findsOneWidget);
|
||
expect(find.text('In transit'), findsOneWidget);
|
||
expect(find.text('Out for delivery'), findsOneWidget);
|
||
expect(find.text('Delivered'), findsOneWidget);
|
||
|
||
// The visit's own stages are never rows of their own.
|
||
expect(find.text('Pickup in progress'), findsNothing);
|
||
expect(find.text('Package collected'), findsNothing);
|
||
expect(find.text('Miler arrived'), findsNothing);
|
||
|
||
// Advancing through the visit does not add a row — it moves the detail
|
||
// under the one row the whole visit occupies.
|
||
await serverAdvance(tester, state, 'DM-482913', JourneyStage.onTheWay);
|
||
expect(find.text('Pickup booked'), findsOneWidget);
|
||
expect(find.textContaining('Miler on the way'), findsWidgets);
|
||
await drainToasts(tester);
|
||
});
|
||
|
||
testWidgets('packages stay together until the pickup is actually collected',
|
||
(tester) async {
|
||
final state = AppState();
|
||
await signIn(tester, state: state);
|
||
|
||
// 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 head card names the state in a pill and draws the route as a rail.
|
||
// It used to be a filled crimson card headed "LIVE" — crimson is the
|
||
// action colour, and tracking is a screen you read.
|
||
expect(find.text('Miler assigned'), findsWidgets);
|
||
expect(find.text('12 Nehru Street'), findsWidgets);
|
||
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);
|
||
|
||
// 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 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);
|
||
|
||
// The head card carries the state in its pill now, not as a headline.
|
||
expect(find.textContaining('Cancelled'), findsWidgets);
|
||
final booking = state.byReference('DM-482913')!;
|
||
expect(booking.status, BookingStatus.cancelled);
|
||
await drainToasts(tester);
|
||
});
|
||
|
||
testWidgets('shows the empty service-area state', (tester) async {
|
||
api.flags.emptyServiceAreas = true;
|
||
await signIn(tester);
|
||
|
||
await openSend(tester);
|
||
|
||
expect(find.text('No cities open yet'), findsOneWidget);
|
||
await drainToasts(tester);
|
||
});
|
||
|
||
testWidgets('shows the network error state with retry', (tester) async {
|
||
final state = AppState();
|
||
await signIn(tester, state: state);
|
||
|
||
// ── The cache has to be cold for the error to be reachable ──
|
||
//
|
||
// Signing in now warms the serviceable cities, and the destination sheet
|
||
// renders `cachedCities` as its `initialItems` rather than a skeleton — so
|
||
// with a warm cache and no network it shows the list it already has, which
|
||
// is the right behaviour and not what this test is about. Clearing the
|
||
// caches puts it back in the state a first-ever open is in.
|
||
state.statesCache = null;
|
||
state.districtCache.clear();
|
||
api.flags.networkError = true;
|
||
|
||
await openSend(tester);
|
||
|
||
expect(find.text("Couldn't load this"), findsOneWidget);
|
||
|
||
api.flags.networkError = false;
|
||
await tester.tap(find.text('Retry'));
|
||
await settle(tester);
|
||
// It comes back with the list it failed to load, not a blank sheet. The
|
||
// state is a heading now — upper-cased and not a step to tap through — so
|
||
// the cities are there without a second navigation.
|
||
expect(find.text('TAMIL NADU'), findsOneWidget);
|
||
expect(find.text('Coimbatore'), findsWidgets);
|
||
expect(find.text('Chennai'), findsWidgets);
|
||
await drainToasts(tester);
|
||
});
|
||
|
||
testWidgets('a delivered order opens its receipt', (tester) async {
|
||
await signIn(tester);
|
||
|
||
await tester.tap(find.bySemanticsLabel('Orders'));
|
||
await settle(tester);
|
||
await tester.tap(find.textContaining('Past'));
|
||
await settle(tester);
|
||
|
||
await tester.tap(find.text('Salem'));
|
||
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('BOOK NOW'), findsOneWidget); // home
|
||
|
||
await openSend(tester); // the whole booking, one screen
|
||
await pickCity(tester, 'Chennai');
|
||
|
||
// The pickup map: a fixed-height sheet over a full-bleed map, so large
|
||
// text has nowhere to grow into. Opening the contact fold is the tallest
|
||
// it ever gets.
|
||
await tester.tap(find.text('PICKUP'));
|
||
await settle(tester);
|
||
await tester.tap(find.text('Someone else is handing it over?'));
|
||
await settle(tester);
|
||
await tester.tap(find.text('Confirm pickup point'));
|
||
await settle(tester);
|
||
|
||
await tester.tap(find.text('Confirm Booking'));
|
||
await settle(tester); // confirmation
|
||
await tester.tap(find.text('Track pickup'));
|
||
await settle(tester); // tracking
|
||
// The live card names the stage; the rail names the shipment milestone.
|
||
expect(find.text('Pickup booked'), findsWidgets);
|
||
|
||
await drainToasts(tester);
|
||
});
|
||
|
||
testWidgets('creating an account 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, 4400);
|
||
|
||
// ── There is no separate sign-up screen any more ──
|
||
//
|
||
// It asked for a name and then sent an SMS code, which cannot arrive with
|
||
// the gateway off. A new number is recognised by `POST /auth/login` and
|
||
// the PIN screen asks for the name and the PIN together, so this walks the
|
||
// real entrance instead of a "Create account" link that no longer exists.
|
||
await tester.enterText(find.byType(TextField).first, '9111122223');
|
||
await settle(tester, 200);
|
||
await tester.tap(find.text('Continue'));
|
||
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.bySemanticsLabel('Orders'));
|
||
await settle(tester);
|
||
// At 1.8x the three tabs are wider than the screen, so the second one has
|
||
// to be scrolled to before it can be tapped.
|
||
await tester.ensureVisible(find.textContaining('Past'));
|
||
await tester.pump();
|
||
await tester.tap(find.textContaining('Past'));
|
||
await settle(tester);
|
||
await tester.tap(find.text('Salem'));
|
||
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.bySemanticsLabel('Orders'));
|
||
await settle(tester);
|
||
// ── Label and count are separate widgets now ──
|
||
//
|
||
// The filter used to fold the figure into the label's own paragraph
|
||
// ("Active (4)"), so this matched one span. It is [DmChoiceChip] now —
|
||
// the same control as the pickup window's day switcher — which puts the
|
||
// count in a bubble beside the label. Two Texts, not one string.
|
||
//
|
||
// The figure is still the rows that tab will show, which for a collected
|
||
// multi-destination pickup is one per destination.
|
||
expect(find.text('Active'), findsOneWidget);
|
||
expect(
|
||
find.text('${state.entriesOf(BookingStatus.active).length}'),
|
||
findsWidgets,
|
||
);
|
||
|
||
// The tab row scrolls — three labels plus their counts do not fit a 390pt
|
||
// phone at large text sizes.
|
||
await tester.ensureVisible(find.textContaining('Cancelled').first);
|
||
await tester.pump();
|
||
await tester.tap(find.textContaining('Cancelled').first);
|
||
await settle(tester);
|
||
expect(find.text('Erode'), findsOneWidget);
|
||
await drainToasts(tester);
|
||
});
|
||
}
|