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:
209
test/deliver_identity_test.dart
Normal file
209
test/deliver_identity_test.dart
Normal file
@@ -0,0 +1,209 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'package:miler/data/accepted_store.dart';
|
||||
import 'package:miler/data/mutation_guard.dart';
|
||||
import 'package:miler/data/service_profile.dart';
|
||||
import 'package:miler/Models/stop_status.dart';
|
||||
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// THE DELIVERY WRITES AGAINST THE CONSIGNMENT, ONCE
|
||||
///
|
||||
/// Three ids exist for one job and they come from three different sequences:
|
||||
///
|
||||
/// `bookingid` the customer's booking
|
||||
/// `bookingassignmentid` the row that gave it to this rider
|
||||
/// `consignmentid` what the booking BECAME at pickup-complete
|
||||
///
|
||||
/// `accept`/`reject` key on the assignment, `reached`/`pickup-complete` on the
|
||||
/// booking, and `deliver`/`skip` on the **consignment**. Passing the wrong one
|
||||
/// does not fail loudly — the backend answers 404, or worse finds a row that
|
||||
/// happens to share the number — so these pin the mapping the delivery path
|
||||
/// depends on.
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
void main() {
|
||||
setUp(() {
|
||||
ServiceProfile.setActive(ServiceProfile.milkMan);
|
||||
SharedPreferences.setMockInitialValues({'userid': 38});
|
||||
});
|
||||
tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel));
|
||||
|
||||
group('pickup-complete preserves the real consignment id', () {
|
||||
test('the id minted at the pivot survives to the door', () async {
|
||||
// `pickup-complete` is the only moment the consignment id is guaranteed
|
||||
// to be in hand: `GET /miler/bookings` does not return it at all
|
||||
// (verified against the live API), so if the pivot does not record it,
|
||||
// the delivery leg has nothing to write against.
|
||||
await rememberConsignmentId('ORDER-1', '46');
|
||||
final ids = await getConsignmentIds();
|
||||
expect(ids['ORDER-1'], '46');
|
||||
});
|
||||
|
||||
test('a booking id is never mistaken for a consignment id', () async {
|
||||
// Booking 75 became consignment 46 — different sequences, and the
|
||||
// mapping is keyed by the ORDER, never by the number itself.
|
||||
await rememberConsignmentId('ORDER-1', '46');
|
||||
final ids = await getConsignmentIds();
|
||||
expect(
|
||||
ids['ORDER-1'],
|
||||
isNot('75'),
|
||||
reason: 'the booking id must never end up in the consignment slot',
|
||||
);
|
||||
expect(
|
||||
ids.containsKey('75'),
|
||||
isFalse,
|
||||
reason: 'the map is keyed by order, not by booking number',
|
||||
);
|
||||
});
|
||||
|
||||
test('the mapping is per rider and per line', () async {
|
||||
await rememberConsignmentId('ORDER-1', '46');
|
||||
|
||||
ServiceProfile.setActive(ServiceProfile.parcel);
|
||||
expect(
|
||||
(await getConsignmentIds())['ORDER-1'],
|
||||
isNull,
|
||||
reason: "one line's consignment mapping is not another's",
|
||||
);
|
||||
|
||||
SharedPreferences.setMockInitialValues({'userid': 99});
|
||||
ServiceProfile.setActive(ServiceProfile.milkMan);
|
||||
expect(
|
||||
(await getConsignmentIds())['ORDER-1'],
|
||||
isNull,
|
||||
reason: "another rider's handset drawer is not this one",
|
||||
);
|
||||
});
|
||||
|
||||
test('a delivered order forgets its mapping', () async {
|
||||
await rememberConsignmentId('ORDER-1', '46');
|
||||
await forgetConsignmentIds(['ORDER-1']);
|
||||
expect(
|
||||
(await getConsignmentIds())['ORDER-1'],
|
||||
isNull,
|
||||
reason: 'nothing else may be posted against a closed consignment',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('a missing consignment id is reported honestly', () {
|
||||
// Verified against production 21 Aug 2026: booking 59 is
|
||||
// `Converted_To_Consignment` and on the Deliveries tab, yet no route
|
||||
// exposes its consignment id — `/miler/bookings` carries none on any of
|
||||
// its 29 rows and `/miler/assignments` lists only 12 of those bookings.
|
||||
//
|
||||
// The app cannot deliver it. What it CAN do is tell the rider the truth:
|
||||
// a collected order with an unreachable reference is a hub problem, not
|
||||
// "you forgot to pick it up" — advice that sends him to look for a stop
|
||||
// that is not on Home, to redo work he has already done.
|
||||
test('a collected stop and an uncollected one are different states', () {
|
||||
// `Converted_To_Consignment` maps to `picked` — collected.
|
||||
expect(stopStatusFromRaw('picked').isPicked, isTrue);
|
||||
expect(stopStatusFromRaw('outfordelivery').isDeliveryLeg, isTrue);
|
||||
|
||||
// Still awaiting collection — the "pick it up from Home" advice is
|
||||
// correct only for these.
|
||||
expect(stopStatusFromRaw('assigned').isPicked, isFalse);
|
||||
expect(stopStatusFromRaw('assigned').isDeliveryLeg, isFalse);
|
||||
expect(stopStatusFromRaw('accepted').isPicked, isFalse);
|
||||
expect(stopStatusFromRaw('accepted').isDeliveryLeg, isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('one press, one write', () {
|
||||
test('a duplicate Delivered tap produces one mutation', () async {
|
||||
var calls = 0;
|
||||
Future<String> deliver() async {
|
||||
calls++;
|
||||
await Future<void>.delayed(const Duration(milliseconds: 30));
|
||||
return 'done';
|
||||
}
|
||||
|
||||
// Both taps land while the first request is still in flight — which is
|
||||
// what a gloved thumb on a moving bike actually produces.
|
||||
final first = MutationGuard.run('delivered:ORDER-1', deliver);
|
||||
final second = MutationGuard.run('delivered:ORDER-1', deliver);
|
||||
final results = await Future.wait([first, second]);
|
||||
|
||||
expect(calls, 1, reason: 'the second press must be dropped, not queued');
|
||||
expect(results.where((r) => r != null).length, 1);
|
||||
expect(
|
||||
results.contains(null),
|
||||
isTrue,
|
||||
reason: 'the dropped press returns null so the caller keeps the stop',
|
||||
);
|
||||
});
|
||||
|
||||
test('the guard frees the key so a genuine retry still works', () async {
|
||||
var calls = 0;
|
||||
Future<String> deliver() async {
|
||||
calls++;
|
||||
return 'done';
|
||||
}
|
||||
|
||||
await MutationGuard.run('delivered:ORDER-1', deliver);
|
||||
await MutationGuard.run('delivered:ORDER-1', deliver);
|
||||
expect(
|
||||
calls,
|
||||
2,
|
||||
reason: 'a retry after the first completed is a real second attempt',
|
||||
);
|
||||
});
|
||||
|
||||
test('two different stops do not block each other', () async {
|
||||
var a = 0;
|
||||
var b = 0;
|
||||
await Future.wait([
|
||||
MutationGuard.run('delivered:ORDER-1', () async {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
a++;
|
||||
return 'a';
|
||||
}),
|
||||
MutationGuard.run('delivered:ORDER-2', () async {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
b++;
|
||||
return 'b';
|
||||
}),
|
||||
]);
|
||||
expect(a, 1);
|
||||
expect(b, 1);
|
||||
});
|
||||
|
||||
test('the outcome is part of the key', () async {
|
||||
// Delivered and Skipped are different writes against the same stop; one
|
||||
// must not silently swallow the other.
|
||||
var delivered = 0;
|
||||
var skipped = 0;
|
||||
await Future.wait([
|
||||
MutationGuard.run('delivered:ORDER-1', () async {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
delivered++;
|
||||
return 'd';
|
||||
}),
|
||||
MutationGuard.run('skipped:ORDER-1', () async {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
skipped++;
|
||||
return 's';
|
||||
}),
|
||||
]);
|
||||
expect(delivered, 1);
|
||||
expect(skipped, 1);
|
||||
});
|
||||
});
|
||||
|
||||
group('completing a delivery clears the rider’s hands', () {
|
||||
test('a delivered order stops being carried', () async {
|
||||
await addCollectedOrderIds(['ORDER-1', 'ORDER-2']);
|
||||
expect(await getCollectedOrderIds(), containsAll(['ORDER-1', 'ORDER-2']));
|
||||
|
||||
await removeCollectedOrderIds(['ORDER-1']);
|
||||
final carried = await getCollectedOrderIds();
|
||||
expect(carried, isNot(contains('ORDER-1')));
|
||||
expect(
|
||||
carried,
|
||||
contains('ORDER-2'),
|
||||
reason: 'closing one stop must not drop the rest of the load',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user