Files
doormile_milderapp/test/collected_by_miler_test.dart
Thiru-tenext d7348e253f Miler rider app: surface system, visible design language, backend lifecycle
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>
2026-08-22 05:40:35 +05:30

425 lines
14 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
});
});
}