Replace the customer app with Doormile CX

Book a pickup, track it to delivery — the rebuilt customer app.

- Design language from doormile-screens.html: brand #8F0F06, Manrope +
  Geist Mono (variable fonts), bordered cards instead of shadows, crimson
  brand headers, sliding tab indicator, mono for anything read digit by digit.
- lib/data (one live API implementation, plus a debug-only offline fake),
  lib/state, lib/ui (tokens, widgets, screens).
- 84 tests, plus a design snapshot harness that renders every screen with the
  real fonts: flutter test test/design_snapshot_test.dart --run-skipped
  --update-goldens

This replaces the previous app (pubspec 'doormile', app id
com.doormile.customer). That tree remains in history at 6c7d656; note its
android/app/google-services.json is not carried over, and the application id
here is in.doormile.customer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-15 16:07:33 +05:30
parent 6c7d656de5
commit 0d66627c3c
305 changed files with 21930 additions and 11056 deletions

432
test/api_client_test.dart Normal file
View File

@@ -0,0 +1,432 @@
import 'dart:convert';
import 'package:doormile_cx/data/api_client.dart';
import 'package:doormile_cx/data/api_exception.dart';
import 'package:doormile_cx/data/session_store.dart';
import 'package:doormile_cx/data/models.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
/// Records what the client actually put on the wire.
class _Recorder {
final List<http.BaseRequest> requests = [];
}
MockClient _client(
_Recorder log,
http.Response Function(http.Request request, int callIndex) respond,
) {
var calls = 0;
return MockClient((request) async {
log.requests.add(request);
return respond(request, calls++);
});
}
http.Response _ok(Object data, {Map<String, String>? headers}) => http.Response(
jsonEncode({'success': true, 'data': data, 'message': ''}),
200,
headers: {'content-type': 'application/json', ...?headers},
);
http.Response _fail(int status, String code, String message) => http.Response(
jsonEncode({
'success': false,
'message': message,
'error': {'code': code},
}),
status,
headers: {'content-type': 'application/json', 'x-request-id': 'req_42'},
);
Session _session({
String access = 'access-1',
String refresh = 'refresh-1',
Duration life = const Duration(hours: 1),
}) => Session(
accessToken: access,
refreshToken: refresh,
expiresAt: DateTime.now().add(life),
);
void main() {
group('envelope and headers', () {
test('unwraps data and sends the client identity on every request', () async {
final log = _Recorder();
final client = ApiClient(
httpClient: _client(log, (_, _) => _ok({'ok': true})),
sessions: MemorySessionStore(),
);
final response = await client.get('/config/booking-limits');
expect(response.map['ok'], isTrue);
final sent = log.requests.single;
expect(sent.headers['X-Client'], startsWith('doormile-cx/'));
expect(sent.headers['X-Platform'], isNotEmpty);
expect(sent.url.path, endsWith('/customer/config/booking-limits'));
});
test('sends the bearer token when there is a session', () async {
final log = _Recorder();
final sessions = MemorySessionStore();
await sessions.write(_session());
final client = ApiClient(
httpClient: _client(log, (_, _) => _ok(const {})),
sessions: sessions,
);
await client.get('/auth/me');
expect(log.requests.single.headers['Authorization'], 'Bearer access-1');
});
test('carries the idempotency key given to it', () async {
final log = _Recorder();
final client = ApiClient(
httpClient: _client(log, (_, _) => _ok(const {})),
sessions: MemorySessionStore(),
);
await client.post('/bookings', body: const {}, idempotencyKey: 'key-1');
expect(log.requests.single.headers['Idempotency-Key'], 'key-1');
});
test('a fresh idempotency key is never the same twice', () {
final client = ApiClient(
httpClient: _client(_Recorder(), (_, _) => _ok(const {})),
sessions: MemorySessionStore(),
);
final keys = List.generate(50, (_) => client.newIdempotencyKey());
expect(keys.toSet().length, 50);
});
});
group('error mapping', () {
test('maps every contract status onto its code', () async {
const cases = {
400: ApiException.invalid,
401: ApiException.unauthorized,
403: ApiException.forbidden,
404: ApiException.notFound,
409: ApiException.conflict,
422: ApiException.unserviceable,
429: ApiException.rateLimited,
500: ApiException.serverError,
};
for (final entry in cases.entries) {
final client = ApiClient(
httpClient: MockClient(
(_) async => http.Response(
jsonEncode({'success': false, 'message': 'nope'}),
entry.key,
),
),
sessions: MemorySessionStore(),
);
await expectLater(
// 401 without a session must not be retried as a refresh.
client.get('/bookings', authenticated: false),
throwsA(
isA<ApiException>().having((e) => e.code, 'code', entry.value),
),
);
}
});
test("the server's own code wins over the status default", () async {
final client = ApiClient(
httpClient: MockClient(
(_) async => _fail(400, ApiException.invalidName, 'Enter your full name'),
),
sessions: MemorySessionStore(),
);
await expectLater(
client.post('/auth/signup', body: const {}, authenticated: false),
throwsA(
isA<ApiException>()
.having((e) => e.code, 'code', ApiException.invalidName)
.having((e) => e.message, 'message', 'Enter your full name')
.having((e) => e.requestId, 'requestId', 'req_42'),
),
);
});
test('an HTML error page is a network failure, not a parse crash', () async {
final client = ApiClient(
httpClient: MockClient(
(_) async => http.Response('<html>502 Bad Gateway</html>', 502),
),
sessions: MemorySessionStore(),
);
await expectLater(
client.get('/bookings', authenticated: false),
throwsA(
isA<ApiException>().having(
(e) => e.code,
'code',
ApiException.serverError,
),
),
);
});
test('a stale slot is recognised however the server phrases it', () {
expect(
ApiException(ApiException.conflict, 'That pickup window just filled up')
.needsFreshSlots,
isTrue,
);
expect(
ApiException(
ApiException.invalid,
'That pickup time has passed — pick a new slot',
).needsFreshSlots,
isTrue,
);
expect(
ApiException(ApiException.invalid, 'Enter your full name')
.needsFreshSlots,
isFalse,
);
});
});
group('retries', () {
test('retries a GET through a transient failure', () async {
final log = _Recorder();
final client = ApiClient(
httpClient: _client(
log,
(_, call) =>
call < 2 ? http.Response('', 503) : _ok(const {'ok': true}),
),
sessions: MemorySessionStore(),
);
final response = await client.get('/serviceability/states');
expect(response.map['ok'], isTrue);
expect(log.requests.length, 3);
});
test('never replays a POST — a second booking is worse than an error', () async {
final log = _Recorder();
final client = ApiClient(
httpClient: _client(log, (_, _) => http.Response('', 503)),
sessions: MemorySessionStore(),
);
await expectLater(
client.post('/bookings', body: const {}),
throwsA(isA<ApiException>()),
);
expect(log.requests.length, 1);
});
});
group('ETag', () {
test('replays the tag and reports a 304 rather than empty data', () async {
final log = _Recorder();
final client = ApiClient(
httpClient: _client(
log,
(_, call) => call == 0
? _ok(const [], headers: {'etag': 'W/"v1"'})
: http.Response('', 304, headers: {'etag': 'W/"v1"'}),
),
sessions: MemorySessionStore(),
);
final first = await client.get('/serviceability/states');
expect(first.etag, 'W/"v1"');
expect(first.notModified, isFalse);
final second = await client.get(
'/serviceability/states',
ifNoneMatch: first.etag,
);
expect(second.notModified, isTrue);
expect(log.requests.last.headers['If-None-Match'], 'W/"v1"');
});
});
group('keyset pagination', () {
test('carries the cursor out of the envelope', () async {
final client = ApiClient(
httpClient: MockClient(
(_) async => http.Response(
jsonEncode({
'success': true,
'data': [
{'reference': 'DM-1'},
],
'total': 42,
'nextCursor': 'cur_2',
}),
200,
),
),
sessions: MemorySessionStore(),
);
final page = await client.get('/bookings');
expect(page.rows.single['reference'], 'DM-1');
expect(page.total, 42);
expect(page.nextCursor, 'cur_2');
});
});
group('session', () {
test('refreshes on 401, persists the rotated token, retries once', () async {
final log = _Recorder();
final sessions = MemorySessionStore();
await sessions.write(_session());
final client = ApiClient(
httpClient: _client(log, (request, _) {
if (request.url.path.endsWith('/auth/refresh')) {
return _ok(const {
'accessToken': 'access-2',
'refreshToken': 'refresh-2',
'expiresIn': 3600,
});
}
final auth = request.headers['Authorization'];
return auth == 'Bearer access-2'
? _ok(const {'ok': true})
: _fail(401, ApiException.unauthorized, 'Please sign in again');
}),
sessions: sessions,
);
final response = await client.get('/bookings');
expect(response.map['ok'], isTrue);
// The rotated pair is the only one that still works, so it must be the
// one on disk before anything else uses it.
final stored = await sessions.read();
expect(stored!.accessToken, 'access-2');
expect(stored.refreshToken, 'refresh-2');
});
test('an expired access token refreshes before the call, not after', () async {
final log = _Recorder();
final sessions = MemorySessionStore();
await sessions.write(_session(life: const Duration(seconds: 5)));
final client = ApiClient(
httpClient: _client(log, (request, _) {
if (request.url.path.endsWith('/auth/refresh')) {
return _ok(const {
'accessToken': 'access-2',
'refreshToken': 'refresh-2',
'expiresIn': 3600,
});
}
return _ok(const {'ok': true});
}),
sessions: sessions,
);
await client.get('/bookings');
// Refresh first, then the real call — and no 401 spent discovering it.
expect(log.requests.first.url.path, endsWith('/auth/refresh'));
expect(log.requests.last.headers['Authorization'], 'Bearer access-2');
});
test('a dead refresh chain clears the session and reports it once', () async {
var lost = 0;
final sessions = MemorySessionStore();
await sessions.write(_session());
final client = ApiClient(
httpClient: MockClient(
(_) async => _fail(401, ApiException.unauthorized, 'Please sign in again'),
),
sessions: sessions,
)..onSessionLost = () => lost++;
await expectLater(
client.get('/bookings'),
throwsA(isA<ApiException>().having((e) => e.isAuthFailure, 'auth', isTrue)),
);
expect(await sessions.read(), isNull);
expect(lost, 1);
});
test('concurrent 401s produce one refresh, not one each', () async {
final log = _Recorder();
final sessions = MemorySessionStore();
await sessions.write(_session());
final client = ApiClient(
httpClient: _client(log, (request, _) {
if (request.url.path.endsWith('/auth/refresh')) {
return _ok(const {
'accessToken': 'access-2',
'refreshToken': 'refresh-2',
'expiresIn': 3600,
});
}
return request.headers['Authorization'] == 'Bearer access-2'
? _ok(const {'ok': true})
: _fail(401, ApiException.unauthorized, 'Please sign in again');
}),
sessions: sessions,
);
await Future.wait([
client.get('/bookings'),
client.get('/auth/me'),
client.get('/pickup-slots'),
]);
final refreshes = log.requests
.where((r) => r.url.path.endsWith('/auth/refresh'))
.length;
// A rotated refresh token is single-use: a second call would replay a
// token the server has already revoked and kill the whole chain.
expect(refreshes, 1);
});
test('a session survives a round trip through the store', () async {
final store = MemorySessionStore();
await store.write(
Session(
accessToken: 'a',
refreshToken: 'r',
expiresAt: DateTime.fromMillisecondsSinceEpoch(1757056800000),
customer: const Customer(
id: 'cust_1',
name: 'Joe Oommen',
phone: '+919876543210',
email: '',
),
),
);
final restored = Session.fromJson(
jsonDecode(jsonEncode((await store.read())!.toJson()))
as Map<String, dynamic>,
);
expect(restored!.accessToken, 'a');
expect(restored.refreshToken, 'r');
expect(restored.customer!.name, 'Joe Oommen');
});
test('an expiring session is treated as expired a minute early', () {
expect(_session(life: const Duration(seconds: 30)).isExpired, isTrue);
expect(_session(life: const Duration(minutes: 30)).isExpired, isFalse);
});
});
}

