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>
210 lines
8.1 KiB
Dart
210 lines
8.1 KiB
Dart
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',
|
||
);
|
||
});
|
||
});
|
||
}
|