Files
doormile_milderapp/test/milk_run_test.dart
Thiru-tenext d7348e253f 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>
2026-08-22 05:40:35 +05:30

595 lines
22 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:miler/Models/stop_status.dart';
import 'package:miler/data/accepted_store.dart';
import 'package:miler/data/api_config.dart';
import 'package:miler/data/milk_run.dart';
import 'package:miler/data/service_profile.dart';
import 'package:miler/views/Dashboard/pickups/stop_type.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// THE MILK RUN, END TO END
///
/// ```
/// 6 orders ─ accept ─┬─ Kitchen 1 ×5 ─ arrived ─ picked ─▶ DELIVERIES
/// └─ Kitchen 2 ×5 ─ arrived ─ picked ─▶ DELIVERIES
/// │
/// arrived ─ delivered ─▶ ACTIVITY
/// ```
///
/// Three units, three different shapes, and the file exists because they were
/// being conflated:
///
/// • **Acceptance is per order** and crosses kitchens freely.
/// • **Pickup is per kitchen** — one ride, one counter, one handover — so a
/// selection must never span two of them.
/// • **Delivery is per order** again, each bag to its own door.
///
/// And the lifecycle rule underneath all of it: `picked` is the *middle* of a
/// milk-run morning. An order collected at a kitchen is a live delivery, not
/// history — it goes to DELIVERIES, and only `delivered` sends it to Activity.
/// ─────────────────────────────────────────────────────────────────────────
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() {
SharedPreferences.setMockInitialValues({});
ServiceProfile.setActive(ServiceProfile.milkMan);
});
tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel));
/// One assigned stop, in the shape the adapter produces.
Map<String, dynamic> stop({
required String id,
required String kitchen,
String status = 'assigned',
String? consignmentId,
}) => {
'orderid': id,
'pickupid': int.parse(id.replaceAll(RegExp(r'\D'), '')),
'orderstatus': status,
'sourceid': kitchen.hashCode.toString(),
'sourcename': kitchen,
'pickupcustomer': 'Customer $id',
'pickuplat': 11.01,
'pickuplon': 76.95,
if (consignmentId != null) 'consignmentid': consignmentId,
};
/// Two kitchens, five orders each — the brief's own example.
List<Map<String, dynamic>> tenOrders() => [
for (var i = 1; i <= 5; i++) stop(id: '100$i', kitchen: 'Kitchen 1'),
for (var i = 6; i <= 10; i++) stop(id: '10$i', kitchen: 'Kitchen 2'),
];
Set<String> idsOf(Iterable<Map<String, dynamic>> stops) =>
stops.map((s) => s['orderid'] as String).toSet();
group('pickup is bounded by the kitchen', () {
// ── The three units ──
//
// Acceptance is per order and crosses kitchens freely. Pickup does not: a
// selection spanning two counters would post "arrived" for a shop the rider
// is nowhere near and mark bags collected that are still in another
// kitchen's warmer. Delivery is per order again.
test('orders from one kitchen group together', () {
final stops = tenOrders();
final vidhya = MilkRun.sameSourceAs(stops.first, stops);
expect(vidhya.length, 5);
expect(vidhya.every((s) => s['sourcename'] == 'Kitchen 1'), isTrue);
});
test('and orders from another kitchen are not swept in', () {
final stops = tenOrders();
final vidhya = MilkRun.sameSourceAs(stops.first, stops);
expect(
vidhya.any((s) => s['sourcename'] == 'Kitchen 2'),
isFalse,
reason: 'Kitchen 2 is a different ride and a different counter',
);
expect(MilkRun.sameSource(stops.first, stops.last), isFalse);
});
test('the stable id decides, not the display name', () {
// Two kitchens can share a name across areas; the id is what the hub
// controls, so it is what the app compares.
final a = {'orderid': '1', 'sourceid': 'K1', 'sourcename': 'Vidhya'};
final b = {'orderid': '2', 'sourceid': 'K2', 'sourcename': 'Vidhya'};
expect(MilkRun.sameSource(a, b), isFalse);
});
test('a name-only payload still groups', () {
final a = {'orderid': '1', 'sourcename': 'Vidhya Kitchen'};
final b = {'orderid': '2', 'sourcename': 'vidhya kitchen'};
expect(MilkRun.sameSource(a, b), isTrue);
});
test(
'no source at all is one honest bucket, never a fabricated kitchen',
() {
final a = {'orderid': '1'};
final b = {'orderid': '2'};
expect(MilkRun.sourceKeyOf(a), 'none');
expect(MilkRun.sameSource(a, b), isTrue);
expect(MilkRun.sourceNameOf(a), '');
},
);
test('a zero pickuplocationid is not an id', () {
// The adapter emits '0' for an absent location; treating it as a real
// key would put every unsourced order in one bogus group.
expect(MilkRun.sourceKeyOf({'pickuplocationid': '0'}), 'none');
expect(MilkRun.sourceKeyOf({'pickuplocationid': '7'}), 'id:7');
});
test('collected work is NOT held back for the other kitchens', () {
// The rule §24 turns on: three Kitchen-1 bags collected at 11:40 are
// three live deliveries, even though Kitchen 2 has not been visited.
final stops = tenOrders();
final k1 = idsOf(stops.take(5));
for (final s in stops.take(5)) {
expect(
k1.contains(MilkRun.idOf(s)),
isTrue,
reason: 'on the deliveries tab the moment it is in his hands',
);
}
// And the rest are still his to collect.
final outstanding = MilkRun.outstandingPickups(
stops,
acceptedIds: idsOf(stops),
collectedIds: k1,
);
expect(outstanding.length, 5);
expect(outstanding.every((s) => s['sourcename'] == 'Kitchen 2'), isTrue);
});
});
group('where Navigate goes', () {
test('to the kitchen before collection', () {
final s = stop(id: '1001', kitchen: 'Kitchen 1');
expect(MilkRun.navigatesToCustomer(s), isFalse);
final target = MilkRun.navigationTarget(s);
expect(target?.lat, 11.01);
expect(MilkRun.navigationLabel(s), 'Kitchen 1');
});
test(
'a nameless pickup is titled by its neighbourhood, never "pickup"',
() {
// The live backend sends no sourcename today, and the literal 'pickup'
// this used to fall back to became the 24sp headline of the rider's
// first group — the exact regression the project rule forbids.
final s = {
'orderid': '1001',
'type': 'pickup',
'pickupaddress': '12/4, RS Puram, Coimbatore, Tamil Nadu 641002',
};
expect(
MilkRun.navigationLabel(s),
'RS Puram',
reason:
'the first non-numeric address component is a place; '
'"pickup" is a leg description',
);
// A component carrying its own house number is titled by the street.
expect(
MilkRun.navigationLabel({
'orderid': '1002',
'type': 'pickup',
'pickupaddress': '124 Gandhipuram Main Road, Coimbatore 641012',
}),
'Gandhipuram Main Road',
);
// No address either → a capitalised noun, still never the raw leg word.
expect(
MilkRun.navigationLabel({'orderid': '1', 'type': 'pickup'}),
'Pickup',
);
},
);
test('to the customer after collection', () {
final s = stop(id: '1001', kitchen: 'Kitchen 1')
..['droplat'] = 11.05
..['droplon'] = 76.99;
expect(MilkRun.navigatesToCustomer(s, collectedIds: {'1001'}), isTrue);
final target = MilkRun.navigationTarget(s, collectedIds: {'1001'});
expect(target?.lat, 11.05);
expect(target?.lng, 76.99);
expect(
MilkRun.navigationLabel(s, collectedIds: {'1001'}),
'Customer 1001',
);
});
test('the backend status alone flips it, without the local set', () {
final s = stop(id: '1001', kitchen: 'Kitchen 1', status: 'picked')
..['droplat'] = 11.05
..['droplon'] = 76.99;
expect(MilkRun.navigatesToCustomer(s), isTrue);
expect(MilkRun.navigationTarget(s)?.lat, 11.05);
});
test('null rather than the wrong end when coordinates are missing', () {
// A Navigate button that quietly opens the other end of the journey is
// worse than a disabled one: the rider only finds out on arrival.
final s = stop(id: '1001', kitchen: 'Kitchen 1', status: 'picked');
expect(MilkRun.navigatesToCustomer(s), isTrue);
expect(MilkRun.navigationTarget(s), isNull);
});
test('logistics never navigates to a customer', () {
ServiceProfile.setActive(ServiceProfile.parcel);
final s = stop(id: '2001', kitchen: '', status: 'picked')
..['droplat'] = 11.05
..['droplon'] = 76.99;
expect(
MilkRun.navigatesToCustomer(s),
isFalse,
reason: 'its collected parcel goes to a hub; somebody else delivers it',
);
expect(MilkRun.navigationTarget(s)?.lat, 11.01);
});
});
group('the ladder a stop climbs', () {
test('accepted → arrived → picked → out for delivery → delivered', () {
final s = stop(id: '1001', kitchen: 'Kitchen 1');
expect(
MilkRun.stageOf(s, acceptedIds: const {}, collectedIds: const {}),
StopStatus.assigned,
);
expect(
MilkRun.stageOf(s, acceptedIds: {'1001'}, collectedIds: const {}),
StopStatus.accepted,
);
s['orderstatus'] = 'arrived';
expect(
MilkRun.stageOf(s, acceptedIds: {'1001'}, collectedIds: const {}),
StopStatus.arrived,
);
expect(
MilkRun.stageOf(s, acceptedIds: {'1001'}, collectedIds: {'1001'}),
StopStatus.picked,
);
expect(
MilkRun.stageOf(
s,
acceptedIds: {'1001'},
collectedIds: {'1001'},
outForDeliveryIds: {'1001'},
),
StopStatus.outForDelivery,
);
expect(
MilkRun.stageOf(
s,
acceptedIds: {'1001'},
collectedIds: {'1001'},
outForDeliveryIds: {'1001'},
deliveredIds: {'1001'},
),
StopStatus.delivered,
);
});
test('picked up is never mistaken for delivered', () {
// The crate is in his hands at 11:50 and the last box goes through a door
// at 13:20. Reporting the first as the second bills the client for ninety
// minutes of work that has not happened.
expect(StopStatus.picked.isDelivered, isFalse);
expect(StopStatus.picked.isTerminal, isTrue);
expect(StopStatus.delivered.isDelivered, isTrue);
expect(StopStatus.outForDelivery.isDeliveryLeg, isTrue);
expect(StopStatus.picked.isDeliveryLeg, isFalse);
});
test(
'the pickup arrival and the delivery arrival are different states',
() {
expect(stopStatusFromRaw('arrived'), StopStatus.arrived);
expect(
stopStatusFromRaw('delivery_arrived'),
StopStatus.deliveryArrived,
);
expect(
StopStatus.arrived,
isNot(StopStatus.deliveryArrived),
reason: 'one is a kitchen counter, the other a customer door',
);
},
);
test(
'the backend spelling of out-for-delivery folds to the same state',
() {
for (final raw in [
'Out_for_Delivery',
'out for delivery',
'outfordelivery',
]) {
expect(
stopStatusFromRaw(raw),
StopStatus.outForDelivery,
reason: raw,
);
}
},
);
test('each rung offers the button that belongs to it', () {
expect(MilkRun.nextActionLabel(StopStatus.accepted), 'Arrived');
expect(MilkRun.nextActionLabel(StopStatus.arrived), 'Picked up');
expect(MilkRun.nextActionLabel(StopStatus.outForDelivery), 'Arrived');
expect(MilkRun.nextActionLabel(StopStatus.deliveryArrived), 'Delivered');
expect(MilkRun.nextActionLabel(StopStatus.delivered), isNull);
});
});
group('a collected order is on DELIVERIES, never Activity', () {
// ── The bug this group exists for ──
//
// `picked` at a kitchen was being filed as completed work, so the order
// left the deliveries list and appeared in the rider's history before he
// had left the counter. Activity's filter and the deliveries list read the
// same test from opposite sides, so it broke both at once.
test('picked is not finished work on a milk run', () {
ServiceProfile.setActive(ServiceProfile.milkMan);
expect(StopStatus.picked.isWorkComplete, isFalse);
expect(StopStatus.delivered.isWorkComplete, isTrue);
expect(StopStatus.cancelled.isWorkComplete, isTrue);
});
test('picked IS finished work on logistics', () {
ServiceProfile.setActive(ServiceProfile.parcel);
expect(
StopStatus.picked.isWorkComplete,
isTrue,
reason: 'collecting the parcel is the job; the hub takes it from there',
);
});
test('a picked milk-run stop is still live work', () {
ServiceProfile.setActive(ServiceProfile.milkMan);
final s = stop(id: '1001', kitchen: 'Kitchen 1', status: 'picked');
expect(stopStatusOf(s).isWorkComplete, isFalse);
expect(stopStatusOf(s).isPicked, isTrue);
});
});
group('the round, on the device', () {
test('releasing marks the load and finishing clears it', () async {
expect(await getOutForDeliveryOrderIds(), isEmpty);
await addOutForDeliveryOrderIds(['1001', '1002', '1003']);
expect(await getOutForDeliveryOrderIds(), {'1001', '1002', '1003'});
await removeOutForDeliveryOrderIds(['1002']);
expect(await getOutForDeliveryOrderIds(), {'1001', '1003'});
});
test('a finished day leaves nothing behind for tomorrow', () async {
await addCollectedOrderIds(['1001']);
await addOutForDeliveryOrderIds(['1001']);
await addNotLoadedOrderIds(['1002']);
await clearServiceRunState();
expect(await getCollectedOrderIds(), isEmpty);
expect(await getOutForDeliveryOrderIds(), isEmpty);
expect(await getNotLoadedOrderIds(), isEmpty);
});
test('a milk-run stop is filed as delivered, not picked', () async {
await addCompletedBookings([
{'orderid': '1001', 'pickupcustomer': 'A'},
]);
final done = await getCompletedBookings();
expect(done.single['orderstatus'], 'delivered');
expect(stopStatusOf(done.single), StopStatus.delivered);
});
test('a logistics stop is filed as picked', () async {
ServiceProfile.setActive(ServiceProfile.parcel);
await addCompletedBookings([
{'orderid': '2001', 'pickupcustomer': 'B'},
]);
final done = await getCompletedBookings();
expect(done.single['orderstatus'], 'picked');
});
});
group('the consignment id survives the trip from the API', () {
test('the adapter carries source, pincodes and consignment through', () {
final stop = ApiConfig.pickupFromBooking({
'bookingid': 42,
'bookingreference': 'DM-42',
'status': 'Converted_To_Consignment',
'pickupaddress': 'Kitchen 1, RS Puram',
'pickuppincode': '641002',
'deliveryaddress': '12 Cross Cut Road',
'deliverypincode': '641012',
'sourceid': '7',
'sourcename': 'Kitchen 1',
'consignmentid': 900,
});
// These four were the ones being dropped: without them every milk-run
// stop grouped into one nameless pile and no drop could be closed.
expect(stop['sourceid'], '7');
expect(stop['sourcename'], 'Kitchen 1');
expect(stop['consignmentid'], '900');
expect(stop['pickuppincode'], '641002');
expect(stop['droppincode'], '641012');
});
test('a booking with no source groups as no source, not as a guess', () {
final stop = ApiConfig.pickupFromBooking({
'bookingid': 43,
'bookingreference': 'DM-43',
'pickupaddress': '9 Mettupalayam Road',
});
expect(stop['sourcename'], '');
expect(stop['sourceid'], '');
expect(stop['consignmentid'], '');
});
test('MilkRun reads the consignment the adapter wrote', () {
expect(MilkRun.consignmentIdOf({'consignmentid': '900'}), '900');
expect(MilkRun.consignmentIdOf({}), '');
});
});
// ── The label follows the leg, not the payload ──
//
// `ApiConfig.pickupFromBooking` stamps `type: pickup` on every row it builds,
// because a v1 booking *is* a first-mile pickup and the backend has no
// per-stop type to send. On a milk run that made a bag already in the rider's
// box describe itself as a pickup: the delivery card offered **Start Pickup**,
// the confirmation sheet asked him to **Confirm pickup**, and the write path
// behind that button took the pickup branch — he was being asked to collect
// something he was carrying.
// ── Which screen a rung is worked on ──
//
// The defect: after accepting on Home, the Deliveries tab offered a live
// strip for the stop. Tapping it opened the single-stop map, then I'VE
// ARRIVED, then a confirmation sheet — a second, contradictory route up a
// ladder whose rungs are supposed to be one bulk slide on Home, at the
// counter, for the whole stack of bags at once. It also posted the booking
// ACTIVE from the tab that is meant to be the rider's load.
group('worksOnOwnScreen', () {
Map<String, dynamic> booking(String status) => {
'orderid': 'B1',
'type': 'pickup',
'orderstatus': status,
'kitchenname': 'Vidhya Kitchen',
};
test('a kitchen collection is worked on Home, not on its own screen', () {
ServiceProfile.setActive(ServiceProfile.milkMan);
for (final status in ['assigned', 'accepted', 'arrived']) {
expect(
MilkRun.worksOnOwnScreen(booking(status)),
isFalse,
reason: '$status is a rung of the bulk ladder on Home',
);
}
});
test('the delivery leg is worked on its own screen', () {
ServiceProfile.setActive(ServiceProfile.milkMan);
// Both ways a bag is known to be in his hands.
expect(MilkRun.worksOnOwnScreen(booking('picked')), isTrue);
expect(
MilkRun.worksOnOwnScreen(booking('accepted'), collectedIds: {'B1'}),
isTrue,
);
});
test('every logistics stop is worked on its own screen', () {
// Nothing is batched on that line: one customer, one parcel, one
// shipment raised at the door. The rule must not leak across.
ServiceProfile.setActive(ServiceProfile.parcel);
for (final status in ['assigned', 'accepted', 'arrived', 'picked']) {
expect(
MilkRun.worksOnOwnScreen(booking(status)),
isTrue,
reason: status,
);
}
});
});
group('workingKind', () {
Map<String, dynamic> booking(String status) => {
'orderid': 'B1',
'pickupcustomer': 'Joe',
// Exactly what the adapter writes.
'type': 'pickup',
'orderstatus': status,
'kitchenname': 'Vidhya Kitchen',
};
test(
'a collected milk-run order is a delivery, whatever the payload says',
() {
ServiceProfile.setActive(ServiceProfile.meals);
final stop = booking('picked');
expect(
stopKindOf(stop),
StopKind.pickup,
reason: 'what the payload says',
);
expect(
MilkRun.workingKind(stop),
StopKind.delivery,
reason: 'what the rider is actually doing',
);
expect(MilkRun.workingKind(stop).startCtaLabel, 'Start Delivery');
expect(MilkRun.workingKind(stop).completeLabel, 'Delivered');
},
);
test('the collected set is what the delivery screen actually has', () {
// ── The status is not the record on that screen ──
//
// A stop on the work tab comes out of the *accepted* store, which
// normalises every row it holds to `accepted`. What records the hand-over
// is the collected set — so asking the leg without it reads a bag already
// in the rider's box as still needing collection, which is exactly how
// the delivery card came to offer **Start Pickup**.
ServiceProfile.setActive(ServiceProfile.meals);
final onTheTab = booking('accepted');
expect(MilkRun.workingKind(onTheTab), StopKind.pickup);
expect(
MilkRun.workingKind(onTheTab, collectedIds: {'B1'}).startCtaLabel,
'Start Delivery',
);
expect(
MilkRun.workingKind(onTheTab, collectedIds: {'B1'}).completeLabel,
'Delivered',
);
});
test('the local collected set counts, before any status lands', () {
ServiceProfile.setActive(ServiceProfile.meals);
final stop = booking('accepted');
// Straight after the pickup slide the server may still say `accepted`;
// the rider is holding the bag either way.
expect(MilkRun.workingKind(stop), StopKind.pickup);
expect(
MilkRun.workingKind(stop, collectedIds: {'B1'}),
StopKind.delivery,
);
});
test('before collection it is still a pickup', () {
ServiceProfile.setActive(ServiceProfile.meals);
expect(MilkRun.workingKind(booking('accepted')), StopKind.pickup);
});
test('a parcel booking is untouched', () {
ServiceProfile.setActive(ServiceProfile.parcel);
// A collected parcel goes to a hub and is delivered by somebody else, so
// the rider never drives to its customer and the word never changes.
expect(MilkRun.workingKind(booking('picked')), StopKind.pickup);
expect(
MilkRun.workingKind(booking('picked')).startCtaLabel,
'Start Pickup',
);
});
});
}