View File

@@ -0,0 +1,673 @@
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? 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('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('the test environment is a bypass, and says so out loud', () {
// The Miler app shipped a flag that swapped authentication for a fake
// rider on any four digits, and said nothing on screen. The rule that
// replaces it: a build that did not sign in must name it. Under
// FLUTTER_TEST the dev data is on, so it must read as bypassed and the
// environment line must carry the DEV DATA marker.
expect(AppConfig.useDevData, isTrue, reason: 'dev data is on under test');
expect(AppConfig.authBypassed, isTrue);
expect(AppConfig.describe, contains('DEV DATA'));
});
test('the auto-login default never reaches the tests', () {
// Dev data now defaults on in a debug build, and with it the login
// screen is skipped — one `flutter run` lands on Home, because staging
// has no SMS gateway to send a code through.
//
// The widget tests must keep walking that entrance anyway: they cover the
// phone field, the code screen, and the guard that refuses to open the
// code screen when nothing was sent. A session waiting for them at launch
// would delete all of it and nothing would fail, so [devAutoLogin] is
// switched off under FLUTTER_TEST on purpose. This is that promise.
expect(AppConfig.devAutoLogin, isFalse);
});
// The guarantee that matters cannot be asserted from a debug test — every
// switch is `!kReleaseMode`, so `useDevData`, `devAutoLogin`, `hasDevToken`
// and `allowStageOverride` are all forced 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);
});
});
}

