Files
doormile_milderapp/test/miler_contract_regression_test.dart
Thiru-tenext d612916fe4 Session expiry, arrival geofence guard, multi-destination stops
Three fixes found by running the app on a real handset against production.

1. An expired token left the app looking signed in and unable to work.
   MilerApi.onUnauthorized was declared and called on every 401 but never
   assigned, so the token was dropped and nothing else happened: the profile
   stayed on disk, logged_out stayed false, and the rider saw his own name over
   a dashboard whose every call returned 401. He reads that as "no work today".
   The teardown now lives in endSession() and both ways out of a session — the
   Log out button and the 401 path — use it.

2. Arrived was written locally even when the rider was not there.
   updateArrivedStatus answers false for three different things and the caller
   treated all of them as "the write did not land", which is only true of one.
   A geofence refusal and a server refusal now stop the rung and hand back the
   reason; a dead network still advances, as it should.

3. A multi-destination customer pickup collapsed onto one stop.
   GET /miler/bookings returns a row per destination once collected, all with
   the same bookingid and reference. Every local store keys on that id, so the
   accepted store deduped two of three drops away and their consignment ids
   were unrecoverable. orderid is now the stop key; bookingreference stays the
   booking's name. Cards show "Stop 2 of 3" and the receiver's own name and
   number rather than the sender's.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EqVJPB9B4QuieZnBAAKgYQ
2026-09-18 11:05:40 +05:30

872 lines
34 KiB
Dart

