The effort pass, end to end. Every screen was run through one test — if I remove this sentence, does the customer make a worse decision? — and the parts that failed it are gone. The flow Home ▸ BOOK ▸ Where is it going? ▸ When shall we collect? ▸ details ▸ booked BOOK opens a sheet, not a form. The destination is browsed state-then-district because a flat list of every serviceable district survives twelve and not sixty, and search cuts across states because somebody who knows they are sending to Chennai should not have to know which state it is in. Districts multi-select, but only where the server allows it: BookingLimits advertises maxDestinations: 1 until the Miler build keys on consignmentid, and a sheet that ignored that would sell a booking the network cannot complete. The pickup window is now a step the customer answers rather than a slot chosen for them. A pickup window is a promise about somebody's afternoon. What the screens stopped saying Home lost the orb caption for returning customers and a four-cell live card. Send lost the city strip, both address fields, the optional disclosure and three sentences about charging — the route, the packages and the button are what is left. Tracking lost a radar with a bike in it, a Milers-in-your-zone count, a "Step 2 of 7" and a sentence describing the screen you were looking at. The window sheet lost "Fastest pickup", "4 Milers nearby" and "Relaxed evening handover". Type Poppins, which has no variable release — four static cuts, and the sans styles set fontWeight alone because fontVariations on a static font is ignored in silence. Every weight dropped a step and the tracking went deeper: Poppins is built on near-circles and carries more ink than the humanist faces before it. Objects One lit sphere on Home, and the primary button now takes its gradient and rim because a committing action that is not lit like the hero reads as a different material. The tracking rail's connector is crimson as far as the parcel has come, so the line is the progress bar. Confirmation is a white tick on green: crimson is this app's action colour and that screen has nothing left to do. Bugs found on the way The OTP screen dropped digits. Four fields passing focus along lose a keystroke that arrives mid-transition, so "1234" became "124" and the screen answered "That code did not match" — blaming the customer for its own race. One field now, four boxes that only draw. Nothing ever asked for the customer's location: detectPickupLocation was the OTP screen's job, so a restored session or an auto-login never triggered the permission prompt and the pickup map had nothing to centre on. The launcher icon and both splash screens pointed at a house drawn as two vector paths — a placeholder that shipped. The splash clock started when the widget was built rather than when it was visible, so the truck got 0.45s of a 1.8s beat behind Android's own splash. It waits on waitUntilFirstFrameRasterized now, raced against a timeout so a binding that never reports one cannot strand the app. Also: design/screens/ holds all 19 screens under readable names, tool/ has the scripts that refresh them and rebrand the Lottie, and DESIGN.md is current. flutter analyze clean. 88 tests, 1 skipped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EqVJPB9B4QuieZnBAAKgYQ
726 lines
25 KiB
Dart
726 lines
25 KiB
Dart
import 'package:doormile_cx/data/app_config.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/state/app_state.dart';
|
||
import 'package:flutter_test/flutter_test.dart';
|
||
|
||
/// A programmable stand-in for the service layer.
|
||
///
|
||
/// Not the mock API — that one invents a whole product. This one answers
|
||
/// exactly what a test sets and records exactly what it was asked, which is
|
||
/// what the wiring between [AppState] and the contract needs proving.
|
||
class FakeApi extends DoormileApi {
|
||
FakeApi({this.limits = const BookingLimits(maxPackages: 20, maxDestinations: 5)});
|
||
|
||
BookingLimits limits;
|
||
Object? limitsError;
|
||
|
||
final List<String?> createKeys = [];
|
||
int createFailures = 0;
|
||
Object? createError;
|
||
|
||
final List<BookingPage> pages = [];
|
||
int pageCalls = 0;
|
||
final List<String?> cursors = [];
|
||
|
||
List<PickupSlot> slots = const [
|
||
PickupSlot(
|
||
id: 'slot_20260905_t1',
|
||
day: 'Today',
|
||
window: '2:00 – 4:00 PM',
|
||
available: true,
|
||
tag: 'Fastest pickup',
|
||
milersNearby: 4,
|
||
caption: 'Arriving in approx. 45 mins',
|
||
),
|
||
];
|
||
int slotCalls = 0;
|
||
|
||
/// Reverse-geocode answers, keyed by latitude, with a delay each — so a test
|
||
/// can make an earlier request land after a later one.
|
||
final Map<double, (Duration, Place)> geocodes = {};
|
||
|
||
@override
|
||
Future<BookingLimits> getBookingLimits({Place? pickup}) async {
|
||
if (limitsError != null) throw limitsError!;
|
||
return limits;
|
||
}
|
||
|
||
@override
|
||
Future<Booking> createBooking({
|
||
required Place pickup,
|
||
required List<DestinationGroup> destinations,
|
||
required String? slotId,
|
||
FareEstimate? fare,
|
||
String? contactPhone,
|
||
String? idempotencyKey,
|
||
}) async {
|
||
createKeys.add(idempotencyKey);
|
||
if (createFailures > 0) {
|
||
createFailures--;
|
||
throw createError ?? ApiException(ApiException.network, 'offline');
|
||
}
|
||
return Booking(
|
||
reference: 'DM-482913',
|
||
pickup: pickup,
|
||
destinations: destinations.map((d) => d.copy()).toList(),
|
||
slotId: slotId ?? '',
|
||
createdAt: DateTime.now(),
|
||
);
|
||
}
|
||
|
||
@override
|
||
Future<BookingPage> getBookingPage({
|
||
BookingStatus? status,
|
||
String? cursor,
|
||
int limit = 20,
|
||
}) async {
|
||
cursors.add(cursor);
|
||
if (pageCalls >= pages.length) return BookingPage.empty;
|
||
return pages[pageCalls++];
|
||
}
|
||
|
||
@override
|
||
Future<List<PickupSlot>> getPickupSlots({Place? pickup}) async {
|
||
slotCalls++;
|
||
return slots;
|
||
}
|
||
|
||
@override
|
||
Future<Place> reverseGeocode({double? lat, double? lng}) async {
|
||
final answer = geocodes[lat];
|
||
if (answer == null) {
|
||
return Place(title: 'Pin', sub: 'somewhere', lat: lat, lng: lng);
|
||
}
|
||
await Future<void>.delayed(answer.$1);
|
||
return answer.$2;
|
||
}
|
||
|
||
// ---- everything the interface needs but these tests do not exercise ------
|
||
|
||
@override
|
||
Future<OtpChallenge> sendOtp(String identifier) async =>
|
||
OtpChallenge.unspecified;
|
||
|
||
@override
|
||
Future<OtpChallenge> signUp({
|
||
required String name,
|
||
required String phone,
|
||
String? email,
|
||
}) async => OtpChallenge.unspecified;
|
||
|
||
@override
|
||
Future<Customer> verifyOtp(
|
||
String identifier,
|
||
String code, {
|
||
String? name,
|
||
}) async => Customer(
|
||
id: 'c1',
|
||
name: name ?? 'Joe',
|
||
phone: identifier,
|
||
email: '',
|
||
);
|
||
|
||
@override
|
||
Future<List<ServiceArea>> getServiceableStates() async => const [];
|
||
|
||
@override
|
||
Future<List<District>> getServiceableDistricts(String stateCode) async =>
|
||
const [];
|
||
|
||
@override
|
||
District? districtByCode(String? code) => null;
|
||
|
||
@override
|
||
PickupSlot? slotById(String? id) {
|
||
for (final slot in slots) {
|
||
if (slot.id == id) return slot;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
@override
|
||
Future<List<Place>> searchPlaces(
|
||
String query, {
|
||
double? lat,
|
||
double? lng,
|
||
}) async => const [];
|
||
|
||
@override
|
||
Future<FareEstimate> estimateFare({
|
||
required Place pickup,
|
||
required List<DestinationGroup> destinations,
|
||
}) async => const FareEstimate(min: 49, max: 64, paymentMethod: 'UPI');
|
||
|
||
@override
|
||
Future<void> cancelBooking(String reference, String? reason) async {}
|
||
|
||
@override
|
||
@override
|
||
Future<Booking?> getBooking(String reference) async => null;
|
||
|
||
@override
|
||
bool isCancellable(JourneyStage stage) =>
|
||
stage.index < JourneyStage.arrived.index;
|
||
|
||
final List<(String, JourneyStage)> stageCalls = [];
|
||
|
||
@override
|
||
Future<void> setStage(String reference, JourneyStage stage) async {
|
||
stageCalls.add((reference, stage));
|
||
}
|
||
}
|
||
|
||
Booking _booking(String reference) => Booking(
|
||
reference: reference,
|
||
pickup: const Place(title: 'Home', sub: 'Coimbatore'),
|
||
destinations: [DestinationGroup()],
|
||
slotId: 'slot_1',
|
||
createdAt: DateTime.now(),
|
||
);
|
||
|
||
AppState _state(FakeApi api) =>
|
||
AppState(api: api, location: FixedLocationService());
|
||
|
||
void main() {
|
||
group('nothing on screen is invented', () {
|
||
test('a fresh state holds no bookings until the server sends some', () {
|
||
// There is no seed, no demo data and no constructor side effect that
|
||
// could put a booking in front of a customer. The list starts empty and
|
||
// is only ever filled by a response.
|
||
late AppState state;
|
||
expect(() => state = _state(FakeApi()), returnsNormally);
|
||
expect(state.orders, isEmpty);
|
||
});
|
||
|
||
test('the orders list is the server\'s, adopted whole', () async {
|
||
final api = FakeApi()
|
||
..pages.add(BookingPage(bookings: [_booking('DM-9')]));
|
||
final state = _state(api);
|
||
await state.refreshOrders();
|
||
expect(state.orders.single.reference, 'DM-9');
|
||
|
||
// A later page that no longer contains it removes it, rather than the
|
||
// client keeping a copy the backend has forgotten.
|
||
api.pages.add(const BookingPage(bookings: []));
|
||
await state.refreshOrders();
|
||
expect(state.orders, isEmpty);
|
||
});
|
||
|
||
test('a stage move goes through the server and comes back read', () async {
|
||
final api = FakeApi()
|
||
..pages.add(BookingPage(bookings: [_booking('DM-1')]));
|
||
final state = _state(api);
|
||
await state.refreshOrders();
|
||
|
||
await state.advanceStage(state.orders.single, JourneyStage.delivered);
|
||
|
||
// The client asked; it did not apply anything to its own copy.
|
||
expect(api.stageCalls, [('DM-1', JourneyStage.delivered)]);
|
||
expect(state.orders.single.stage, JourneyStage.booked);
|
||
expect(state.orders.single.destinations.single.trackingId, isNull);
|
||
});
|
||
});
|
||
|
||
group('which backend this build believes it is', () {
|
||
// `DM_ENV` used to default to `staging` for EVERY build, release included,
|
||
// while `_stagingBase` is `_prodBase`. So a release APK built without
|
||
// `--dart-define=DM_ENV=prod` talked to production and reported
|
||
// `isProd == false` — and `isProd` is what the non-network guards read: the
|
||
// Account screen's Build panel, `allowStageOverride`, `setStage`'s refusal,
|
||
// and the environment name on every bug report.
|
||
|
||
test('an unflagged debug build is staging, not production', () {
|
||
// This test run carries no DM_ENV, and under test kReleaseMode is false.
|
||
expect(AppConfig.environment, DoormileEnvironment.staging);
|
||
expect(AppConfig.isProd, isFalse);
|
||
});
|
||
|
||
test('staging and prod resolve to the same host today', () {
|
||
// The backend has not named a staging host, so the environment decides
|
||
// what the build CALLS ITSELF, not who it talks to. Worth pinning: the
|
||
// day a real staging host appears, this test failing is the reminder that
|
||
// the default now changes where traffic goes.
|
||
expect(AppConfig.baseUrl, 'https://api.doormile.com/api/v1');
|
||
});
|
||
|
||
test('the environment is named on every build description', () {
|
||
// What a bug report carries. A production session that called itself
|
||
// "staging" is how a real incident gets triaged against the wrong server.
|
||
expect(AppConfig.describe, contains(AppConfig.environment.name));
|
||
});
|
||
|
||
// NOT ASSERTABLE FROM HERE, and stated rather than faked: the release
|
||
// branch of `environment` is `kReleaseMode ? prod : staging`, and
|
||
// kReleaseMode is a compile-time const that is false under `flutter test`.
|
||
// There is no way to exercise the release arm from a debug test run, so
|
||
// this note is the coverage. Verify it with:
|
||
//
|
||
// flutter build apk --release → Account shows no Build panel
|
||
// flutter build apk --release --dart-define=DM_ENV=staging
|
||
// → Build panel returns
|
||
});
|
||
|
||
group('development access', () {
|
||
test('no dev token is compiled into this build', () {
|
||
// A pasted token is not present unless a define supplies one.
|
||
expect(AppConfig.hasDevToken, isFalse);
|
||
expect(AppConfig.describe, isNot(contains('DEV TOKEN')));
|
||
});
|
||
|
||
test('an offline build says so, every time', () {
|
||
// The offline build is back, and this is the guarantee that makes it
|
||
// safe to have. It was deleted once because a build answering from the
|
||
// fake looked exactly like a build answering from the server — which
|
||
// cost two rounds of hunting for bookings in the admin console that had
|
||
// never left the phone.
|
||
//
|
||
// A test run IS an offline build, so this asserts the announcement in
|
||
// the one place it can be asserted at all.
|
||
expect(AppConfig.useDevData, isTrue);
|
||
expect(AppConfig.describe, contains('DEV DATA (offline)'));
|
||
expect(
|
||
AppConfig.describe,
|
||
isNot(contains(AppConfig.baseUrl)),
|
||
reason: 'naming a host a build never contacts is the confusion this '
|
||
'whole guard exists to prevent',
|
||
);
|
||
});
|
||
|
||
test('no dev token and no auto-login are compiled in', () {
|
||
// The two ways to skip the login SCREEN without faking the login. Both
|
||
// hold a server-issued token; neither define is passed to this run.
|
||
expect(AppConfig.hasAutoLogin, isFalse);
|
||
expect(AppConfig.hasDevToken, isFalse);
|
||
});
|
||
|
||
test('the auto-login flag can never fire under test', () {
|
||
// The widget tests drive the real entrance — phone, code, and the guard
|
||
// that refuses to open the code screen when no code was sent. A session
|
||
// waiting for them at launch would delete that coverage and nothing
|
||
// would fail, so the flag is inert under FLUTTER_TEST whatever the
|
||
// defines say.
|
||
expect(AppConfig.isTest, isTrue);
|
||
expect(AppConfig.hasAutoLogin, isFalse);
|
||
});
|
||
|
||
// The guarantee that matters cannot be asserted from a debug test — every
|
||
// switch is `!kReleaseMode`, so `autoLoginIdentifier`, `hasDevToken` and
|
||
// `allowStageOverride` are all forced empty/false in a release build,
|
||
// whatever the defines say. That is what keeps a bypass out of the store.
|
||
});
|
||
|
||
group('booking limits', () {
|
||
test('a failed limits call falls back to one destination, not five', () async {
|
||
final api = FakeApi()..limitsError = ApiException('network', 'offline');
|
||
final state = _state(api);
|
||
await Future<void>.delayed(Duration.zero);
|
||
|
||
// Multi-destination is undeliverable until the Miler build lands. The
|
||
// permissive default is the one that strands a parcel.
|
||
expect(state.limits.maxDestinations, 1);
|
||
expect(state.allowsMultipleDestinations, isFalse);
|
||
expect(state.addDestination(), isFalse);
|
||
});
|
||
|
||
test('the served cap is authoritative when it arrives', () async {
|
||
final state = _state(FakeApi());
|
||
await state.loadLimits();
|
||
|
||
expect(state.limits.maxDestinations, 5);
|
||
expect(state.allowsMultipleDestinations, isTrue);
|
||
});
|
||
|
||
test('a zero or missing cap resolves to one, never to none', () {
|
||
expect(BookingLimits.fromJson(const {}).maxDestinations, 1);
|
||
expect(BookingLimits.fromJson(const {}).maxPackages, 20);
|
||
expect(
|
||
BookingLimits.fromJson(const {'maxDestinations': 0, 'maxPackages': 0})
|
||
.maxDestinations,
|
||
1,
|
||
);
|
||
expect(
|
||
BookingLimits.fromJson(const {'maxPackages': 0}).maxPackages,
|
||
1,
|
||
);
|
||
});
|
||
});
|
||
|
||
group('pickup slots', () {
|
||
test('starting a booking drops yesterday\'s slots', () async {
|
||
final api = FakeApi();
|
||
final state = _state(api);
|
||
|
||
await state.loadSlots();
|
||
await state.loadSlots();
|
||
expect(api.slotCalls, 1, reason: 'cached within one booking');
|
||
|
||
final epoch = state.slotsEpoch;
|
||
state.startBooking();
|
||
await state.loadSlots();
|
||
|
||
expect(api.slotCalls, 2);
|
||
expect(state.slotsEpoch, greaterThan(epoch));
|
||
});
|
||
|
||
test('a refused slot clears the cache and the choice', () async {
|
||
final api = FakeApi()
|
||
..createFailures = 1
|
||
..createError = ApiException(
|
||
ApiException.invalid,
|
||
'That pickup time has passed — pick a new slot',
|
||
);
|
||
final state = _state(api);
|
||
state.startBooking();
|
||
state.pickup = const Place(title: 'Home', sub: 'CBE', lat: 11, lng: 76);
|
||
state.draftPickup = state.pickup;
|
||
await state.loadSlots();
|
||
state.selectSlot(api.slots.first);
|
||
|
||
await expectLater(state.confirmBooking(), throwsA(isA<ApiException>()));
|
||
|
||
expect(state.draftSlotId, isNull);
|
||
expect(state.slotsCache, isNull);
|
||
});
|
||
|
||
test('the parser keeps every field the picker renders', () {
|
||
final slot = PickupSlot.fromJson(const {
|
||
'id': 'slot_20260905_t1',
|
||
'day': 'Today',
|
||
'window': '2:00 – 4:00 PM',
|
||
'available': true,
|
||
'tag': 'Fastest pickup',
|
||
'milersNearby': 4,
|
||
'caption': 'Arriving in approx. 45 mins',
|
||
'note': null,
|
||
});
|
||
|
||
expect(slot.tag, 'Fastest pickup');
|
||
expect(slot.milersNearby, 4);
|
||
expect(slot.caption, 'Arriving in approx. 45 mins');
|
||
expect(slot.label, 'Today · 2:00 – 4:00 PM');
|
||
});
|
||
});
|
||
|
||
group('idempotency', () {
|
||
test('a retried booking reuses its key; a new booking mints one', () async {
|
||
final api = FakeApi()..createFailures = 1;
|
||
final state = _state(api);
|
||
state.startBooking();
|
||
state.pickup = const Place(title: 'Home', sub: 'CBE', lat: 11, lng: 76);
|
||
state.draftPickup = state.pickup;
|
||
state.selectSlot(api.slots.first);
|
||
|
||
await expectLater(state.confirmBooking(), throwsA(isA<ApiException>()));
|
||
await state.confirmBooking();
|
||
|
||
expect(api.createKeys.length, 2);
|
||
expect(api.createKeys[0], isNotNull);
|
||
// Same intent, same key — the server replays rather than booking twice.
|
||
expect(api.createKeys[0], api.createKeys[1]);
|
||
|
||
state.startBooking();
|
||
state.draftPickup = state.pickup;
|
||
state.selectSlot(api.slots.first);
|
||
await state.confirmBooking();
|
||
|
||
expect(api.createKeys[2], isNot(api.createKeys[1]));
|
||
});
|
||
});
|
||
|
||
group('keyset pagination', () {
|
||
test('appends pages and follows the cursor', () async {
|
||
final api = FakeApi()
|
||
..pages.addAll([
|
||
BookingPage(bookings: [_booking('DM-1'), _booking('DM-2')], nextCursor: 'c2'),
|
||
BookingPage(bookings: [_booking('DM-3')], nextCursor: null),
|
||
]);
|
||
final state = _state(api);
|
||
|
||
await state.refreshOrders();
|
||
expect(state.orders.map((o) => o.reference), ['DM-1', 'DM-2']);
|
||
expect(state.hasMoreOrders, isTrue);
|
||
|
||
await state.loadMoreOrders();
|
||
expect(state.orders.map((o) => o.reference), ['DM-1', 'DM-2', 'DM-3']);
|
||
expect(state.hasMoreOrders, isFalse);
|
||
expect(api.cursors, [null, 'c2']);
|
||
});
|
||
|
||
test('a row that arrives on two pages is shown once', () async {
|
||
final api = FakeApi()
|
||
..pages.addAll([
|
||
BookingPage(bookings: [_booking('DM-1')], nextCursor: 'c2'),
|
||
BookingPage(bookings: [_booking('DM-1'), _booking('DM-2')]),
|
||
]);
|
||
final state = _state(api);
|
||
|
||
await state.refreshOrders();
|
||
await state.loadMoreOrders();
|
||
|
||
expect(state.orders.map((o) => o.reference), ['DM-1', 'DM-2']);
|
||
});
|
||
|
||
test('a failed page keeps the cursor so the next scroll retries it', () async {
|
||
final api = FakeApi()
|
||
..pages.add(BookingPage(bookings: [_booking('DM-1')], nextCursor: 'c2'));
|
||
final state = _state(api);
|
||
await state.refreshOrders();
|
||
|
||
// The fake runs out of pages and returns an empty one; the cursor then
|
||
// clears normally. What matters is that nothing throws at the UI.
|
||
await state.loadMoreOrders();
|
||
expect(state.loadingMoreOrders, isFalse);
|
||
});
|
||
});
|
||
|
||
group('pickup point', () {
|
||
test('the latest reverse-geocode wins, however the earlier one lands', () async {
|
||
final api = FakeApi()
|
||
..geocodes[11.0] = (
|
||
const Duration(milliseconds: 80),
|
||
const Place(title: 'First drag', sub: 'stale', lat: 11.0, lng: 76.0),
|
||
)
|
||
..geocodes[12.0] = (
|
||
const Duration(milliseconds: 10),
|
||
const Place(title: 'Second drag', sub: 'fresh', lat: 12.0, lng: 77.0),
|
||
);
|
||
final state = _state(api);
|
||
|
||
final first = state.setPickupFromPin(11.0, 76.0);
|
||
final second = state.setPickupFromPin(12.0, 77.0);
|
||
await Future.wait([first, second]);
|
||
|
||
expect(state.pickup!.title, 'Second drag');
|
||
expect(state.resolvingPickup, isFalse);
|
||
});
|
||
|
||
test('a refused permission still leaves a usable pickup', () async {
|
||
final state = AppState(
|
||
api: FakeApi(),
|
||
location: FixedLocationService.denied(LocationDenial.deniedForever),
|
||
);
|
||
|
||
await state.detectPickupLocation();
|
||
expect(state.locationDenial, LocationDenial.deniedForever);
|
||
// Falls back to the service-area centre rather than stranding the form,
|
||
// and search still overrides it.
|
||
expect(state.pickup, isNotNull);
|
||
|
||
state.setPickup(
|
||
const Place(title: 'Brookefields Mall', sub: 'CBE', lat: 10.99, lng: 76.96),
|
||
);
|
||
expect(state.pickup!.title, 'Brookefields Mall');
|
||
expect(state.hasPickupPoint, isTrue);
|
||
expect(state.locationDenial, isNull);
|
||
});
|
||
});
|
||
|
||
group('booking parser', () {
|
||
final json = <String, dynamic>{
|
||
'reference': 'DM-482913',
|
||
'stage': 'in_transit',
|
||
'status': 'active',
|
||
'cancellable': false,
|
||
'createdAt': 1757056800000,
|
||
'pickup': {
|
||
'title': '12 Nehru Street',
|
||
'sub': 'Gandhipuram, Coimbatore 641012',
|
||
'lat': 11.0183,
|
||
'lng': 76.9725,
|
||
},
|
||
'slotId': 'slot_t_1',
|
||
'destinations': [
|
||
{
|
||
'stateCode': 'TN',
|
||
'stateName': 'Tamil Nadu',
|
||
'districtCode': 'TN-MAA',
|
||
'districtName': 'Chennai',
|
||
'packageCount': 2,
|
||
'district': {
|
||
'code': 'TN-MAA',
|
||
'name': 'Chennai',
|
||
'available': true,
|
||
'hub': 'Chennai Guindy Hub',
|
||
'promise': 'Next-day delivery',
|
||
'lat': 13.08,
|
||
'lng': 80.27,
|
||
},
|
||
'details': {
|
||
'street': '12th Main',
|
||
'recipientName': 'Meera S',
|
||
'recipientPhone': '+919884412210',
|
||
},
|
||
'trackingId': 'DMX10482913',
|
||
'stage': 'in_transit',
|
||
'verification': {
|
||
'weightKg': 2.8,
|
||
'photos': ['https://cdn.doormile.com/pv/abc.jpg'],
|
||
'capturedAt': 1757060400000,
|
||
'capturedBy': 'Arun Kumar',
|
||
},
|
||
},
|
||
],
|
||
'miler': {
|
||
'name': 'Arun Kumar',
|
||
'vehicle': 'TN 37 BX 4412',
|
||
'phone': '+919000011223',
|
||
'rating': 4.9,
|
||
'trips': 1240,
|
||
'vehicleType': 'E-Scooter',
|
||
},
|
||
'deliveryAgent': null,
|
||
'milerDistanceKm': null,
|
||
'milerEtaMinutes': null,
|
||
'milersInZone': 3,
|
||
'routeKm': 6.4,
|
||
'expectedDelivery': 'Thu, 12 Sep',
|
||
'fare': {
|
||
'min': 49,
|
||
'max': 64,
|
||
'paymentMethod': 'UPI · Cash at doorstep',
|
||
'parcel': 'Standard box (up to 3 kg)',
|
||
},
|
||
'amountPaid': 64,
|
||
'deliveredAt': null,
|
||
'cancelReason': null,
|
||
'history': [
|
||
{'stage': 'booked', 'at': 1757056800000},
|
||
{'stage': 'assigned', 'at': 1757057400000},
|
||
{'stage': 'in_transit', 'at': 1757062200000},
|
||
],
|
||
};
|
||
|
||
test('reads the whole canonical object, not a subset', () {
|
||
final booking = Booking.fromJson(json);
|
||
|
||
expect(booking.reference, 'DM-482913');
|
||
expect(booking.stage, JourneyStage.inTransit);
|
||
expect(booking.status, BookingStatus.active);
|
||
expect(booking.cancellable, isFalse);
|
||
expect(booking.createdAt.millisecondsSinceEpoch, 1757056800000);
|
||
expect(booking.pickup.title, '12 Nehru Street');
|
||
expect(booking.slotId, 'slot_t_1');
|
||
expect(booking.routeKm, 6.4);
|
||
expect(booking.milersInZone, 3);
|
||
expect(booking.expectedDelivery, 'Thu, 12 Sep');
|
||
expect(booking.amountPaid, 64);
|
||
expect(booking.deliveredAt, isNull);
|
||
expect(booking.miler!.name, 'Arun Kumar');
|
||
expect(booking.miler!.trips, 1240);
|
||
expect(booking.deliveryAgent, isNull);
|
||
expect(booking.fare!.range, '₹49 – ₹64');
|
||
expect(booking.history.length, 3);
|
||
expect(booking.history.first.stage, JourneyStage.booked);
|
||
expect(booking.timeOf(JourneyStage.assigned), isNotNull);
|
||
|
||
final group = booking.destinations.single;
|
||
expect(group.destination.label, 'Chennai, Tamil Nadu');
|
||
expect(group.packageCount, 2);
|
||
expect(group.trackingId, 'DMX10482913');
|
||
expect(group.stage, JourneyStage.inTransit);
|
||
expect(group.details.recipientName, 'Meera S');
|
||
expect(group.district!.hub, 'Chennai Guindy Hub');
|
||
expect(group.hub, isNotNull, reason: 'district coordinates draw the route');
|
||
expect(group.verification!.weightLabel, '2.8 kg');
|
||
expect(group.verification!.photos.single, endsWith('abc.jpg'));
|
||
});
|
||
|
||
test('before order_created there is no order to track', () {
|
||
final pending = Map<String, dynamic>.from(json)
|
||
..['stage'] = 'assigned'
|
||
..['amountPaid'] = null
|
||
..['destinations'] = [
|
||
{
|
||
'stateCode': 'TN',
|
||
'stateName': 'Tamil Nadu',
|
||
'districtCode': 'TN-MAA',
|
||
'districtName': 'Chennai',
|
||
'packageCount': 1,
|
||
},
|
||
];
|
||
|
||
final booking = Booking.fromJson(pending);
|
||
final group = booking.destinations.single;
|
||
|
||
expect(group.trackingId, isNull);
|
||
expect(group.stage, isNull);
|
||
expect(group.verification, isNull);
|
||
expect(booking.amountPaid, isNull);
|
||
expect(booking.hasOrders, isFalse);
|
||
});
|
||
|
||
test('an unknown stage key falls back to booked', () {
|
||
final unknown = Map<String, dynamic>.from(json)
|
||
..['stage'] = 'delivery_failed';
|
||
expect(Booking.fromJson(unknown).stage, JourneyStage.booked);
|
||
expect(JourneyStage.fromKey(null), JourneyStage.booked);
|
||
expect(JourneyStage.fromKey('delivered'), JourneyStage.delivered);
|
||
});
|
||
|
||
test('a cancelled booking parses even when the server thins it out', () {
|
||
final booking = Booking.fromJson(const {
|
||
'reference': 'DM-449220',
|
||
'status': 'cancelled',
|
||
'stage': 'booked',
|
||
'cancelReason': 'Package not ready',
|
||
});
|
||
|
||
expect(booking.status, BookingStatus.cancelled);
|
||
expect(booking.isCancelled, isTrue);
|
||
expect(booking.cancelReason, 'Package not ready');
|
||
expect(booking.destinations, hasLength(1));
|
||
expect(booking.slotId, '');
|
||
});
|
||
|
||
test('timestamps are epoch millis, and nothing ambiguous is guessed', () {
|
||
expect(
|
||
epochMillis(1757056800000)!.millisecondsSinceEpoch,
|
||
1757056800000,
|
||
);
|
||
expect(epochMillis('1757056800000')!.millisecondsSinceEpoch, 1757056800000);
|
||
expect(epochMillis('2026-09-05T14:30:00+05:30'), isNotNull);
|
||
expect(epochMillis(null), isNull);
|
||
// A naive local string is ambiguous by 5h30m here. Refusing it is how the
|
||
// tracking screen avoids dating a parcel to yesterday.
|
||
expect(epochMillis('2026-09-05 14:30:00'), isNull);
|
||
});
|
||
|
||
test('a destination round-trips into the create request', () {
|
||
final group = DestinationGroup(
|
||
destination: Destination(stateCode: 'TN', districtCode: 'TN-MAA'),
|
||
packageCount: 3,
|
||
);
|
||
expect(group.toBookingJson(), {
|
||
'stateCode': 'TN',
|
||
'districtCode': 'TN-MAA',
|
||
'packageCount': 3,
|
||
});
|
||
|
||
// The contract carries the recipient flat on the destination, beside the
|
||
// codes, and spells a dropped pin `latitude`/`longitude`.
|
||
group.details.update(recipientName: 'Meera S', building: '12/A');
|
||
group.details.pin = const MapPin(13.085, 80.21);
|
||
expect(group.toBookingJson(), {
|
||
'stateCode': 'TN',
|
||
'districtCode': 'TN-MAA',
|
||
'packageCount': 3,
|
||
'recipientName': 'Meera S',
|
||
'building': '12/A',
|
||
'latitude': 13.085,
|
||
'longitude': 80.21,
|
||
});
|
||
});
|
||
|
||
test('a PATCH sends nulls, because null is how a field is cleared', () {
|
||
final details = DeliveryDetails(street: '12th Main');
|
||
final patch = details.toPatchJson();
|
||
expect(patch['street'], '12th Main');
|
||
expect(patch.containsKey('landmark'), isTrue);
|
||
expect(patch['landmark'], isNull);
|
||
expect(patch.containsKey('latitude'), isTrue);
|
||
});
|
||
});
|
||
}
|