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>
419 lines
16 KiB
Dart
419 lines
16 KiB
Dart
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');
|
||
});
|
||
});
|
||
}
|