Files
doormile_milderapp/test/milk_man_flow_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

419 lines
16 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 BRIEF'S OWN SCENARIO, WALKED THROUGH
///
/// Six orders, three kitchens:
///
/// ```
/// Vidhya ×3 ┐
/// Priyanka ×2 ├─ accept all six (per order, across kitchens)
/// ABC ×1 ┘
/// │
/// ├─ Vidhya: arrived → picked ×3 ─▶ DELIVERIES (Priyanka/ABC stay)
/// ├─ Priyanka: arrived → picked ×2 ─▶ DELIVERIES
/// └─ ABC: arrived → picked ×1 ─▶ DELIVERIES
/// │
/// arrived → delivered ─▶ ACTIVITY
/// ```
///
/// The three rules this file holds, all of which were got wrong at some point:
///
/// 1. **Acceptance is per order.** Six accepted orders stay six visible cards;
/// they are not replaced by three kitchen cards.
/// 2. **Pickup is per kitchen.** One counter at a time — and finishing one
/// kitchen must NOT wait for the others.
/// 3. **`picked` is the middle of the morning.** It goes to DELIVERIES, and
/// only `delivered` reaches Activity.
/// ─────────────────────────────────────────────────────────────────────────
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() {
SharedPreferences.setMockInitialValues({});
ServiceProfile.setActive(ServiceProfile.milkMan);
});
tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel));
Map<String, dynamic> order({
required String id,
required String kitchen,
required String kitchenId,
String status = 'assigned',
bool withDrop = true,
}) => {
'orderid': id,
'pickupid': int.parse(id),
'orderstatus': status,
'sourceid': kitchenId,
'sourcename': kitchen,
'pickupaddress': '$kitchen, Trichy Road',
'pickupcustomer': 'Receiver $id',
'pickupcontactno': '98765432$id',
'quantity': 1,
'pickuplat': 11.01,
'pickuplon': 76.95,
if (withDrop) 'droplat': 11.05,
if (withDrop) 'droplon': 76.99,
if (withDrop) 'dropaddress': 'Flat $id, Anna Nagar',
};
/// The brief's six.
List<Map<String, dynamic>> sixOrders() => [
order(id: '101', kitchen: 'Vidhya Kitchen', kitchenId: 'K1'),
order(id: '102', kitchen: 'Vidhya Kitchen', kitchenId: 'K1'),
order(id: '103', kitchen: 'Vidhya Kitchen', kitchenId: 'K1'),
order(id: '104', kitchen: 'Priyanka Kitchen', kitchenId: 'K2'),
order(id: '105', kitchen: 'Priyanka Kitchen', kitchenId: 'K2'),
order(id: '106', kitchen: 'ABC Kitchen', kitchenId: 'K3'),
];
Set<String> idsOf(Iterable<Map<String, dynamic>> s) =>
s.map(MilkRun.idOf).toSet();
group('1–7 · six orders arrive, all six are accepted, all six stay', () {
test('acceptance is per order and crosses kitchens', () {
final stops = sixOrders();
final accepted = idsOf(stops);
expect(
accepted.length,
6,
reason:
'he takes on the whole morning in one press, whichever '
'counters it comes from',
);
});
test('six accepted orders remain six — not three kitchen cards', () {
final stops = sixOrders();
final accepted = idsOf(stops);
// Every accepted order is still individually addressable and still
// outstanding work. Collapsing them into one card per kitchen would lose
// the receiver, the drop address and the order id — the things the rider
// reads on the card.
final outstanding = MilkRun.outstandingPickups(
stops,
acceptedIds: accepted,
collectedIds: const {},
);
expect(outstanding.length, 6);
expect(idsOf(outstanding), accepted);
for (final s in outstanding) {
expect(MilkRun.sourceNameOf(s), isNotEmpty);
expect(s['pickupcustomer'], isNotEmpty);
expect(s['pickupcontactno'], isNotEmpty);
}
});
test('but they are still knowable as three kitchens', () {
final stops = sixOrders();
final keys = stops.map(MilkRun.sourceKeyOf).toSet();
expect(keys.length, 3);
expect(MilkRun.sameSourceAs(stops[0], stops).length, 3); // Vidhya
expect(MilkRun.sameSourceAs(stops[3], stops).length, 2); // Priyanka
expect(MilkRun.sameSourceAs(stops[5], stops).length, 1); // ABC
});
});
group('9–11 · navigate and the details sheet', () {
test('any Vidhya order navigates to Vidhya Kitchen', () {
final stops = sixOrders();
// He does not need a special kitchen card — any of the three will do.
for (final s in MilkRun.sameSourceAs(stops[0], stops)) {
expect(MilkRun.navigatesToCustomer(s), isFalse);
expect(MilkRun.navigationLabel(s), 'Vidhya Kitchen');
expect(MilkRun.navigationTarget(s)?.lat, 11.01);
}
});
test('the sheet has everything it needs, from the payload', () {
// Nothing here is invented; each is a field the adapter carries.
final s = sixOrders().first;
expect(MilkRun.sourceNameOf(s), 'Vidhya Kitchen'); // pickup kitchen
expect(s['pickupaddress'], isNotEmpty); // pickup address
expect(s['pickupcustomer'], 'Receiver 101'); // receiver
expect(s['pickupcontactno'], isNotEmpty); // phone → Call
expect(s['dropaddress'], isNotEmpty); // drop address
expect(MilkRun.idOf(s), '101'); // order id
expect(s['quantity'], 1); // quantity
expect(stopStatusOf(s), StopStatus.assigned); // status
});
test('a missing phone is absent, never fabricated', () {
final s = sixOrders().first..remove('pickupcontactno');
expect(
s['pickupcontactno'],
isNull,
reason: 'the Call action hides rather than dialling a made-up number',
);
});
});
group('12–17 · Vidhya: arrive, pick, and straight to DELIVERIES', () {
test('the pickup set is bounded to one kitchen', () {
final stops = sixOrders();
final vidhya = MilkRun.sameSourceAs(stops[0], stops);
expect(idsOf(vidhya), {'101', '102', '103'});
expect(
idsOf(vidhya).intersection({'104', '105', '106'}),
isEmpty,
reason: 'Priyanka and ABC are different rides and different counters',
);
});
test('arrived is not picked', () {
// §14: arrival and collection are separate events.
final s = sixOrders().first..['orderstatus'] = 'arrived';
expect(stopStatusOf(s), StopStatus.arrived);
expect(stopStatusOf(s).isPicked, isFalse);
expect(MilkRun.nextActionLabel(StopStatus.arrived), 'Picked up');
});
test('picking Vidhya leaves Priyanka and ABC on Home', () {
final stops = sixOrders();
final accepted = idsOf(stops);
final collected = {'101', '102', '103'};
final stillToCollect = MilkRun.outstandingPickups(
stops,
acceptedIds: accepted,
collectedIds: collected,
);
expect(idsOf(stillToCollect), {'104', '105', '106'});
expect(stillToCollect.map(MilkRun.sourceNameOf).toSet(), {
'Priyanka Kitchen',
'ABC Kitchen',
});
});
test('and the three picked ones are live deliveries immediately', () {
// §24 — the rule that must NOT wait for the other kitchens.
final collected = {'101', '102', '103'};
for (final id in collected) {
expect(collected.contains(id), isTrue);
}
// They navigate to the customer now, not back to the kitchen.
final s = sixOrders().first;
expect(MilkRun.navigatesToCustomer(s, collectedIds: collected), isTrue);
expect(MilkRun.navigationTarget(s, collectedIds: collected)?.lat, 11.05);
});
});
group('18 · the picked card lands on DELIVERIES, not Activity', () {
// ── Both filters, from opposite sides, on one fixture ──
//
// The deliveries list keeps what is NOT complete; Activity keeps what IS.
// They read the same test, so a stop can only ever be on one of them — and
// the bug was that `picked` answered "complete" on a line where it means
// "in the rider's box".
test('the deliveries list keeps a picked milk-run stop', () {
ServiceProfile.setActive(ServiceProfile.milkMan);
final s = sixOrders().first..['orderstatus'] = 'picked';
// `pickups.dart` builds its list with `!st.isFinishedPickup`.
expect(stopStatusOf(s).isFinishedPickup, isFalse);
});
test('and Activity drops it', () {
ServiceProfile.setActive(ServiceProfile.milkMan);
final s = sixOrders().first..['orderstatus'] = 'picked';
// `activity_page.dart` builds its list with `.isFinishedPickup`.
expect(stopStatusOf(s).isFinishedPickup, isFalse);
});
test('the backend\'s own post-pickup status behaves the same', () {
// `pickup-complete` leaves the booking `Converted_To_Consignment`, which
// the adapter maps to `picked`. The live payload must not put the order
// in history either.
ServiceProfile.setActive(ServiceProfile.milkMan);
final mapped = ApiConfig.legacyStatusFromNew('Converted_To_Consignment');
expect(mapped, 'picked');
expect(stopStatusFromRaw(mapped).isWorkComplete, isFalse);
});
test('once delivered the two swap', () {
ServiceProfile.setActive(ServiceProfile.milkMan);
final s = sixOrders().first..['orderstatus'] = 'delivered';
expect(stopStatusOf(s).isFinishedPickup, isTrue);
});
test('on logistics picked goes to Activity, as it always did', () {
ServiceProfile.setActive(ServiceProfile.parcel);
final s = sixOrders().first..['orderstatus'] = 'picked';
expect(stopStatusOf(s).isFinishedPickup, isTrue);
});
});
group('18b · the status vocabulary underneath', () {
test('picked is live work on this line', () {
final s = sixOrders().first..['orderstatus'] = 'picked';
expect(
stopStatusOf(s).isWorkComplete,
isFalse,
reason:
'the bug: a bag collected at 11:40 was filed as history before '
'the rider had left the counter',
);
});
test('delivered is what completes it', () {
final s = sixOrders().first..['orderstatus'] = 'delivered';
expect(stopStatusOf(s).isWorkComplete, isTrue);
});
});
group('20–22 · the rest of the kitchens, then all six on the round', () {
test('each kitchen adds its orders as it is worked', () {
final stops = sixOrders();
final accepted = idsOf(stops);
final collected = <String>{};
for (final batch in [
{'101', '102', '103'},
{'104', '105'},
{'106'},
]) {
collected.addAll(batch);
final left = MilkRun.outstandingPickups(
stops,
acceptedIds: accepted,
collectedIds: collected,
);
expect(left.length, 6 - collected.length);
}
expect(collected.length, 6);
expect(
MilkRun.outstandingPickups(
stops,
acceptedIds: accepted,
collectedIds: collected,
),
isEmpty,
);
});
});
group('23–25 · the delivery leg', () {
test('navigation targets the customer', () {
final s = sixOrders().first..['orderstatus'] = 'picked';
expect(MilkRun.navigationLabel(s), 'Receiver 101');
expect(MilkRun.navigationTarget(s)?.lng, 76.99);
});
test('customer arrival is a different state from kitchen arrival', () {
expect(stopStatusFromRaw('arrived'), StopStatus.arrived);
expect(stopStatusFromRaw('delivery_arrived'), StopStatus.deliveryArrived);
expect(StopStatus.arrived, isNot(StopStatus.deliveryArrived));
expect(MilkRun.nextActionLabel(StopStatus.deliveryArrived), 'Delivered');
});
test('the delivery arrival is local, and says so', () {
// §20 — documented, not faked. No call is made and no success invented.
expect(MilkRun.deliveryArrivalIsLocalOnly, isTrue);
});
test('delivered files it in Activity with the right verb', () async {
await addCompletedBookings([
{'orderid': '101', 'pickupcustomer': 'Receiver 101'},
]);
final done = await getCompletedBookings();
expect(done.single['orderstatus'], 'delivered');
expect(stopStatusOf(done.single).isWorkComplete, isTrue);
});
test('a delivered order keys on the consignment, not the booking', () {
// The same trap `bookingassignmentid` sets on the accept side.
final s = sixOrders().first..['consignmentid'] = '900';
expect(MilkRun.consignmentIdOf(s), '900');
expect(MilkRun.consignmentIdOf(sixOrders().first), '');
});
});
group('26 · a milk run never sees the logistics desk', () {
test('no payment, pricing, address capture or verification', () {
const p = ServiceProfile.milkMan;
expect(p.collectsCash, isFalse);
expect(p.pricesShipment, isFalse);
expect(p.capturesShipmentAddresses, isFalse);
expect(p.initiatesShipment, isFalse);
expect(p.needsVerification, isFalse);
});
test('and nothing is ever owed at a milk-run door', () {
ServiceProfile.setActive(ServiceProfile.milkMan);
expect(stopCollectionAmount({'collectionamt': 450}), 0);
});
});
group('27 · logistics is untouched', () {
test('its whole desk is still on', () {
const p = ServiceProfile.parcel;
expect(p.capturesShipmentAddresses, isTrue);
expect(p.pricesShipment, isTrue);
expect(p.initiatesShipment, isTrue);
expect(p.collectsCash, isTrue);
expect(p.needsVerification, isTrue);
});
test('accepted logistics work still moves off Home', () {
expect(ServiceProfile.parcel.handoffAt, HandoffPoint.accepted);
expect(ServiceProfile.milkMan.handoffAt, HandoffPoint.collected);
});
test('it has no kitchen and never drives to its customer', () {
const p = ServiceProfile.parcel;
expect(p.sourceIsKitchen, isFalse);
expect(p.deliversToCustomer, isFalse);
});
test('and picked still finishes it', () {
ServiceProfile.setActive(ServiceProfile.parcel);
expect(StopStatus.picked.isWorkComplete, isTrue);
});
});
group('the wire carries what the grouping needs', () {
test('the adapter passes source, drop and consignment through', () {
final s = ApiConfig.pickupFromBooking({
'bookingid': 42,
'bookingreference': 'DM-42',
'status': 'Miler_Assigned',
'pickupaddress': 'Vidhya Kitchen, Trichy Road',
'deliveryaddress': 'Flat 3, Anna Nagar',
'sourceid': 'K1',
'sourcename': 'Vidhya Kitchen',
'consignmentid': 900,
'customerphone': '9876543210',
});
expect(MilkRun.sourceNameOf(s), 'Vidhya Kitchen');
expect(MilkRun.sourceKeyOf(s), 'id:K1');
expect(MilkRun.consignmentIdOf(s), '900');
expect(s['pickupcontactno'], '9876543210');
expect(s['dropaddress'], 'Flat 3, Anna Nagar');
});
test('a booking without source fields does not invent one', () {
// BACKEND DEPENDENCY: sourceid/sourcename are not deployed. Until they
// are, every order groups under one unnamed bucket — visibly wrong, and
// deliberately not papered over with a guessed kitchen name.
final s = ApiConfig.pickupFromBooking({
'bookingid': 43,
'bookingreference': 'DM-43',
'pickupaddress': '9 Mettupalayam Road',
});
expect(MilkRun.sourceNameOf(s), '');
expect(MilkRun.sourceKeyOf(s), 'none');
});
});
}