799
test/booking_flow_test.dart Normal file
View File

@@ -0,0 +1,799 @@
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<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));
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<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 ▸ BOOK ▸ confirm the pin ▸ destination step.
Future<void> 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<void> 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<void> 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<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('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<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 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<DmButton>(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);
});
}

View File

@@ -0,0 +1,170 @@
@Tags(['snapshot'])
library;
import 'package:doormile_cx/data/dev_doormile_api.dart';
import 'package:doormile_cx/data/doormile_api.dart';
import 'package:doormile_cx/data/location_service.dart';
import 'package:doormile_cx/main.dart';
import 'package:doormile_cx/state/app_state.dart';
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
/// Renders each screen at phone size and writes it to `test/snapshots/`.
///
/// This is a **design** check, not a regression check. A deliberate redesign
/// would fail every one of these, which is worthless as a test signal, so the
/// `snapshot` tag is skipped in a normal run (see `dart_test.yaml`) and the
/// pictures are rendered on demand:
///
/// flutter test test/design_snapshot_test.dart --run-skipped --update-goldens
///
/// It exists because the only honest way to review a visual rebuild is to look
/// at it, and this produces the same pictures on any machine, with the real
/// fonts, without a device attached.
///
/// The real fonts are loaded first — without that, Flutter's test harness draws
/// everything in Ahem's black boxes and the pictures are worthless.
Future<void> _loadFonts() async {
for (final (family, asset) in const [
('Manrope', 'assets/fonts/Manrope-VF.ttf'),
('GeistMono', 'assets/fonts/GeistMono-VF.ttf'),
// The icon font too, or every glyph in the picture is a hollow box.
('packages/lucide_icons_flutter/Lucide',
'packages/lucide_icons_flutter/assets/lucide.ttf'),
]) {
final loader = FontLoader(family)..addFont(rootBundle.load(asset));
await loader.load();
}
}
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));
}
Future<void> _shot(WidgetTester tester, String name) async {
await expectLater(
find.byType(MaterialApp),
matchesGoldenFile('snapshots/$name.png'),
);
}
void main() {
late DevDoormileApi api;
setUp(() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
const MethodChannel('plugins.flutter.io/path_provider'),
(call) async => '/tmp',
);
api = DevDoormileApi();
DoormileApi.overrideInstance(api);
LocationService.instance = FixedLocationService();
});
tearDown(() => DoormileApi.overrideInstance(null));
testWidgets('screens', (tester) async {
await _loadFonts();
// One phone, portrait: 390x844 at 3x, the frame the design was drawn in.
tester.view.physicalSize = const Size(1170, 2532);
tester.view.devicePixelRatio = 3.0;
addTearDown(tester.view.reset);
final state = AppState();
await tester.pumpWidget(DoormileApp(initialState: state));
await _settle(tester);
// Image.asset resolves off the frame loop, so in a test it never paints
// unless it is decoded first — without this the wordmark is a blank gap in
// every picture of the entrance.
await tester.runAsync(() async {
await precacheImage(
const AssetImage('assets/images/doormile-logo.png'),
tester.element(find.byType(MaterialApp)),
);
});
await _settle(tester);
await _shot(tester, '01-sign-in');
await tester.tap(find.text('Create account'));
await _settle(tester);
await _shot(tester, '01b-sign-up');
await tester.tap(find.byIcon(LucideIcons.arrowLeft).first);
await _settle(tester);
await tester.enterText(find.byType(TextField).first, '9876543210');
await _settle(tester, 200);
await tester.tap(find.text('Continue'));
await _settle(tester);
await _shot(tester, '02-verify');
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);
await _shot(tester, '03-home');
await tester.tap(find.text('Orders'));
await _settle(tester);
await _shot(tester, '04-orders');
await tester.tap(find.text('Account'));
await _settle(tester);
await _shot(tester, '05-account');
await tester.tap(find.text('Home'));
await _settle(tester);
await tester.tap(find.text('Miler assigned'));
await _settle(tester);
await _shot(tester, '06-tracking');
await tester.tap(find.byIcon(LucideIcons.arrowLeft).first);
await _settle(tester);
// Booking flow.
await tester.tap(find.text('Start booking'));
await _settle(tester);
await _shot(tester, '07-pickup-point');
await tester.tap(find.text('Next'));
await _settle(tester);
await tester.tap(find.text('Tamil Nadu'));
await _settle(tester);
await tester.tap(find.text('Chennai'));
await _settle(tester);
await _shot(tester, '08-destination');
await tester.tap(find.text('Next'));
await _settle(tester);
await tester.tap(find.text('2:00 – 4:00 PM').first);
await _settle(tester);
await _shot(tester, '09-slot');
await tester.tap(find.text('Review booking'));
await _settle(tester);
await _shot(tester, '10-review');
await tester.tap(find.text('Book pickup'));
await _settle(tester);
await _shot(tester, '11-booked');
// A finished order, for the receipt treatment.
await tester.tap(find.text('Back to home'));
await _settle(tester);
await tester.tap(find.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);
await _shot(tester, '12-receipt');
await tester.pump(const Duration(seconds: 3));
});
}

