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>
319 lines
11 KiB
Dart
319 lines
11 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/lifecycle.dart';
|
|
import 'package:miler/data/miler_api.dart';
|
|
import 'package:miler/data/mock_backend.dart';
|
|
|
|
/// ─────────────────────────────────────────────────────────────────────────
|
|
/// THE RIDER'S RUNG AND THE OFFICE'S RUNG ARE THE SAME RUNG
|
|
///
|
|
/// ── The regression, measured against production 21 Aug 2026 ──
|
|
///
|
|
/// ```
|
|
/// BEFORE GET /miler/bookings booking 78 = Miler_Assigned
|
|
/// CALL POST /miler/bookings/78/reached
|
|
/// → 200 {"success":true,"data":{"bookingid":78,
|
|
/// "status":"Miler_Assigned"}}
|
|
/// AFTER GET /miler/bookings booking 78 = Miler_Assigned
|
|
/// ```
|
|
///
|
|
/// `reached` reports success and writes nothing. The app read only `ok`, drew
|
|
/// ARRIVED, and the hub never heard about it — so a rider stood at a kitchen
|
|
/// with a screen saying one thing and an operator watching a board that said
|
|
/// another, and neither could tell the other was wrong.
|
|
///
|
|
/// The rule these pin: **a 200 proves the call was accepted, never that the
|
|
/// state moved.** The only proof of a state change is the state in the
|
|
/// response. Every rung the app draws is now read that way, which is also what
|
|
/// makes the app correct under both of the backend's two live pickup
|
|
/// lifecycles without knowing which one is switched on.
|
|
/// ─────────────────────────────────────────────────────────────────────────
|
|
void main() {
|
|
TestWidgetsFlutterBinding.ensureInitialized();
|
|
|
|
late List<http.Request> sent;
|
|
|
|
http.Response jsonRes(Object body, [int code = 200]) => http.Response(
|
|
json.encode(body),
|
|
code,
|
|
headers: const {'content-type': 'application/json'},
|
|
);
|
|
|
|
void stub(Map<String, http.Response> routes) {
|
|
sent = <http.Request>[];
|
|
MilerApi.client = MockClient((req) async {
|
|
sent.add(req);
|
|
for (final e in routes.entries) {
|
|
if (req.url.path.endsWith(e.key)) return e.value;
|
|
}
|
|
return jsonRes({'success': true});
|
|
});
|
|
}
|
|
|
|
setUp(() async {
|
|
MockBackend.enabled = false;
|
|
SharedPreferences.setMockInitialValues({'authtoken': 't'});
|
|
await ApiConfig.setToken('t');
|
|
stub(const {});
|
|
});
|
|
|
|
tearDown(() {
|
|
MilerApi.client = http.Client();
|
|
MockBackend.enabled = kMockBackend;
|
|
});
|
|
|
|
group('reached — arrival is only arrival if the server says so', () {
|
|
test('the production no-op is reported, not believed', () async {
|
|
// The exact body production returns today.
|
|
stub({
|
|
'/reached': jsonRes({
|
|
'success': true,
|
|
'data': {'bookingid': 78, 'status': 'Miler_Assigned'},
|
|
}),
|
|
});
|
|
|
|
final t = MilerLifecycle.reached(await MilerApi.reached(78));
|
|
|
|
expect(
|
|
t.isConfirmed,
|
|
isFalse,
|
|
reason:
|
|
'a 200 that leaves the booking on its old rung is not an '
|
|
'arrival, however cheerful the envelope is',
|
|
);
|
|
expect(t.isUnconfirmed, isTrue);
|
|
expect(t.bookingStatus, BookingStatus.milerAssigned);
|
|
expect(
|
|
t.evidence,
|
|
contains('Miler_Assigned'),
|
|
reason: 'the log line has to be pasteable into a backend ticket',
|
|
);
|
|
});
|
|
|
|
test('Arrived_At_Pickup is what confirms it', () async {
|
|
stub({
|
|
'/reached': jsonRes({
|
|
'success': true,
|
|
'data': {'bookingid': 78, 'status': 'Arrived_At_Pickup'},
|
|
}),
|
|
});
|
|
|
|
final t = MilerLifecycle.reached(await MilerApi.reached(78));
|
|
expect(t.isConfirmed, isTrue);
|
|
expect(t.bookingStatus, BookingStatus.arrivedAtPickup);
|
|
expect(ApiConfig.legacyStatusFromNew('Arrived_At_Pickup'), 'arrived');
|
|
});
|
|
|
|
test('a response naming no status at all is not confirmation', () async {
|
|
stub({
|
|
'/reached': jsonRes({'success': true, 'data': {}}),
|
|
});
|
|
final t = MilerLifecycle.reached(await MilerApi.reached(78));
|
|
expect(t.isUnconfirmed, isTrue);
|
|
expect(t.evidence, contains('no status'));
|
|
});
|
|
|
|
test('a refusal is a refusal, with its code', () async {
|
|
stub({
|
|
'/reached': jsonRes({
|
|
'success': false,
|
|
'code': 'INVALID_STATE',
|
|
'message': 'not assigned to you',
|
|
}, 400),
|
|
});
|
|
final t = MilerLifecycle.reached(await MilerApi.reached(78));
|
|
expect(t.isRefused, isTrue);
|
|
expect(t.code, 'INVALID_STATE');
|
|
});
|
|
});
|
|
|
|
group('pickup-complete — both live lifecycles, read from the response', () {
|
|
test('the new flow stops at Collected_By_Miler', () async {
|
|
stub({
|
|
'/pickup-complete': jsonRes({
|
|
'success': true,
|
|
'data': {
|
|
'booking_status': 'Converted_To_Consignment',
|
|
'consignment_id': 46,
|
|
'consignmentstatus': 'Collected_By_Miler',
|
|
'next_action': 'start_delivery',
|
|
},
|
|
}),
|
|
});
|
|
|
|
final t = MilerLifecycle.pickupComplete(
|
|
await MilerApi.pickupComplete(75),
|
|
);
|
|
|
|
expect(t.isConfirmed, isTrue);
|
|
expect(t.consignmentState, ConsignmentState.collectedByMiler);
|
|
expect(t.consignmentId, '46');
|
|
expect(t.awaitsStartDelivery, isTrue);
|
|
expect(
|
|
t.isCompatibilityMode,
|
|
isFalse,
|
|
reason: 'the rider holds it — Start delivery is what makes it active',
|
|
);
|
|
// The rung the rider and the office both see: collected, not delivering.
|
|
expect(
|
|
ApiConfig.legacyStatusFromConsignment('Collected_By_Miler'),
|
|
'picked',
|
|
);
|
|
});
|
|
|
|
test('compatibility mode is detected, not assumed', () async {
|
|
// What production returns today, with the server flag off.
|
|
stub({
|
|
'/pickup-complete': jsonRes({
|
|
'success': true,
|
|
'data': {
|
|
'booking_status': 'Converted_To_Consignment',
|
|
'consignment_id': 46,
|
|
'consignmentstatus': 'Out_for_Delivery',
|
|
},
|
|
}),
|
|
});
|
|
|
|
final t = MilerLifecycle.pickupComplete(
|
|
await MilerApi.pickupComplete(75),
|
|
);
|
|
|
|
expect(t.isConfirmed, isTrue);
|
|
expect(t.consignmentState, ConsignmentState.outForDelivery);
|
|
expect(
|
|
t.isCompatibilityMode,
|
|
isTrue,
|
|
reason:
|
|
'this is the fact the app must surface rather than paper over '
|
|
'— it is why the console shows Active for a fresh pickup',
|
|
);
|
|
expect(t.awaitsStartDelivery, isFalse);
|
|
// And the app must agree with the console rather than say "picked".
|
|
expect(
|
|
ApiConfig.legacyStatusFromConsignment('Out_for_Delivery'),
|
|
'outfordelivery',
|
|
);
|
|
});
|
|
|
|
test('no feature flag is mirrored — the same build reads both', () async {
|
|
for (final (status, expected) in const [
|
|
('Collected_By_Miler', ConsignmentState.collectedByMiler),
|
|
('Out_for_Delivery', ConsignmentState.outForDelivery),
|
|
]) {
|
|
stub({
|
|
'/pickup-complete': jsonRes({
|
|
'success': true,
|
|
'data': {'consignment_id': 46, 'consignmentstatus': status},
|
|
}),
|
|
});
|
|
final t = MilerLifecycle.pickupComplete(
|
|
await MilerApi.pickupComplete(75),
|
|
);
|
|
expect(t.consignmentState, expected);
|
|
}
|
|
});
|
|
|
|
test('next_action alone is enough to place the new flow', () async {
|
|
stub({
|
|
'/pickup-complete': jsonRes({
|
|
'success': true,
|
|
'data': {'consignment_id': 46, 'next_action': 'start_delivery'},
|
|
}),
|
|
});
|
|
final t = MilerLifecycle.pickupComplete(
|
|
await MilerApi.pickupComplete(75),
|
|
);
|
|
expect(t.consignmentState, ConsignmentState.collectedByMiler);
|
|
expect(t.awaitsStartDelivery, isTrue);
|
|
});
|
|
|
|
test('a pivot that minted no consignment is not confirmed', () async {
|
|
stub({
|
|
'/pickup-complete': jsonRes({'success': true, 'data': {}}),
|
|
});
|
|
final t = MilerLifecycle.pickupComplete(
|
|
await MilerApi.pickupComplete(75),
|
|
);
|
|
expect(
|
|
t.isConfirmed,
|
|
isFalse,
|
|
reason:
|
|
'without an id there is nothing to deliver against, whatever '
|
|
'the words say',
|
|
);
|
|
});
|
|
});
|
|
|
|
group('the ladder, rung by rung', () {
|
|
test('Accepted is where the rider starts', () {
|
|
expect(ApiConfig.legacyStatusFromNew('Pickup_Scheduled'), 'accepted');
|
|
expect(BookingStatus.parse('Pickup_Scheduled').isOpen, isTrue);
|
|
expect(BookingStatus.parse('Pickup_Scheduled').isCollected, isFalse);
|
|
});
|
|
|
|
test('Arrived does not move the work boundary', () {
|
|
expect(BookingStatus.arrivedAtPickup.isCollected, isFalse);
|
|
expect(BookingStatus.arrivedAtPickup.isOpen, isTrue);
|
|
});
|
|
|
|
test('start-delivery is only for a collected consignment', () {
|
|
expect(ConsignmentState.collectedByMiler.needsRelease, isTrue);
|
|
expect(
|
|
ConsignmentState.outForDelivery.needsRelease,
|
|
isFalse,
|
|
reason:
|
|
'calling start-delivery on an already-released consignment is '
|
|
'a 4xx per press in the backend log',
|
|
);
|
|
expect(ConsignmentState.inwardedAtHub.needsRelease, isFalse);
|
|
});
|
|
|
|
test('only Out_for_Delivery is deliverable', () {
|
|
expect(ConsignmentState.outForDelivery.isDeliverable, isTrue);
|
|
expect(ConsignmentState.collectedByMiler.isDeliverable, isFalse);
|
|
expect(ConsignmentState.inwardedAtHub.isDeliverable, isFalse);
|
|
expect(
|
|
ConsignmentGate.gateFor(ConsignmentState.collectedByMiler),
|
|
DeliverGate.needsRelease,
|
|
);
|
|
expect(
|
|
ConsignmentGate.gateFor(ConsignmentState.outForDelivery),
|
|
DeliverGate.deliverable,
|
|
);
|
|
});
|
|
|
|
test('Delivered is terminal and reached only through deliver', () {
|
|
expect(ConsignmentState.delivered.isDelivered, isTrue);
|
|
expect(ConsignmentState.delivered.isDeliverable, isFalse);
|
|
expect(
|
|
ConsignmentGate.gateFor(ConsignmentState.delivered),
|
|
DeliverGate.alreadyDelivered,
|
|
);
|
|
});
|
|
|
|
test('the whole ladder maps to rider words with no enum leaking', () {
|
|
// What the rider sees at each rung. Never the API's own spelling.
|
|
expect(ApiConfig.legacyStatusFromNew('Miler_Assigned'), 'assigned');
|
|
expect(ApiConfig.legacyStatusFromNew('Pickup_Scheduled'), 'accepted');
|
|
expect(ApiConfig.legacyStatusFromNew('Arrived_At_Pickup'), 'arrived');
|
|
expect(
|
|
ApiConfig.legacyStatusFromConsignment('Collected_By_Miler'),
|
|
'picked',
|
|
);
|
|
expect(
|
|
ApiConfig.legacyStatusFromConsignment('Out_for_Delivery'),
|
|
'outfordelivery',
|
|
);
|
|
expect(ApiConfig.legacyStatusFromConsignment('Delivered'), 'delivered');
|
|
});
|
|
});
|
|
}
|