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 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> 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 idsOf(Iterable> 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 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 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', ); }); }); }