View File

@@ -1,88 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:doormile/main.dart';
/// Drives the app through its main flows at a phone-sized viewport to catch
/// layout overflows and runtime exceptions on each screen.
void main() {
testWidgets('full user journey renders without errors', (WidgetTester tester) async {
tester.view.physicalSize = const Size(1170, 2532); // iPhone-ish @3x
tester.view.devicePixelRatio = 3.0;
addTearDown(tester.view.reset);
await tester.pumpWidget(const DoormileApp());
// Splash -> Onboarding
await tester.pump(const Duration(seconds: 3));
await tester.pumpAndSettle();
expect(find.text('Send a parcel in one touch'), findsOneWidget);
// Skip onboarding -> Login
await tester.tap(find.text('Skip'));
await tester.pumpAndSettle();
expect(find.text('Welcome back'), findsOneWidget);
// Enter phone and request OTP
await tester.enterText(find.byType(TextField).first, '9876543210');
await tester.pump();
await tester.tap(find.text('Send OTP'));
await tester.pump(const Duration(milliseconds: 1400));
await tester.pumpAndSettle();
expect(find.text('Verify your number'), findsOneWidget);
// Fill the 6 OTP boxes
final otpBoxes = find.byType(TextField);
for (int i = 0; i < 6; i++) {
await tester.enterText(otpBoxes.at(i), '1');
await tester.pump();
}
await tester.tap(find.text('Verify & Continue'));
await tester.pump(const Duration(milliseconds: 1300));
await tester.pumpAndSettle();
// Home
expect(find.text('Send a parcel'), findsOneWidget);
// Start a booking
await tester.tap(find.text('Book Now'));
await tester.pumpAndSettle();
expect(find.text('Step 1: Confirm Pickup'), findsOneWidget);
await tester.tap(find.text('Continue'));
await tester.pumpAndSettle();
expect(find.text('What are you sending?'), findsOneWidget);
await tester.tap(find.text('Continue'));
await tester.pumpAndSettle();
expect(find.text('CHOOSE SERVICE'), findsOneWidget);
// Confirm & Continue -> review
await tester.tap(find.textContaining('Confirm & Continue'));
await tester.pumpAndSettle();
expect(find.text('Review Booking'), findsWidgets);
// Pop all the way back to the home shell.
for (int i = 0; i < 4; i++) {
if (find.text('Send a parcel').evaluate().isNotEmpty) break;
await tester.pageBack();
await tester.pumpAndSettle();
}
expect(find.text('Send a parcel'), findsOneWidget);
// Wallet tab
await tester.tap(find.text('Wallet').first);
await tester.pumpAndSettle();
expect(find.text('Doormile Wallet'), findsOneWidget);
// Profile tab
await tester.tap(find.text('Profile').first);
await tester.pumpAndSettle();
expect(find.text('Riya Sharma'), findsOneWidget);
// Track tab (orders list)
await tester.tap(find.text('Track').first);
await tester.pumpAndSettle();
expect(find.text('My Parcels'), findsOneWidget);
});
}