import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:miler/Models/stop_status.dart';
import 'package:miler/controllers/riderkm.dart';
import 'package:miler/data/api_config.dart';
import 'package:miler/data/miler_api.dart';
import 'package:miler/data/milk_run.dart';
import 'package:miler/data/route_order.dart';
import 'package:miler/data/service_profile.dart';
import 'package:miler/views/Dashboard/home/homepage.dart';
import 'package:miler/data/stop_compliance.dart';
import 'package:miler/controllers/pickups_controller.dart';
import 'package:miler/providers/pickuplog/pickuplog_provider.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// FOUR CONTRACTS THE APP MEETS THE BACKEND ON
///
/// Each of these has already been wrong once, and each was wrong in the same
/// way: the app read a field the server does not send, or ignored one it does.
/// None of them raised anything — an absent key and an empty answer are the
/// same value, so every failure was silent and every symptom was cosmetic
/// ("the chart is empty", "the route is in the wrong order"). That is what
/// makes them worth pinning rather than trusting.
/// ─────────────────────────────────────────────────────────────────────────
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
// ══════════════════════════════════════════════════════════════════════
// 1 · ROUTE SEQUENCING
//
// The backend's rule, given 25 Aug: `sequencedat` is the authority, not
// `step`. Non-null → a route was assigned, follow `step` exactly. Null → no
// route, and nearest-first is correct rather than a fallback for a bug.
//
// `RouteOrder` implemented that correctly and it never ran, because
// `pickupFromBooking` was dropping the field. Every adapted row looked
// unsequenced and the app re-sorted routes the hub had solved.
// ══════════════════════════════════════════════════════════════════════
group('1 · sequencedat survives the adapter and decides the order', () {
Map<String, dynamic> booking(int id, Map<String, dynamic> extra) => {
'bookingid': id,
'bookingno': 'B$id',
'pickupaddress': '$id Thadagam Road, Coimbatore',
'pickupcustomer': 'Sri Balaji Stores',
...extra,
};
/// The rider is beside stop 3, so nearest-first would put it first. Any
/// test that expects the hub's order has to be able to lose to proximity
/// if the gate fails — otherwise it passes for the wrong reason.
double distanceTo(Map<String, dynamic> s) => switch ('${s['orderid']}') {
'3' => 10,
'1' => 4000,
'2' => 9000,
_ => 99999,
};
test('a sequenced booking keeps its stamp and its step', () {
final row = ApiConfig.pickupFromBooking(
booking(1, {'step': 2, 'sequencedat': '2026-08-25T09:00:00+05:30'}),
);
expect(
RouteOrder.isSequenced(row),
isTrue,
reason: 'the stamp did not survive pickupFromBooking',
);
expect(RouteOrder.sequenceOf(row), 2);
});
test('the hub order is followed, and proximity does not override it', () {
final stops = [
for (final (id, step) in const [(3, 3), (1, 1), (2, 2)])
ApiConfig.pickupFromBooking(
booking(id, {
'step': step,
'sequencedat': '2026-08-25T09:00:00+05:30',
}),
),
];
final (sorted, source) = RouteOrder.sort(stops, distanceTo: distanceTo);
expect(source, RouteOrderSource.adminSequence);
expect(
sorted.map((s) => '${s['orderid']}').toList(),
['1', '2', '3'],
reason:
'the rider is standing on B3 — if this comes back B3-first the '
'stamp was ignored and nearest-first won',
);
});
test('no stamp means no route, and the fallback is correct', () {
// The backend's own words: `step: 0` with a null stamp is a rider holding
// fewer than two active stops, or a stop with no coordinates. Neither is
// a route, and re-sorting is the right answer rather than a workaround.
final stops = [
for (final id in const [1, 2, 3])
ApiConfig.pickupFromBooking(
booking(id, {'step': 0, 'sequencedat': null}),
),
];
expect(RouteOrder.hasAdminSequence(stops), isFalse);
final (sorted, source) = RouteOrder.sort(stops, distanceTo: distanceTo);
expect(source, isNot(RouteOrderSource.adminSequence));
expect(
'${sorted.first['orderid']}',
'3',
reason: 'with no route assigned, the nearest stop leads',
);
});
test('a blank stamp is no stamp', () {
final row = ApiConfig.pickupFromBooking(
booking(1, {'step': 0, 'sequencedat': ' '}),
);
expect(RouteOrder.isSequenced(row), isFalse);
});
});
// ══════════════════════════════════════════════════════════════════════
// 2 · PER-STOP DISTANCE
//
// Three spellings, because the app writes two of them itself: the contract's
// `compliance.actualkm`, the backend's `riderkms` on a raw booking row, and
// `actualkms` — what `PickupsController` posts on the arrival and pickup
// writes, so it is the key on any record stamped locally before the queue
// caught up. Reading only the first two made a stop the rider had just
// finished report no distance at all.
// ══════════════════════════════════════════════════════════════════════
group('2 · every distance spelling resolves', () {
test('compliance.actualkm — the contract\'s own', () {
final c = StopCompliance.of({
'compliance': {'actualkm': 4.2},
});
expect(c.actualKm, 4.2);
});
test('riderkms — a row straight off the API', () {
expect(StopCompliance.of({'riderkms': 3.1}).actualKm, 3.1);
});
test('actualkms — a row this app stamped itself', () {
expect(StopCompliance.of({'actualkms': '2.75'}).actualKm, 2.75);
});
test('the contract wins when more than one is present', () {
final c = StopCompliance.of({
'compliance': {'actualkm': 9.9},
'riderkms': 3.1,
'actualkms': '2.75',
});
expect(
c.actualKm,
9.9,
reason: 'the authoritative figure must outrank the locally stamped one',
);
});
test('riderkms outranks the local stamp', () {
final c = StopCompliance.of({'riderkms': 3.1, 'actualkms': '2.75'});
expect(c.actualKm, 3.1);
});
test('no distance at all is null, not zero', () {
// Null is what the UI turns into the em dash. A zero would render as a
// real measurement of nothing, which is a different claim.
expect(StopCompliance.of(const {}).actualKm, isNull);
expect(StopCompliance.of({'riderkms': 0}).actualKm, isNull);
expect(StopCompliance.of({'actualkms': ''}).actualKm, isNull);
});
});
// ══════════════════════════════════════════════════════════════════════
// 3 · THE WEEK'S DISTANCE
//
// `GET /miler/earnings` answers with six totals for the period asked for and
// no series. The app read a `breakdown` array that is not on the contract, so
// it was always null and the chart drew an empty week while the rider had
// ridden all of it.
//
// Seven daily calls build the week now. The fast path stays, so the day the
// field ships the seven calls stop — and that switch has to be automatic and
// has to refuse anything it cannot draw.
// ══════════════════════════════════════════════════════════════════════
group('3 · weekly distance: fast path and fallback', () {
late List<Uri> asked;
/// Answers `weekly` with [weekly] and every `daily` with its own total.
setUp(() => SharedPreferences.setMockInitialValues({}));
void stub({
Map<String, dynamic> weekly = const {'total_kms': 40},
double dailyKm = 5,
}) {
asked = <Uri>[];
MilerApi.client = MockClient((req) async {
asked.add(req.url);
final period = req.url.queryParameters['period'];
final body = period == 'weekly' ? weekly : {'total_kms': dailyKm};
return http.Response(
jsonEncode({'success': true, 'data': body}),
200,
headers: {'content-type': 'application/json'},
);
});
}
int dailyCalls() =>
asked.where((u) => u.queryParameters['period'] == 'daily').length;
test(
'FAST PATH · a valid breakdown is used, and no daily call is made',
() async {
stub(
weekly: {
'total_kms': 40.5,
// The backend's own example shape: ISO dates, not weekday names.
'breakdown': [
{'day': '2026-08-19', 'kms': 18.4},
{'day': '2026-08-20', 'kms': 22.1},
],
},
);
final res = await RiderWeeklyKmController().getRiderWeeklyKms(1);
final details = res['details'] as List;
expect(details, hasLength(2));
expect(res['total_kms'], 40.5);
expect(
dailyCalls(),
0,
reason: 'the seven daily calls must stop the day breakdown ships',
);
},
);
test('FAST PATH · an ISO date is labelled with the day it names', () async {
stub(
weekly: {
'total_kms': 18.4,
'breakdown': [
{'day': '2026-08-19', 'kms': 18.4},
],
},
);
final res = await RiderWeeklyKmController().getRiderWeeklyKms(1);
final first = (res['details'] as List).first;
// 2026-08-19 is a Wednesday. The chart labels a bar with the first three
// characters of `day`, so an un-normalised ISO date renders as `202`.
expect(first.day, 'Wed');
expect(first.kms, 18.4);
});
test('FAST PATH · a weekday name is passed through untouched', () async {
stub(
weekly: {
'breakdown': [
{'day': 'Mon', 'kms': 3},
],
},
);
final res = await RiderWeeklyKmController().getRiderWeeklyKms(1);
expect((res['details'] as List).first.day, 'Mon');
expect(dailyCalls(), 0);
});
for (final (name, bad) in <(String, Object?)>[
('absent', null),
('empty', <dynamic>[]),
('not a list', 'nope'),
('entries that are not maps', ['Mon', 'Tue']),
(
'an entry with no day',
[
{'kms': 4},
],
),
(
'an entry whose distance will not parse',
[
{'day': 'Mon', 'kms': 'lots'},
],
),
]) {
test('FALLBACK · $name breakdown falls through to seven calls', () async {
stub(weekly: {'total_kms': 35, if (bad != null) 'breakdown': bad});
final res = await RiderWeeklyKmController().getRiderWeeklyKms(1);
final details = res['details'] as List;
expect(
dailyCalls(),
7,
reason: 'a series the chart cannot draw is not a series',
);
expect(details, hasLength(7));
expect(
details.every((d) => d.kms == 5),
isTrue,
reason: 'each bar is its own day\'s total_kms',
);
});
}
test(
'FALLBACK · the daily series is labelled and ordered oldest first',
() async {
stub();
final res = await RiderWeeklyKmController().getRiderWeeklyKms(1);
final days = (res['details'] as List).map((d) => d.day).toList();
expect(days, hasLength(7));
expect(days.toSet(), hasLength(7), reason: 'seven distinct weekdays');
expect(
days.last,
const [
'Mon',
'Tue',
'Wed',
'Thu',
'Fri',
'Sat',
'Sun',
][DateTime.now().weekday - 1],
reason: 'the last bar is today',
);
},
);
test('FALLBACK · the server\'s weekly total is not overruled', () async {
// Seven daily figures are a reconstruction. The weekly number the backend
// computed is the one that stands.
stub(weekly: {'total_kms': 99}, dailyKm: 5);
final res = await RiderWeeklyKmController().getRiderWeeklyKms(1);
expect(res['total_kms'], 99);
});
test(
'FALLBACK · with no weekly total, the reconstruction stands in',
() async {
stub(weekly: {'total_kms': 0}, dailyKm: 5);
final res = await RiderWeeklyKmController().getRiderWeeklyKms(1);
expect(res['total_kms'], 35);
},
);
});
// ══════════════════════════════════════════════════════════════════════
// 3b · THE TWO LIFECYCLE WRITES ACTUALLY LEAVE THE PHONE
//
// The defect these pin: the map sheet's **I've arrived** control never called
// the arrival API. `updateArrivedStatus` was reachable from `homepage.dart`
// and nowhere else, so on the logistics line — where the map sheet is the
// only arrival control — `reached` never fired, the local record was never
// written, and the rung went from ACCEPTED straight to whatever came next.
//
// Nothing raised. A transition that is never attempted looks exactly like one
// that succeeded and was not reflected, which is why this was read as a
// status-mapping bug for as long as it was.
//
// These assert the wire, not the widget: the endpoint, the identifier in the
// path, and the body — with the geofence bypass ON, which is the state the
// build ships in and the thing that was suspected of eating the call.
// ══════════════════════════════════════════════════════════════════════
group('3b · the arrival and pickup writes reach the network', () {
late List<http.Request> sent;
void stub() {
sent = <http.Request>[];
MilerApi.client = MockClient((req) async {
sent.add(req);
return http.Response(
jsonEncode({'success': true, 'data': {}}),
200,
headers: {'content-type': 'application/json'},
);
});
}
setUp(() {
SharedPreferences.setMockInitialValues({});
stub();
});
tearDown(() => MilerApi.client = http.Client());
http.Request only(String fragment) {
final hits = sent.where((r) => r.url.path.contains(fragment)).toList();
expect(
hits,
hasLength(1),
reason:
'expected exactly one request to …/$fragment, sent: '
'${sent.map((r) => r.url.path).toList()}',
);
return hits.single;
}
test('ARRIVED · endpoint, path id and body', () async {
await MilerApi.reached(4211, lat: 11.0168, lon: 76.9558);
final req = only('reached');
expect(req.method, 'POST');
// The **booking** id, in the path. Not the assignment id — that one keys
// accept/reject only — and not a consignment, which does not exist yet.
expect(req.url.path, endsWith('/miler/bookings/4211/reached'));
expect(jsonDecode(req.body), {'latitude': 11.0168, 'longitude': 76.9558});
});
test(
'ARRIVED · a fix-less arrival still posts, with no coordinates',
() async {
// The body omits what it does not have rather than sending zeros, which
// would be a claim about where the rider was standing.
await MilerApi.reached(4211);
expect(jsonDecode(only('reached').body), isEmpty);
},
);
test('PICKED · endpoint, path id and body', () async {
await MilerApi.pickupComplete(4211, lat: 11.0168, lon: 76.9558);
final req = only('pickup-complete');
expect(req.method, 'POST');
expect(req.url.path, endsWith('/miler/bookings/4211/pickup-complete'));
expect(jsonDecode(req.body), {'latitude': 11.0168, 'longitude': 76.9558});
expect(
req.headers['Idempotency-Key'],
isNotNull,
reason:
'the pivot is the one write a dropped acknowledgement must not '
'be able to run twice',
);
});
test(
'the provider routes ARRIVED to reached, not to anything else',
() async {
await UpdatePickupProvider().updatePickup({
'pickupid': 4211,
'orderheaderid': 77,
'orderstatus': 'arrived',
'riderslat': '11.016800',
'riderslon': '76.955800',
});
final req = only('reached');
// `pickupid` IS the booking id — see `ApiConfig.pickupFromBooking`, which
// maps `bookingid` onto it. This pins that, because the provider picks
// the path id off the payload and a change there would be silent.
expect(req.url.path, endsWith('/miler/bookings/4211/reached'));
expect(jsonDecode(req.body), {
'latitude': 11.0168,
'longitude': 76.9558,
});
},
);
test('the provider routes PICKED to pickup-complete', () async {
await UpdatePickupProvider().updatePickup({
'pickupid': 4211,
'orderheaderid': 77,
'orderstatus': 'picked',
'riderslat': '11.016800',
'riderslon': '76.955800',
});
expect(
only('pickup-complete').url.path,
endsWith('/miler/bookings/4211/pickup-complete'),
);
});
test(
'a missing booking id refuses the call, it does not post to zero',
() async {
// ── The silent one ──
//
// The provider resolved its path id with `pickupid ?? orderheaderid`, and
// `??` catches only null. Every caller builds the payload with
// `int.tryParse(...) ?? 0`, so a row without a booking id arrived as
// **0** — not null — the fallback never fired, and the app posted to
// `/miler/bookings/0/reached`.
//
// Nothing complained on the phone: arrival is the rider's own report and
// is deliberately not gated on the write landing, so his rung advanced
// and the row looked healthy. The hub was told about booking zero, and
// the console showed no arrival — which is exactly how this was reported.
final res = await UpdatePickupProvider().updatePickup({
'pickupid': 0,
'orderheaderid': 0,
'orderstatus': 'arrived',
'riderslat': '11.016800',
'riderslon': '76.955800',
});
expect(sent, isEmpty, reason: 'booking 0 is not a booking');
expect(res?['status'], isFalse);
},
);
test(
'a real id still posts, and zero on the other key is ignored',
() async {
await UpdatePickupProvider().updatePickup({
'pickupid': 0,
'orderheaderid': 77,
'orderstatus': 'arrived',
'riderslat': '11.016800',
'riderslon': '76.955800',
});
expect(
only('reached').url.path,
endsWith('/miler/bookings/77/reached'),
);
},
);
test('the provider itself holds no proximity gate', () async {
// ── What this test used to say ──
//
// `expect(kBypassGeofenceForTesting, isTrue)` — it asserted the fence was
// OFF, and then that the write still went out. The flag is gone: the
// fence is enforced by default now and lives in `lib/data/geofence.dart`.
//
// The claim underneath it survives and is worth keeping: **the fence is
// in the controller, not in the transport.** `UpdatePickupProvider` maps
// a legacy payload onto a v1 route and posts it, full stop. If a
// proximity check ever appears down here there would be two fences again,
// reachable by different paths, which is the shape that gave the app a
// 500 m radius on Home and a 10 m one on a stop.
//
// The controller-level rule — blocked means no request — is asserted
// end-to-end in `geofence_test.dart`.
await UpdatePickupProvider().updatePickup({
'pickupid': 4211,
'orderstatus': 'arrived',
'riderslat': '11.016800',
'riderslon': '76.955800',
});
expect(sent, isNotEmpty, reason: 'the provider swallowed the request');
expect(only('reached').url.path, contains('/4211/reached'));
});
});
// ══════════════════════════════════════════════════════════════════════
// 3c · THE PICKUP MILESTONE IS NOT THE DELIVERY STATUS
//
// The backend's booking lifecycle, confirmed by them and with no Arrived rung
// anywhere in it:
//
// pending → miler_assigned → pickup_scheduled → converted_to_consignment
// → out_for_delivery / active → delivered
//
// Three of those words carry the **pickup** milestone and all three parsed as
// `unknown`: `pickup_scheduled`, `converted_to_consignment` and `picked_up`.
// So the one field the pickup UI reads had nothing in it, and `active` — a
// *delivery* state, which does parse — won by default. That is the second
// half of "Picked shows as Active"; the first was the app writing `active`
// onto the rung itself.
//
// Nothing about the backend's meanings is changed here. `active` still means
// active, and the delivery surfaces still read it that way. What is fixed is
// that the pickup surfaces resolve the *pickup* fact and it outranks a
// delivery state that came after it.
// ══════════════════════════════════════════════════════════════════════
group('3c · the pickup rung, derived from authoritative facts', () {
setUp(() => ServiceProfile.setActive(ServiceProfile.milkMan));
tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel));
Map<String, dynamic> row(String status) => {
'orderid': 'B1',
'pickupid': 'B1',
'sourcename': 'Vidhya Kitchen',
'orderstatus': status,
};
StopStatus rung(
String status, {
Set<String> collected = const {},
Set<String> arrived = const {},
Set<String> released = const {},
Set<String> accepted = const {'B1'},
}) => MilkRun.stageOf(
row(status),
acceptedIds: accepted,
collectedIds: collected,
arrivedIds: arrived,
outForDeliveryIds: released,
);
test('the backend\'s pickup words parse at all', () {
// Each of these was StopStatus.unknown.
expect(stopStatusFromRaw('pickup_scheduled'), StopStatus.accepted);
expect(stopStatusFromRaw('converted_to_consignment'), StopStatus.picked);
expect(stopStatusFromRaw('picked_up'), StopStatus.picked);
});
test('pickup_scheduled, nothing else → Accepted', () {
expect(rung('pickup_scheduled'), StopStatus.accepted);
});
test('pickup_scheduled + an arrival fact → Arrived', () {
expect(
rung('pickup_scheduled', arrived: {'B1'}),
StopStatus.arrived,
reason:
'the arrival is the rider\'s own record; nothing outranks it '
'here because nothing further along has happened',
);
});
test('converted_to_consignment → Picked', () {
expect(rung('converted_to_consignment'), StopStatus.picked);
});
test('picked_up → Picked', () {
expect(rung('picked_up'), StopStatus.picked);
});
test('active + pickup evidence → Picked on the pickup UI', () {
// The case the whole separation exists for. The delivery lifecycle has
// moved on; the pickup milestone did not un-happen.
expect(rung('active', collected: {'B1'}), StopStatus.picked);
});
test('out_for_delivery + pickup evidence → Picked on the pickup UI', () {
expect(rung('out_for_delivery', collected: {'B1'}), StopStatus.picked);
});
test('delivered keeps its own rung, not the pickup one', () {
// A delivered stop is finished. The pickup milestone is history, and the
// rung a rider is shown is the one that is current.
expect(rung('delivered', collected: {'B1'}), StopStatus.delivered);
});
test('active WITHOUT pickup evidence stays active', () {
// The line the brief drew: do not infer Picked merely because a booking
// is Active. Without a collection fact there is nothing to infer from.
//
// `accepted` is emptied here on purpose: the local accept store is
// checked below `active` in the ladder, so leaving it in would answer
// `accepted` and the test would pass for the wrong reason.
expect(rung('active', accepted: const {}), StopStatus.active);
});
test('the delivery resolver still reads active as active', () {
// Nothing about the backend's meaning is being renamed.
expect(stopStatusFromRaw('active'), StopStatus.active);
expect(stopStatusFromRaw('out_for_delivery'), StopStatus.outForDelivery);
});
test('an arrival record cannot walk a picked stop backwards', () {
expect(
rung('converted_to_consignment', arrived: {'B1'}, collected: {'B1'}),
StopStatus.picked,
);
expect(
rung('out_for_delivery', arrived: {'B1'}, released: {'B1'}),
StopStatus.outForDelivery,
);
expect(rung('delivered', arrived: {'B1'}), StopStatus.delivered);
});
});
// ══════════════════════════════════════════════════════════════════════
// 3d · START DELIVERY IS THE ONLY THING THAT MAKES A ROUND ACTIVE
//
// The one rider action that moves `Collected_By_Miler → Out_for_Delivery`.
// Not pickup-complete, not opening the Deliveries tab, not navigation, not
// availability — all of which have at some point been mistaken for it.
// ══════════════════════════════════════════════════════════════════════
group('3d · start-delivery, and what must not call it', () {
late List<http.Request> sent;
setUp(() {
SharedPreferences.setMockInitialValues({});
sent = <http.Request>[];
MilerApi.client = MockClient((req) async {
sent.add(req);
return http.Response(
jsonEncode({'success': true, 'data': {}}),
200,
headers: {'content-type': 'application/json'},
);
});
});
tearDown(() => MilerApi.client = http.Client());
test('it is keyed on the consignment, with no body', () async {
await MilerApi.startDelivery('C-4211');
final req = sent.single;
expect(req.method, 'POST');
// The **consignment**, not the booking. They are different sequences and
// posting one where the other belongs is a 404 the rider is shown as
// success.
expect(
req.url.path,
endsWith('/miler/consignments/C-4211/start-delivery'),
);
expect(
jsonDecode(req.body),
isEmpty,
reason:
'the contract takes no parameters; inventing one is how a '
'handler starts rejecting a call that used to work',
);
expect(req.headers['Idempotency-Key'], isNotNull);
});
test('pickup-complete does not start a round by itself', () async {
await MilerApi.pickupComplete(4211, lat: 11.0, lon: 76.9);
expect(
sent.where((r) => r.url.path.contains('start-delivery')),
isEmpty,
reason:
'Picked has to stay visible until the rider sets off; a '
'pickup that starts its own round erases the rung',
);
expect(sent.single.url.path, contains('pickup-complete'));
});
test('availability is its own call and touches no lifecycle', () async {
await MilerApi.setAvailability('On_Pickup');
final req = sent.single;
expect(req.url.path, contains('/miler/availability'));
expect(
req.url.path,
isNot(contains('bookings')),
reason: 'availability is a fact about the RIDER',
);
expect(req.url.path, isNot(contains('consignments')));
});
});
// ══════════════════════════════════════════════════════════════════════
// 4 · ARRIVED PRECEDENCE
//
// The local store exists only because `reached` did not persist. It is the
// weakest record in the app and its one rule is that it must never compete
// with the server: it may speak where the server has said nothing further
// along, and nowhere else.
//
// The rule used to be three exclusions — not complete, not cancelled, not
// rejected — and `isWorkComplete` is line-dependent: on a milk run it means
// *delivered or cancelled* only. So `picked`, `outForDelivery` and
// `deliveryArrived` all passed it, and a stale local record could walk a stop
// the hub knew was on the road back to a rung the rider left an hour ago.
// ══════════════════════════════════════════════════════════════════════
group('4 · the local ARRIVED record never outranks the server', () {
setUp(() => ServiceProfile.setActive(ServiceProfile.milkMan));
tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel));
/// The shipped rule, reached through the same public surface `_fetchQueues`
/// uses — the status the server reported, parsed.
bool mayShow(String serverStatus) =>
HomepageArrivalPrecedence.localArrivalMayShow(
stopStatusFromRaw(serverStatus),
);
test('it may speak where the server has nothing further along', () {
for (final said in const [
'new',
'assigned',
'accepted',
'arrived',
'this is not a status the app knows',
]) {
expect(
mayShow(said),
isTrue,
reason: '"$said" is not a rung past arrival',
);
}
});
test('it is silent once the server has moved the stop on', () {
// Every one of these passed the old three-exclusion test on a milk run.
for (final said in const [
'picked',
'outfordelivery',
'deliveryarrived',
'delivered',
'cancelled',
'rejected',
]) {
expect(
mayShow(said),
isFalse,
reason:
'"$said" is the server\'s own answer — a local record that '
'overwrote it would walk the stop backwards',
);
}
});
test('the rung the map path writes on arrival is ARRIVED, not ACTIVE', () {
// ── Where "Picked" became "Active" ──
//
// `_startPickupNavigation` wrote `orderstatus = 'active'` onto the row.
// Two separate facts were going into one field: *which stop is live*,
// which `_activePickupOrderId` already carries and is what the LIVE mark
// reads, and *how far up the pickup ladder this stop is*.
//
// `active` was never a server state either. The provider maps it to
// `setAvailability('On_Pickup')` — a fact about the **rider** — because
// there is no rider endpoint that sets a booking Active;
// `Pickup_Scheduled` is system-set. So the word was app-generated and it
// outranked the real rung on every screen reading `orderstatus`.
//
// Pinned on the resolver, not on any widget's text.
expect(
stopStatusFromRaw('active'),
StopStatus.active,
reason: 'active parses as its own thing and is not a pickup rung',
);
expect(stopStatusFromRaw('active'), isNot(StopStatus.picked));
expect(stopStatusFromRaw('active').isPicked, isFalse);
expect(
stopStatusFromRaw('active').isFinishedPickup,
isFalse,
reason: 'an active stop is not a collected one',
);
});
test('the server\'s own arrival word resolves to the arrived rung', () {
for (final said in const [
'Arrived_At_Pickup',
'arrived_at_pickup',
'ARRIVEDATPICKUP',
'arrived',
]) {
expect(
stopStatusFromRaw(said),
StopStatus.arrived,
reason: '"$said" must land on the rung the rider was shown',
);
}
});
test('picked and active are distinct rungs, and stay distinct', () {
// The explicit regression: `Picked != Active`.
expect(stopStatusFromRaw('picked'), StopStatus.picked);
expect(stopStatusFromRaw('picked'), isNot(stopStatusFromRaw('active')));
expect(stopStatusFromRaw('picked').isPicked, isTrue);
});
test('and it is silent on a parcel route too', () {
// `isWorkComplete` differs by line, which is exactly what made the old
// blacklist leak. The rule must not depend on which line is running.
ServiceProfile.setActive(ServiceProfile.parcel);
expect(mayShow('picked'), isFalse);
expect(mayShow('accepted'), isTrue);
});
});
}