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:
174
test/delivery_queue_test.dart
Normal file
174
test/delivery_queue_test.dart
Normal file
@@ -0,0 +1,174 @@
|
||||
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/bag_manifest.dart';
|
||||
import 'package:miler/data/service_profile.dart';
|
||||
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// THE DELIVERY QUEUE, AND THE BAG THAT SURVIVES IT
|
||||
///
|
||||
/// Two promises are made at a kitchen counter and both have to hold all the way
|
||||
/// to a doorstep:
|
||||
///
|
||||
/// 1. **Five orders picked is five deliveries** — no order goes missing in the
|
||||
/// hand-off, and nothing appears that was not collected.
|
||||
/// 2. **Joe is Bag 1 and stays Bag 1** — even after Joe is delivered and the
|
||||
/// list underneath him shrinks.
|
||||
///
|
||||
/// The second one is the subtle one, and it is why bag identity is *stored* at
|
||||
/// pickup rather than recomputed from whatever is on screen. A position-derived
|
||||
/// bag number silently renames every remaining order the moment one leaves the
|
||||
/// list, which sends the rider looking for a bag that is already in somebody's
|
||||
/// hallway.
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUp(() => SharedPreferences.setMockInitialValues({}));
|
||||
tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel));
|
||||
|
||||
Map<String, dynamic> order(String id, String customer) => {
|
||||
'orderid': id,
|
||||
'pickupcustomer': customer,
|
||||
'kitchenname': 'Vidhya Kitchen',
|
||||
'kitchenid': 'K1',
|
||||
'quantity': 3,
|
||||
};
|
||||
|
||||
final five = [
|
||||
order('1', 'Joe'),
|
||||
order('2', 'Arun'),
|
||||
order('3', 'Priya'),
|
||||
order('4', 'Kumar'),
|
||||
order('5', 'Ravi'),
|
||||
];
|
||||
|
||||
group('the hand-off', () {
|
||||
test(
|
||||
'five picked orders become five deliveries, each with its bag',
|
||||
() async {
|
||||
ServiceProfile.setActive(ServiceProfile.meals);
|
||||
|
||||
// What the pickup confirmation writes when the slide lands.
|
||||
final manifest = BagManifest.forGroup(five);
|
||||
await addCollectedOrderIds([for (final l in manifest) l.orderId]);
|
||||
await saveBagLabels({for (final l in manifest) l.orderId: l.bag});
|
||||
|
||||
final carrying = await getCollectedOrderIds();
|
||||
final bags = await getBagLabels();
|
||||
|
||||
expect(carrying.length, 5, reason: 'five picked is five to deliver');
|
||||
expect(bags.length, 5, reason: 'and five bags, one each');
|
||||
expect(bags['1'], 'Bag 1');
|
||||
expect(bags['5'], 'Bag 5');
|
||||
// No aggregate anywhere: the count of deliveries and the count of bags
|
||||
// are the same set, not two numbers that can drift.
|
||||
expect(bags.keys.toSet(), carrying);
|
||||
},
|
||||
);
|
||||
|
||||
test('a bag the counter could not supply never becomes a delivery', () async {
|
||||
ServiceProfile.setActive(ServiceProfile.meals);
|
||||
|
||||
// Four handed over, one short — the shortfall path the pickup sheet owns.
|
||||
final loaded = five.where((o) => o['orderid'] != '3').toList();
|
||||
await addCollectedOrderIds([
|
||||
for (final o in loaded) o['orderid'] as String,
|
||||
]);
|
||||
await saveBagLabels({
|
||||
for (final l in BagManifest.forGroup(loaded)) l.orderId: l.bag,
|
||||
});
|
||||
await addNotLoadedOrderIds(['3']);
|
||||
|
||||
expect(await getCollectedOrderIds(), {'1', '2', '4', '5'});
|
||||
expect(await getNotLoadedOrderIds(), {'3'});
|
||||
// A short pick is its own record. It is not a skip: nobody was visited
|
||||
// and the customer did nothing.
|
||||
expect(await getSkippedOrderIds(), isEmpty);
|
||||
// And it is not carrying a bag, because it was never in the box.
|
||||
expect((await getBagLabels()).containsKey('3'), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('bag identity outlives the queue', () {
|
||||
test('delivering the first does not renumber the rest', () async {
|
||||
ServiceProfile.setActive(ServiceProfile.meals);
|
||||
await saveBagLabels({
|
||||
for (final l in BagManifest.forGroup(five)) l.orderId: l.bag,
|
||||
});
|
||||
|
||||
// Joe is delivered and leaves the carrying set.
|
||||
await addCollectedOrderIds([
|
||||
for (final o in five) o['orderid'] as String,
|
||||
]);
|
||||
await removeCollectedOrderIds(['1']);
|
||||
|
||||
final bags = await getBagLabels();
|
||||
expect(await getCollectedOrderIds(), {'2', '3', '4', '5'});
|
||||
// Arun does *not* become Bag 1 because the list in front of him shrank.
|
||||
expect(bags['2'], 'Bag 2');
|
||||
expect(bags['3'], 'Bag 3');
|
||||
expect(bags['5'], 'Bag 5');
|
||||
});
|
||||
|
||||
test('a second kitchen adds to the map rather than replacing it', () async {
|
||||
ServiceProfile.setActive(ServiceProfile.meals);
|
||||
await saveBagLabels({
|
||||
for (final l in BagManifest.forGroup(five.take(2).toList()))
|
||||
l.orderId: l.bag,
|
||||
});
|
||||
|
||||
// A different counter, its own Bag 1 and Bag 2.
|
||||
final second = [order('9', 'Meena'), order('10', 'Sai')];
|
||||
await saveBagLabels({
|
||||
for (final l in BagManifest.forGroup(second)) l.orderId: l.bag,
|
||||
});
|
||||
|
||||
final bags = await getBagLabels();
|
||||
expect(bags.length, 4, reason: 'the first load must survive the second');
|
||||
expect(bags['1'], 'Bag 1');
|
||||
expect(bags['9'], 'Bag 1', reason: 'each counter counts its own shelf');
|
||||
expect(bags['10'], 'Bag 2');
|
||||
});
|
||||
|
||||
test('a skipped delivery keeps its bag so it can be resumed', () async {
|
||||
ServiceProfile.setActive(ServiceProfile.meals);
|
||||
await saveBagLabels({
|
||||
for (final l in BagManifest.forGroup(five)) l.orderId: l.bag,
|
||||
});
|
||||
await addSkippedBooking(five[2], reason: 'Customer unavailable');
|
||||
|
||||
expect(await getSkippedOrderIds(), contains('3'));
|
||||
// And the reason survives with it — the hub needs to know why, and
|
||||
// "Failed" is not a reason.
|
||||
final skipped = await getSkippedBookings();
|
||||
expect(
|
||||
skipped.firstWhere((b) => b['orderid'] == '3')['skipreason'],
|
||||
'Customer unavailable',
|
||||
);
|
||||
// He is still carrying Priya's lunch — the bag identity is exactly what
|
||||
// he needs when he comes back to it.
|
||||
expect((await getBagLabels())['3'], 'Bag 3');
|
||||
});
|
||||
|
||||
test("a day's run is wiped so bags never leak into tomorrow", () async {
|
||||
await saveBagLabels({'1': 'Bag 1'});
|
||||
await clearServiceRunState();
|
||||
expect(await getBagLabels(), isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
test('a demo bag never outlives the build that wrote it', () async {
|
||||
// The mock day's ids are `MOCK-…` so the launch purge can find them. The
|
||||
// bag map is keyed by order id, so it is swept by the same rule — a stored
|
||||
// pairing is trusted later *because* it is stored, and a stale one is worse
|
||||
// than none.
|
||||
await saveBagLabels({'MOCK-M-1001': 'Bag 1', '77': 'Bag 2'});
|
||||
await purgeDemoRecords();
|
||||
|
||||
final bags = await getBagLabels();
|
||||
expect(bags.containsKey('MOCK-M-1001'), isFalse);
|
||||
expect(bags['77'], 'Bag 2', reason: 'real work is untouched');
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user