View File

@@ -0,0 +1,297 @@
import 'dart:convert';
import 'package:doormile_cx/data/api_client.dart';
import 'package:doormile_cx/data/api_exception.dart';
import 'package:doormile_cx/data/live_doormile_api.dart';
import 'package:doormile_cx/data/models.dart';
import 'package:doormile_cx/data/session_store.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
/// What [LiveDoormileApi] actually puts on the wire, checked field by field
/// against the customer API document.
///
/// These are not parser tests. Every one of them exists because a plausible,
/// readable, wrong spelling — `otp` for `code`, `lat` for `latitude`, `min` for
/// `minRupees` — costs a whole feature at runtime and nothing at compile time.
///
/// Where the written contract and the running server disagree, these tests
/// follow the server. The `otp`/`code` case below is why.
class _Sent {
http.Request? last;
Map<String, dynamic> get body =>
jsonDecode(last!.body) as Map<String, dynamic>;
}
({LiveDoormileApi api, _Sent sent}) _api(Object responseData) {
final sent = _Sent();
final http.Client transport = MockClient((request) async {
sent.last = request;
return http.Response(
jsonEncode({'success': true, 'data': responseData}),
200,
headers: {'content-type': 'application/json'},
);
});
return (
api: LiveDoormileApi(
client: ApiClient(httpClient: transport, sessions: MemorySessionStore()),
),
sent: sent,
);
}
void main() {
// ── This test used to assert the opposite, and that is why the bug shipped ──
//
// The written contract names this field `otp`. The server does not:
// CxVerifyOtp reads `json:"code"` and treats a body without it as an empty
// code, answering 400 "Enter the code we sent you" to every sign-in — with
// any code, correct or not. Verified against production: `otp` gets that 400,
// `code` gets 401 "That code did not match" for a wrong code, and a session
// for a right one.
//
// The document was wrong and this test agreed with it, so nothing failed
// until a human tried to log in. The assertion is inverted deliberately —
// the server is the contract.
test('OTP verification sends `code` — the field the server actually reads', () async {
final t = _api({
'accessToken': 'a',
'refreshToken': 'r',
'expiresIn': 3600,
'customer': {'id': 1042, 'name': 'Alex Kumar', 'phone': '+919876543210'},
});
final customer = await t.api.verifyOtp('9876543210', '1234');
expect(t.sent.last!.url.path, endsWith('/customer/auth/otp/verify'));
expect(t.sent.body['code'], '1234');
expect(t.sent.body['identifier'], '+919876543210');
expect(
t.sent.body.containsKey('otp'),
isFalse,
reason: 'the server ignores `otp` and reads the body as an empty code',
);
expect(t.sent.last!.headers['Idempotency-Key'], isNotNull);
expect(customer.name, 'Alex Kumar');
});
test('a fare estimate prices packages, and reads the rupee band back', () async {
final t = _api({
'minRupees': 240,
'maxRupees': 310,
'routeKm': 348.5,
'breakdown': {'baseFare': 180, 'additionalStopsUplift': 60},
});
final fare = await t.api.estimateFare(
pickup: const Place(title: 'Home', sub: '', lat: 13.0827, lng: 80.2707),
destinations: [
DestinationGroup(
destination: Destination(stateCode: 'TN', districtCode: 'CHN'),
packageCount: 2,
),
],
);
final pickup = t.sent.body['pickup'] as Map<String, dynamic>;
expect(pickup['latitude'], 13.0827);
expect(pickup['longitude'], 80.2707);
final destination =
(t.sent.body['destinations'] as List).single as Map<String, dynamic>;
expect(destination['stateCode'], 'TN');
// One entry per package, and no weight on any of them — the customer is
// never asked what a parcel weighs.
expect((destination['packages'] as List).length, 2);
expect(fare.min, 240);
expect(fare.max, 310);
expect(fare.routeKm, 348.5);
});
test('a booking sends the contract shape, flat and with a contact', () async {
final t = _api({
'reference': 'DM-482913',
'stage': 'booked',
'status': 'active',
'cancellable': true,
'createdAt': 1788775499000,
'slotId': 'slot_20260908_t2',
'pickup': {
'title': 'Home',
'sub': 'Flat 4B, Green Towers',
'latitude': 13.0827,
'longitude': 80.2707,
},
'destinations': [
{
'index': 0,
'stateName': 'Tamil Nadu',
'districtName': 'Chennai',
'packageCount': 1,
'codAmount': 450,
'trackingId': null,
'stage': null,
},
],
});
await t.api.client.adoptSession(
Session(
accessToken: 'a',
refreshToken: 'r',
expiresAt: DateTime.now().add(const Duration(hours: 1)),
customer: const Customer(
id: '1042',
name: 'Alex Kumar',
phone: '+919876543210',
email: '',
),
),
);
final group = DestinationGroup(
destination: Destination(stateCode: 'TN', districtCode: 'CHN'),
);
group.details.update(
recipientName: 'Priya S',
building: '12/A',
street: 'MG Road',
instructions: 'Handle with care',
);
final booking = await t.api.createBooking(
pickup: const Place(
title: 'Home',
sub: 'Flat 4B, Green Towers',
lat: 13.0827,
lng: 80.2707,
),
destinations: [group],
slotId: 'slot_20260908_t2',
);
final body = t.sent.body;
final pickup = body['pickup'] as Map<String, dynamic>;
expect(pickup['latitude'], 13.0827);
expect(pickup['contactName'], 'Alex Kumar');
expect(pickup['contactPhone'], '+919876543210');
final destination =
(body['destinations'] as List).single as Map<String, dynamic>;
expect(destination['recipientName'], 'Priya S');
expect(destination['building'], '12/A');
// Flat, not nested under `details`.
expect(destination.containsKey('details'), isFalse);
// And the note is not repeated here — it is the visit's `remarks`.
expect(destination.containsKey('instructions'), isFalse);
// Per-destination instructions become the visit's one `remarks` line.
expect(body['remarks'], 'Handle with care');
// And the response — which names the state and district but sends no
// codes — must still render as a destination.
expect(booking.reference, 'DM-482913');
expect(booking.pickup.lat, 13.0827);
final read = booking.destinations.single;
expect(read.destination.label, 'Chennai, Tamil Nadu');
expect(read.index, 0);
expect(read.codAmount, 450);
expect(read.trackingId, isNull);
});
test('the cancel window closes when the Miler arrives', () {
final t = _api(const {});
expect(t.api.isCancellable(JourneyStage.onTheWay), isTrue);
expect(t.api.isCancellable(JourneyStage.arrived), isFalse);
expect(t.api.isCancellable(JourneyStage.pickedUp), isFalse);
});
test('booking limits carry the COD cap', () {
const limits = BookingLimits();
expect(limits.allowsCod, isFalse);
expect(
BookingLimits.fromJson(const {
'maxDestinations': 1,
'maxPackages': 20,
'maxCodAmount': 5000,
}).maxCodAmount,
5000,
);
});
group('error codes', () {
/// Drives a real failure through [ApiClient], because the translation that
/// matters happens while the response is being decoded.
Future<ApiException> failing(int status, String code) async {
final client = ApiClient(
httpClient: MockClient(
(_) async => http.Response(
jsonEncode({
'success': false,
'error': {'code': code, 'message': 'Something the server said'},
}),
status,
headers: {'content-type': 'application/json'},
),
),
sessions: MemorySessionStore(),
);
try {
await client.get('/bookings', authenticated: false);
} on ApiException catch (e) {
return e;
}
fail('expected $code to throw');
}
test('the contract spells its codes in capitals; the client translates', () async {
expect((await failing(401, 'UNAUTHORIZED')).isAuthFailure, isTrue);
expect((await failing(409, 'SLOT_CAPACITY_FULL')).needsFreshSlots, isTrue);
expect((await failing(400, 'SLOT_EXPIRED')).needsFreshSlots, isTrue);
expect(
(await failing(409, 'BOOKING_NOT_CANCELLABLE')).code,
ApiException.notCancellable,
);
expect(
(await failing(422, 'UNSERVICEABLE_PINCODE')).code,
ApiException.unserviceable,
);
expect((await failing(429, 'RATE_LIMITED')).isTransient, isTrue);
// The raw code is kept for the log line and the support report.
expect((await failing(401, 'UNAUTHORIZED')).serverCode, 'UNAUTHORIZED');
// An unknown code still lands somewhere useful: the HTTP status.
expect((await failing(404, 'NO_SUCH_THING')).code, ApiException.notFound);
});
});
test('a paged list is read through its wrapper as well as beside it', () async {
final client = ApiClient(
httpClient: MockClient(
(_) async => http.Response(
jsonEncode({
'success': true,
'data': {
'items': [
{'reference': 'DM-1'},
],
'nextCursor': 'cur_2',
'total': 9,
},
}),
200,
headers: {'content-type': 'application/json'},
),
),
sessions: MemorySessionStore(),
);
final page = await client.get('/bookings', authenticated: false);
expect(page.rows.single['reference'], 'DM-1');
expect(page.nextCursor, 'cur_2');
expect(page.total, 9);
});
}

