Files
doormile_milderapp/test/milk_man_flow_test.dart
2026-08-28 15:07:30 +05:30

475 lines
18 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 is never given a name', () {
// BACKEND DEPENDENCY: sourceid/sourcename are not deployed everywhere.
// The rule that matters is unchanged and is what this asserts: the app
// does not paper over a missing counter with a guessed kitchen name.
final s = ApiConfig.pickupFromBooking({
'bookingid': 43,
'bookingreference': 'DM-43',
'pickupaddress': '9 Mettupalayam Road',
});
expect(MilkRun.sourceNameOf(s), '');
});
test('...but it still groups by the address it does carry', () {
// ── What changed, and why it is not "inventing" one ──
//
// This used to demand `'none'`, which put every source-less order in one
// bucket regardless of where it was collected. On a real morning that
// failed in the other direction: eight orders off one kitchen, seven
// carrying its id and one not, and the eighth could not join them — it
// fell out of the dropdown into a flat header of its own titled with the
// customer's name.
//
// Keying on the address invents nothing. It is the weakest *true*
// statement on the row — these are collected from the same address — and
// it is only reached when the id and the name are both absent. The name
// is still empty, which is what the test above pins.
Map<String, dynamic> at(String address) =>
ApiConfig.pickupFromBooking({
'bookingid': address.hashCode,
'bookingreference': 'DM-$address',
'pickupaddress': address,
});
// Same counter, spelled as two different rows would spell it.
expect(
MilkRun.sourceKeyOf(at('9 Mettupalayam Road')),
MilkRun.sourceKeyOf(at('9, METTUPALAYAM ROAD ')),
);
// A different place stays a different group.
expect(
MilkRun.sourceKeyOf(at('9 Mettupalayam Road')),
isNot(MilkRun.sourceKeyOf(at('12 SNS Colony'))),
);
// Nothing to group on at all is still its own bucket, never a guess.
expect(
MilkRun.sourceKeyOf(
ApiConfig.pickupFromBooking({
'bookingid': 44,
'bookingreference': 'DM-44',
}),
),
'none',
);
});
test('an identified counter still outranks the address', () {
// Order of trust: the hub's id, then the name it reads by, then where it
// is. Two kitchens at one address must not merge because a row lost its
// id; two rows with the same id must not split because someone retyped
// the address.
final byId = ApiConfig.pickupFromBooking({
'bookingid': 45,
'bookingreference': 'DM-45',
'sourceid': 'K1',
'pickupaddress': '9 Mettupalayam Road',
});
expect(MilkRun.sourceKeyOf(byId), 'id:K1');
});
});
}