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>
This commit is contained in:
318
test/state_contract_test.dart
Normal file
318
test/state_contract_test.dart
Normal file
@@ -0,0 +1,318 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user