View File

@@ -0,0 +1,89 @@
import 'package:doormile_cx/data/dev_doormile_api.dart';
import 'package:doormile_cx/data/models.dart';
import 'package:doormile_cx/main.dart';
import 'package:doormile_cx/state/app_state.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
/// The login screen must not walk a customer to the code screen when no code
/// was sent.
///
/// `sent: false` is the server saying it accepted the request and delivered
/// nothing — the SMS gateway or the mail relay refused it. Advancing anyway
/// puts four empty boxes in front of someone for a code that does not exist,
/// and every digit they try is wrong. The failure then reads as "I typed it
/// wrong" instead of "nothing arrived", which is the wrong problem to hand
/// somebody.
///
/// The resend control on the code screen has always honoured this flag. The
/// login screen did not, which is the asymmetry these tests pin down.
/// The app runs continuous animations, so `pumpAndSettle` never returns.
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));
}
class _SendFails extends DevDoormileApi {
@override
Future<OtpChallenge> sendOtp(String identifier) async =>
const OtpChallenge(sent: false);
}
class _SendSucceeds extends DevDoormileApi {
var calls = 0;
@override
Future<OtpChallenge> sendOtp(String identifier) async {
calls++;
return const OtpChallenge(sent: true);
}
}
Future<void> _tapContinue(WidgetTester tester, AppState state) async {
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');
await settle(tester, 200);
await tester.tap(find.text('Continue'));
await settle(tester);
}
void main() {
testWidgets('a code that was never sent does not open the code screen',
(tester) async {
await _tapContinue(tester, AppState(api: _SendFails()));
expect(
find.text('Verify your number'),
findsNothing,
reason: 'no code was delivered, so there is nothing to verify',
);
expect(
// The entry screen's own headline, which now sits on the brand field
// rather than as a heading inside the sheet.
find.text('Book a pickup from your door.'),
findsOneWidget,
reason: 'the customer stays where they can try again',
);
// Drain the toast timer so the test ends with nothing pending.
await tester.pump(const Duration(seconds: 4));
});
testWidgets('a delivered code does open the code screen', (tester) async {
final api = _SendSucceeds();
await _tapContinue(tester, AppState(api: api));
expect(find.text('Verify your number'), findsOneWidget);
expect(api.calls, 1);
await tester.pump(const Duration(seconds: 4));
});
}

