Design system - MilerSurface ladder (canvas → working → raised → floating) with MilerPanel as layer 1; canvas moved to #DEE3EA so white separates at 1.290:1. - Visible vocabulary applied across Home, Deliveries, Activity, Account and the sheets: hero heads (tabular numeral + small caption, clamped at 1.3x), canvas wells for anything that opens, small filled tags for shelf labels, demoted placeholders. Recorded in DESIGN_SYSTEM.md §6. - One icon family: 222 Material glyphs migrated to Lucide; none left outside lib/xpress. - Colour semantics corrected: amber only for what is genuinely owed, brand red reserved for the live stop, disabled primaries go neutral rather than pale. Data and lifecycle - lib/data/lifecycle.dart reads mutations for what they prove; route_order.dart makes admin sequence the single ordering authority; service_day.dart, and stop_area.dart rewritten against live Coimbatore addresses (digit-token stripping, city stoplist, street suffixes, stammer collapse). - countLabel states the load once, in bags. Testing - 1440 tests passing; golden shot harnesses for Home, Deliveries, Activity, sheets and verify, with test/failures/ now gitignored (diff debris). - New pins: home_gutter_test, stop_area_test, plus updated structural bounds. Note: this commit also carries pre-existing working-tree deletions that were present before this work (API_SPEC.md, README.md, demo test fixtures). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
425 lines
14 KiB
Dart
425 lines
14 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/data/api_config.dart';
|
||
import 'package:miler/data/api_status.dart';
|
||
import 'package:miler/data/consignment_state.dart';
|
||
import 'package:miler/data/miler_api.dart';
|
||
import 'package:miler/data/mock_backend.dart';
|
||
|
||
/// ─────────────────────────────────────────────────────────────────────────
|
||
/// THE RUNG BETWEEN THE COUNTER AND THE ROAD
|
||
///
|
||
/// Backend contract change, 21 Aug 2026. Until then `pickup-complete` pushed a
|
||
/// hyperlocal booking straight to `Out_for_Delivery`, so the hub's board said
|
||
/// *actively delivering* for food still on the kitchen counter, and the app
|
||
/// had nowhere honest to put the one fact only the rider knows — that he has
|
||
/// set off. **Start round** could therefore only ever be a note to self.
|
||
///
|
||
/// The pivot now stops at `Collected_By_Miler` and
|
||
/// `POST /miler/consignments/:id/start-delivery` makes the release, so the
|
||
/// press is a real write again. These pin the four pieces that has to hold:
|
||
/// the state exists and is not mistaken for a hub hold, the gate routes it to
|
||
/// the rider rather than blaming the warehouse, the release is asked of the
|
||
/// server before anything local is written, and a list row's consignment
|
||
/// status outranks the booking status that stopped being informative at
|
||
/// conversion.
|
||
/// ─────────────────────────────────────────────────────────────────────────
|
||
void main() {
|
||
TestWidgetsFlutterBinding.ensureInitialized();
|
||
|
||
late List<http.Request> sent;
|
||
|
||
/// Answers every request with [body], and records what was asked.
|
||
void stub({
|
||
Map<String, dynamic> body = const {'success': true},
|
||
int code = 200,
|
||
}) {
|
||
sent = <http.Request>[];
|
||
MilerApi.client = MockClient((req) async {
|
||
sent.add(req);
|
||
return http.Response(
|
||
json.encode(body),
|
||
code,
|
||
headers: const {'content-type': 'application/json'},
|
||
);
|
||
});
|
||
}
|
||
|
||
/// Answers each path from [routes], falling back to a bare success.
|
||
void stubRoutes(Map<String, http.Response> routes) {
|
||
sent = <http.Request>[];
|
||
MilerApi.client = MockClient((req) async {
|
||
sent.add(req);
|
||
for (final entry in routes.entries) {
|
||
if (req.url.path.endsWith(entry.key)) return entry.value;
|
||
}
|
||
return http.Response(
|
||
json.encode({'success': true}),
|
||
200,
|
||
headers: const {'content-type': 'application/json'},
|
||
);
|
||
});
|
||
}
|
||
|
||
http.Response jsonRes(Object body, [int code = 200]) => http.Response(
|
||
json.encode(body),
|
||
code,
|
||
headers: const {'content-type': 'application/json'},
|
||
);
|
||
|
||
setUp(() async {
|
||
MockBackend.enabled = false;
|
||
SharedPreferences.setMockInitialValues({'authtoken': 'test-token'});
|
||
await ApiConfig.setToken('test-token');
|
||
stub();
|
||
});
|
||
|
||
tearDown(() {
|
||
MilerApi.client = http.Client();
|
||
MockBackend.enabled = kMockBackend;
|
||
});
|
||
|
||
group('the state itself', () {
|
||
test('Collected_By_Miler parses, however it is spelled', () {
|
||
for (final raw in [
|
||
'Collected_By_Miler',
|
||
'collected_by_miler',
|
||
'COLLECTED BY MILER',
|
||
]) {
|
||
expect(
|
||
consignmentStateFromRaw(raw),
|
||
ConsignmentState.collectedByMiler,
|
||
reason:
|
||
'$raw must not fall through to unknown — an unrecognised '
|
||
'state blocks the door',
|
||
);
|
||
}
|
||
});
|
||
|
||
test('it is the rider holding the parcel, not the hub', () {
|
||
const state = ConsignmentState.collectedByMiler;
|
||
expect(
|
||
state.awaitsHub,
|
||
isFalse,
|
||
reason:
|
||
'telling a rider the hub has the parcel in his own box is the '
|
||
'exact error this state exists to prevent',
|
||
);
|
||
expect(state.needsRelease, isTrue);
|
||
expect(
|
||
state.isDeliverable,
|
||
isFalse,
|
||
reason: 'deliver is refused until start-delivery has run',
|
||
);
|
||
expect(state.isClosed, isFalse);
|
||
});
|
||
|
||
test('a skip is reportable the moment the parcel is collected', () {
|
||
// Widened server-side on 21 Aug 2026: a customer who is not home is not
|
||
// home whether or not the rider remembered to press Start round.
|
||
expect(ConsignmentState.collectedByMiler.canSkip, isTrue);
|
||
expect(ConsignmentState.outForDelivery.canSkip, isTrue);
|
||
expect(ConsignmentState.delivered.canSkip, isFalse);
|
||
expect(ConsignmentState.inwardedAtHub.canSkip, isFalse);
|
||
});
|
||
|
||
test('the gate sends the rider to Start round, not to the hub', () {
|
||
expect(
|
||
ConsignmentGate.gateFor(ConsignmentState.collectedByMiler),
|
||
DeliverGate.needsRelease,
|
||
);
|
||
expect(
|
||
ConsignmentGate.gateFor(ConsignmentState.inwardedAtHub),
|
||
DeliverGate.awaitingHub,
|
||
reason: 'the genuine hub guard must survive this change',
|
||
);
|
||
expect(
|
||
ConsignmentGate.gateFor(ConsignmentState.delivered),
|
||
DeliverGate.alreadyDelivered,
|
||
);
|
||
expect(
|
||
ConsignmentGate.gateFor(ConsignmentState.outForDelivery),
|
||
DeliverGate.deliverable,
|
||
);
|
||
});
|
||
});
|
||
|
||
group('reading the state', () {
|
||
test('one call answers it, where it used to take a history', () async {
|
||
stubRoutes({
|
||
'/miler/consignments/34': jsonRes({
|
||
'success': true,
|
||
'data': {'consignmentid': 34, 'status': 'Collected_By_Miler'},
|
||
}),
|
||
});
|
||
|
||
expect(
|
||
await ConsignmentGate.stateOf(34),
|
||
ConsignmentState.collectedByMiler,
|
||
);
|
||
expect(sent.single.method, 'GET');
|
||
expect(
|
||
sent.single.url.path.endsWith('/miler/consignments/34'),
|
||
isTrue,
|
||
reason: 'the logs walk was one round trip and a sort per stop',
|
||
);
|
||
});
|
||
|
||
test(
|
||
'the flags answer it when the word is one this build cannot read',
|
||
() async {
|
||
stubRoutes({
|
||
'/miler/consignments/34': jsonRes({
|
||
'success': true,
|
||
'data': {
|
||
'status': 'Handed_To_Locker_Bank',
|
||
'collected': false,
|
||
'out_for_delivery': false,
|
||
'delivered': true,
|
||
},
|
||
}),
|
||
});
|
||
|
||
expect(
|
||
await ConsignmentGate.stateOf(34),
|
||
ConsignmentState.delivered,
|
||
reason:
|
||
'delivered is read first: mistaking a finished delivery for an '
|
||
'open one makes a rider post work he has already done',
|
||
);
|
||
},
|
||
);
|
||
|
||
test('an old deployment still answers, through the history', () async {
|
||
// A rider mid-round on this build meeting a server that has not shipped
|
||
// the route yet must not be stranded at a door.
|
||
stubRoutes({
|
||
'/miler/consignments/logs/34': jsonRes([
|
||
{'historyid': 2, 'eventstatus': 'Out_for_Delivery'},
|
||
{'historyid': 1, 'eventstatus': 'Created'},
|
||
]),
|
||
'/miler/consignments/34': jsonRes({
|
||
'success': false,
|
||
'message': 'not found',
|
||
}, 404),
|
||
});
|
||
|
||
expect(
|
||
await ConsignmentGate.stateOf(34),
|
||
ConsignmentState.outForDelivery,
|
||
);
|
||
expect(
|
||
sent.map((r) => r.url.path.endsWith('/miler/consignments/logs/34')),
|
||
contains(true),
|
||
);
|
||
});
|
||
|
||
test('a read that fails is not evidence of anything', () async {
|
||
stub(body: const {'success': false, 'message': 'gateway'}, code: 502);
|
||
expect(await ConsignmentGate.stateOf(34), ConsignmentState.unknown);
|
||
expect(
|
||
ConsignmentGate.gateFor(ConsignmentState.unknown),
|
||
DeliverGate.unknown,
|
||
reason: 'blocking on a timed-out GET strands a rider at a door',
|
||
);
|
||
});
|
||
|
||
test('nothing is asked about a consignment that does not exist', () async {
|
||
expect(await ConsignmentGate.stateOf(''), ConsignmentState.unknown);
|
||
expect(await ConsignmentGate.stateOf('0'), ConsignmentState.unknown);
|
||
expect(sent, isEmpty);
|
||
});
|
||
});
|
||
|
||
group('the release', () {
|
||
test('start-delivery posts against the consignment', () async {
|
||
await MilerApi.startDelivery(34);
|
||
expect(sent.single.method, 'POST');
|
||
expect(
|
||
sent.single.url.path.endsWith('/miler/consignments/34/start-delivery'),
|
||
isTrue,
|
||
);
|
||
});
|
||
|
||
test('it carries an idempotency key, stable across a retry', () async {
|
||
await MilerApi.startDelivery(34);
|
||
final first = sent.single.headers['Idempotency-Key'];
|
||
stub();
|
||
await MilerApi.startDelivery(34);
|
||
expect(first, isNotNull);
|
||
expect(
|
||
sent.single.headers['Idempotency-Key'],
|
||
first,
|
||
reason:
|
||
'a key that changes per press is not idempotency — a dropped '
|
||
'response would release the round twice',
|
||
);
|
||
});
|
||
|
||
test('a different consignment gets a different key', () async {
|
||
await MilerApi.startDelivery(34);
|
||
final first = sent.single.headers['Idempotency-Key'];
|
||
stub();
|
||
await MilerApi.startDelivery(35);
|
||
expect(sent.single.headers['Idempotency-Key'], isNot(first));
|
||
});
|
||
|
||
test(
|
||
'the write that is already running is waited out, not reported',
|
||
() async {
|
||
var calls = 0;
|
||
sent = <http.Request>[];
|
||
MilerApi.client = MockClient((req) async {
|
||
sent.add(req);
|
||
calls++;
|
||
// The first attempt's response was lost; the server is running it.
|
||
if (calls == 1) {
|
||
return jsonRes({
|
||
'success': false,
|
||
'code': 'IDEMPOTENCY_IN_PROGRESS',
|
||
'message': 'in progress',
|
||
}, 409);
|
||
}
|
||
return jsonRes({
|
||
'success': true,
|
||
'data': {'status': 'Out_for_Delivery'},
|
||
});
|
||
});
|
||
|
||
final res = await MilerApi.startDelivery(34);
|
||
expect(
|
||
res.ok,
|
||
isTrue,
|
||
reason:
|
||
'telling a rider his round failed while it is in the act of '
|
||
'succeeding sends him to press the same refusal again',
|
||
);
|
||
expect(calls, 2);
|
||
},
|
||
);
|
||
|
||
test('a failure names the code, not just the prose', () async {
|
||
stub(
|
||
body: const {
|
||
'success': false,
|
||
'code': 'INVALID_STATE',
|
||
'message': 'consignment is not collected',
|
||
},
|
||
code: 400,
|
||
);
|
||
final res = await MilerApi.startDelivery(34);
|
||
expect(res.ok, isFalse);
|
||
expect(
|
||
res.isInvalidState,
|
||
isTrue,
|
||
reason: 'branching on message text breaks the day it is reworded',
|
||
);
|
||
});
|
||
});
|
||
|
||
group('the list row carries the delivery half', () {
|
||
Map<String, dynamic> booking(String status, {String? consignment}) => {
|
||
'bookingid': 59,
|
||
'bookingreference': 'DM-BK-501CB551-45130',
|
||
'status': status,
|
||
if (consignment != null) 'consignmentstatus': consignment,
|
||
'consignmentid': 34,
|
||
};
|
||
|
||
test('the consignment id arrives on the row, so nothing has to hunt', () {
|
||
// The 17-of-29 blocker: bookings carried no consignment id and
|
||
// /miler/assignments listed only 12 of them, so for 17 collected stops
|
||
// the delivery reference was unobtainable by any route.
|
||
final stop = ApiConfig.pickupFromBooking(
|
||
booking('Converted_To_Consignment', consignment: 'Collected_By_Miler'),
|
||
);
|
||
expect(stop['consignmentid'], '34');
|
||
});
|
||
|
||
test('a delivered consignment is not an open delivery stop', () {
|
||
final stop = ApiConfig.pickupFromBooking(
|
||
booking('Converted_To_Consignment', consignment: 'Delivered'),
|
||
);
|
||
expect(
|
||
stop['orderstatus'],
|
||
'delivered',
|
||
reason:
|
||
'the booking word is terminal at conversion and says the same '
|
||
'thing for a parcel in the box and one handed over an hour ago',
|
||
);
|
||
});
|
||
|
||
test('collected and on-the-road are told apart on the list', () {
|
||
expect(
|
||
ApiConfig.pickupFromBooking(
|
||
booking(
|
||
'Converted_To_Consignment',
|
||
consignment: 'Collected_By_Miler',
|
||
),
|
||
)['orderstatus'],
|
||
'picked',
|
||
);
|
||
expect(
|
||
ApiConfig.pickupFromBooking(
|
||
booking('Converted_To_Consignment', consignment: 'Out_for_Delivery'),
|
||
)['orderstatus'],
|
||
'outfordelivery',
|
||
);
|
||
});
|
||
|
||
test('a hub-side consignment keeps the booking’s own word', () {
|
||
// Not this rider's parcel to act on, and it has no rung on his card —
|
||
// inventing one would draw somebody else's warehouse on his screen.
|
||
final stop = ApiConfig.pickupFromBooking(
|
||
booking('Converted_To_Consignment', consignment: 'Inwarded_at_Hub'),
|
||
);
|
||
expect(stop['orderstatus'], 'picked');
|
||
});
|
||
|
||
test('a row with no consignment status behaves exactly as before', () {
|
||
expect(
|
||
ApiConfig.pickupFromBooking(booking('Pickup_Scheduled'))['orderstatus'],
|
||
'accepted',
|
||
);
|
||
expect(
|
||
ApiConfig.pickupFromBooking(
|
||
booking('Converted_To_Consignment'),
|
||
)['orderstatus'],
|
||
'picked',
|
||
);
|
||
});
|
||
});
|
||
|
||
group('arrival is a rung the hub can see', () {
|
||
test('Arrived_At_Pickup is the status reached writes', () {
|
||
expect(
|
||
BookingStatus.parse('Arrived_At_Pickup'),
|
||
BookingStatus.arrivedAtPickup,
|
||
);
|
||
expect(ApiConfig.legacyStatusFromNew('Arrived_At_Pickup'), 'arrived');
|
||
});
|
||
|
||
test('the older At_Customer spelling means the same thing', () {
|
||
expect(BookingStatus.parse('At_Customer'), BookingStatus.arrivedAtPickup);
|
||
expect(ApiConfig.legacyStatusFromNew('At_Customer'), 'arrived');
|
||
});
|
||
|
||
test('arriving is not collecting', () {
|
||
expect(
|
||
BookingStatus.arrivedAtPickup.isCollected,
|
||
isFalse,
|
||
reason:
|
||
'the work boundary must not move a bag the rider has not '
|
||
'picked up yet onto the delivery tab',
|
||
);
|
||
expect(BookingStatus.arrivedAtPickup.isOpen, isTrue);
|
||
expect(BookingStatus.arrivedAtPickup.canCancel, isTrue);
|
||
});
|
||
});
|
||
}
|