96
test/map_config_test.dart Normal file
View File

@@ -0,0 +1,96 @@
import 'dart:io';
import 'package:doormile_cx/data/map_config.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('tile provider', () {
test('CARTO serves anonymously and takes a key when one is supplied', () {
const anonymous = MapTileProvider.carto;
expect(anonymous.usable, isTrue);
expect(anonymous.hasKey, isFalse);
expect(anonymous.urlFor(DmMapStyle.streets), isNot(contains('api_key')));
final keyed = anonymous.withKey('abc123');
expect(keyed.hasKey, isTrue);
expect(
keyed.urlFor(DmMapStyle.streets),
endsWith('?api_key=abc123'),
);
// Nothing else about the source changes when the key arrives.
expect(keyed.attribution, anonymous.attribution);
expect(keyed.subdomains, anonymous.subdomains);
});
test('the two styles are distinct sources', () {
expect(
MapTileProvider.carto.urlFor(DmMapStyle.streets),
isNot(MapTileProvider.carto.urlFor(DmMapStyle.muted)),
);
});
test('a key-only provider reports itself unusable without one', () {
expect(MapTileProvider.maptiler.usable, isFalse);
expect(MapTileProvider.maptiler.withKey('k').usable, isTrue);
expect(
MapTileProvider.maptiler.withKey('k').urlFor(DmMapStyle.streets),
endsWith('?key=k'),
);
expect(MapTileProvider.stadia.usable, isFalse);
});
test('every preset credits its data source', () {
for (final provider in MapTileProvider.presets.values) {
expect(
provider.attribution,
contains('OpenStreetMap'),
reason: '${provider.id} must credit OpenStreetMap',
);
expect(provider.attributionUrl, startsWith('https://'));
expect(provider.templates[DmMapStyle.streets], isNotNull);
}
});
});
group('resolved configuration', () {
test('defaults to CARTO and identifies the app to the provider', () {
final config = DmMapConfig.fromEnvironment();
expect(config.provider.id, 'carto');
expect(config.warning, isNull);
expect(config.userAgent, contains('Doormile/'));
expect(config.userAgent, contains(DmMapConfig.packageName));
expect(config.attribution, contains('CARTO'));
});
test('is swappable without touching a screen', () {
final original = DmMapConfig.instance;
addTearDown(() => DmMapConfig.instance = original);
DmMapConfig.instance = DmMapConfig(
provider: MapTileProvider.osm,
userAgent: 'test/1.0',
);
expect(DmMapConfig.instance.attribution, '© OpenStreetMap contributors');
expect(DmMapConfig.instance.retina, isFalse);
expect(DmMapConfig.instance.maxNativeZoom, 19);
});
});
test('no tile URL, subdomain list or map key lives in the UI layer', () {
// The whole point of the provider layer: swapping CARTO for a keyed plan or
// another vendor must never mean editing a map screen.
final offenders = <String>[];
for (final entry in Directory('lib/ui').listSync(recursive: true)) {
if (entry is! File || !entry.path.endsWith('.dart')) continue;
final source = entry.readAsStringSync();
if (source.contains('cartocdn') ||
source.contains('tile.openstreetmap.org') ||
source.contains('api.maptiler.com') ||
source.contains('tiles.stadiamaps.com') ||
source.contains('{z}/{x}/{y}')) {
offenders.add(entry.path);
}
}
expect(offenders, isEmpty, reason: 'tile URLs belong in map_config.dart');
});
}

View File

@@ -1,73 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
import 'package:doormile/state/app_state.dart';
import 'package:doormile/theme/app_theme.dart';
import 'package:doormile/screens/onboarding_screen.dart';
import 'package:doormile/screens/login_screen.dart';
import 'package:doormile/screens/otp_screen.dart';
import 'package:doormile/screens/main_shell.dart';
import 'package:doormile/screens/home_screen.dart';
import 'package:doormile/screens/wallet_screen.dart';
import 'package:doormile/screens/profile_screen.dart';
import 'package:doormile/screens/help_support_screen.dart';
import 'package:doormile/screens/booking/booking_pickup_screen.dart';
import 'package:doormile/screens/booking/parcel_details_screen.dart';
import 'package:doormile/screens/booking/service_price_screen.dart';
import 'package:doormile/screens/booking/booking_preview_screen.dart';
import 'package:doormile/screens/booking/booking_success_screen.dart';
import 'package:doormile/screens/tracking/my_orders_screen.dart';
import 'package:doormile/screens/tracking/live_tracking_screen.dart';
import 'package:doormile/screens/tracking/shipment_journey_screen.dart';
/// Renders each screen in isolation at a phone viewport and settles it, so the
/// test framework flags any overflow in the *settled* layout (no transition
/// transients). A pumped order is placed first so success/tracking have data.
Future<void> _renderScreen(WidgetTester tester, Widget child) async {
final state = AppState()
..login()
..setPhone('98765 43210');
state.placeOrder();
await tester.pumpWidget(
ChangeNotifierProvider<AppState>.value(
value: state,
child: MaterialApp(theme: AppTheme.light, home: child),
),
);
await tester.pump(const Duration(milliseconds: 400));
await tester.pump(const Duration(milliseconds: 400));
}
void main() {
setUp(() {});
final screens = <String, Widget Function()>{
'Onboarding': () => const OnboardingScreen(),
'Login': () => const LoginScreen(),
'OTP': () => const OtpScreen(),
'MainShell/Home': () => const MainShell(),
'Home': () => const Scaffold(body: HomeScreen()),
'Wallet': () => const Scaffold(body: WalletScreen()),
'Profile': () => const Scaffold(body: ProfileScreen()),
'Help': () => const HelpSupportScreen(),
'BookingPickup': () => const BookingPickupScreen(),
'ParcelDetails': () => const ParcelDetailsScreen(),
'ServicePrice': () => const ServicePriceScreen(),
'BookingPreview': () => const BookingPreviewScreen(),
'BookingSuccess': () => const BookingSuccessScreen(),
'MyOrders': () => const MyOrdersScreen(),
'LiveTracking': () => const LiveTrackingScreen(),
'ShipmentJourney': () => const ShipmentJourneyScreen(),
};
for (final entry in screens.entries) {
testWidgets('${entry.key} renders cleanly at phone size', (tester) async {
tester.view.physicalSize = const Size(1170, 2532);
tester.view.devicePixelRatio = 3.0;
addTearDown(tester.view.reset);
await _renderScreen(tester, entry.value());
expect(tester.takeException(), isNull);
});
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

BIN
test/snapshots/03-home.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

BIN
test/snapshots/09-slot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

View File

@@ -1,19 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:doormile/main.dart';
void main() {
testWidgets('App boots and advances from splash to onboarding',
(WidgetTester tester) async {
await tester.pumpWidget(const DoormileApp());
// Splash shows the brand wordmark.
expect(find.text('Doormile'), findsWidgets);
// Let the splash auto-navigation timer fire, then settle on onboarding.
await tester.pump(const Duration(seconds: 3));
await tester.pumpAndSettle();
expect(find.text('Send a parcel in one touch'), findsOneWidget);
});
}