import 'package:flutter_test/flutter_test.dart'; import 'package:miler/data/next_leg.dart'; import 'package:miler/data/service_profile.dart'; import 'package:miler/data/work_domain.dart'; import 'package:miler/data/milk_run.dart'; /// ───────────────────────────────────────────────────────────────────────── /// WHERE A COLLECTED PARCEL GOES NEXT /// /// The logistics line used to answer this with a line-level constant — /// `ServiceProfile.endsAtHub` — and `work_domain.dart` closed every collected /// parcel because of it. These pin the per-order replacement against the four /// operational cases it exists for: /// /// 1. Hub H1 → customer collected at a hub, delivered by this rider /// 2. customer → hub collected at a door, handed to the network /// 3. customer → customer hyperlocal, one rider, no hub /// 4. customer → another state collected here, handed to the local hub /// /// The routing decision is the **backend's** in every one of them. No test here /// asserts anything about a pincode, a district or a city name, because nothing /// in the app is allowed to read one for this purpose. /// ───────────────────────────────────────────────────────────────────────── void main() { setUp(() => ServiceProfile.setActive(ServiceProfile.parcel)); tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel)); /// A queue row as `GET /miler/bookings` delivers it, after the adapter. Map row( String id, { String status = 'picked', String consignment = '', String nextAction = '', String source = '', String drop = '', }) => { 'orderid': id, 'orderstatus': status, 'consignmentstatus': consignment, if (nextAction.isNotEmpty) 'next_action': nextAction, 'sourcename': source, 'dropaddress': drop, }; group('the four operational cases', () { test('CASE 1 — Hub H1 → customer: the rider delivers it himself', () { // Collected at the hub; the pivot released it into his hands. final stop = row( 'H1-001', consignment: 'Collected_By_Miler', source: 'Hub H1', drop: 'Hopes, Coimbatore', ); expect(NextLegResolver.resolve(stop).leg, NextLeg.customer); expect(WorkBoundary.isClosed(stop), isFalse, reason: 'a parcel in his hands is not finished work'); expect(WorkBoundary.domainOf(stop), WorkDomain.delivery); expect(MilkRun.navigatesToCustomer(stop), isTrue, reason: 'the receiver is the immediate destination'); }); test('CASE 3 — customer → customer hyperlocal: never closed at pickup', () { // Gandhipuram → Hopes. The pivot released it in the same call, which is // what compatibility mode does, so the row comes back Out_for_Delivery. final stop = row( 'HYP-1', consignment: 'Out_for_Delivery', source: 'Rajesh Kumar', drop: 'Hopes, Coimbatore', ); expect(NextLegResolver.resolve(stop).leg, NextLeg.customer); // ── The bug this whole change exists for ── // // `isPicked && endsAtHub` closed this row, and endsAtHub is always true // on logistics. The rider walked away from Rajesh Kumar's door carrying a // parcel the app had filed as finished. expect(WorkBoundary.isClosed(stop), isFalse); expect(WorkBoundary.domainOf(stop), WorkDomain.delivery); expect(MilkRun.navigatesToCustomer(stop), isTrue); }); test('CASE 4 — customer → another state: hub leg, no customer delivery', () { // Gandhipuram → Chennai. The pivot routed it into the network. final stop = row( 'ICT-1', consignment: 'Created', source: 'Rajesh Kumar', drop: 'T Nagar, Chennai', ); const pivot = {'ICT-1': NextActionWord.inwardAtHub}; expect( NextLegResolver.resolve(stop, pivotAction: NextActionWord.inwardAtHub).leg, NextLeg.hub, ); // It is in his custody, so it is the work tab's — not closed, not Home's. expect( WorkBoundary.isClosed(stop, pivotActions: pivot), isFalse, reason: 'a parcel he is carrying must not vanish into Activity', ); expect( WorkBoundary.domainOf(stop, pivotActions: pivot), WorkDomain.delivery, ); // ── The safety assertion ── // // Chennai is 500km away. Nothing may offer it as this rider's next // navigation target. expect( MilkRun.navigatesToCustomer(stop, pivotActions: pivot), isFalse, reason: 'a hub-routed parcel must never point at its receiver', ); }); test('CASE 2 — the hub taking it in is what ends the rider s job', () { // There is no rider-side handover mutation yet (backend gap). The one way // a hub-routed parcel legitimately leaves his queue today is hub staff // inwarding it in their console, which the next poll reports. final handed = row('ICT-1', consignment: 'Inwarded_at_Hub'); const pivot = {'ICT-1': NextActionWord.inwardAtHub}; expect(NextLegResolver.resolve(handed, pivotAction: NextActionWord.inwardAtHub).leg, NextLeg.closed); expect(WorkBoundary.isClosed(handed, pivotActions: pivot), isTrue); expect( WorkBoundary.domainOf(handed, pivotActions: pivot), WorkDomain.closed, ); }); }); group('precedence — the server outranks the local record', () { test('a live consignment state beats a stale pivot instruction', () { // The pivot said hub. The server has since said the parcel is out for // delivery in this rider's hands. The server wins: a local record is // continuity, never authority. final stop = row('X1', consignment: 'Out_for_Delivery'); final decision = NextLegResolver.resolve(stop, pivotAction: NextActionWord.inwardAtHub); expect(decision.leg, NextLeg.customer); expect(decision.source, 'row.consignmentstatus'); }); test('a terminal state beats everything', () { final stop = row('X2', status: 'delivered', consignment: 'Delivered'); expect( NextLegResolver.resolve(stop, pivotAction: NextActionWord.startDelivery).leg, NextLeg.closed, ); }); test('live next_action on the row outranks the persisted pivot', () { // Not sent by the backend today — pinned so the resolver is already // correct on the day `GET /miler/bookings` carries it. final stop = row('X3', consignment: 'Created', nextAction: 'start_delivery'); final decision = NextLegResolver.resolve(stop, pivotAction: NextActionWord.inwardAtHub); expect(decision.leg, NextLeg.customer); expect(decision.source, 'row.next_action'); }); test('Created alone decides nothing — it is also the transient state', () { // `Created` is what a hub-routed pivot lands on AND what a consignment // holds while the pivot is still routing it. Reading it as "hub" would // strand every freshly-collected hyperlocal parcel. final stop = row('X4', consignment: 'Created'); final decision = NextLegResolver.resolve(stop); expect(decision.leg, NextLeg.unknown); expect(decision.source, 'none'); }); test('unknown keeps custody: not closed, and no customer destination', () { final stop = row('X5', consignment: 'Created'); // Never written off — he is holding it. expect(WorkBoundary.isClosed(stop), isFalse); expect(WorkBoundary.domainOf(stop), WorkDomain.delivery); // Never pointed anywhere — the server has not said where it goes. expect(MilkRun.navigatesToCustomer(stop), isFalse); }); }); group('the backend outranks the ServiceProfile default', () { test('logistics stays endsAtHub, and it no longer closes an order', () { // The capability is untouched — it still describes the line. expect(ServiceProfile.parcel.endsAtHub, isTrue); expect(ServiceProfile.parcel.deliversToCustomer, isFalse); // And it no longer decides one order's fate. final hyperlocal = row('P1', consignment: 'Collected_By_Miler'); expect(WorkBoundary.isClosed(hyperlocal), isFalse); expect(MilkRun.navigatesToCustomer(hyperlocal), isTrue); }); test('a milk run is unchanged by any of this', () { ServiceProfile.setActive(ServiceProfile.milkMan); // A round's rows often carry no consignment state at all; the line's own // shape still answers for them. final collected = row('M1', status: 'picked'); expect(WorkBoundary.isClosed(collected), isFalse); expect( MilkRun.navigatesToCustomer(collected, collectedIds: const {'M1'}), isTrue, ); }); }); group('the leg decides the words and the controls', () { test('only a customer leg may be released into a delivery round', () { // The gate that keeps a Chennai consignment out of `Out_for_Delivery`. expect(NextLeg.customer.allowsCustomerDelivery, isTrue); expect(NextLeg.hub.allowsCustomerDelivery, isFalse); expect(NextLeg.unknown.allowsCustomerDelivery, isFalse); expect(NextLeg.closed.allowsCustomerDelivery, isFalse); }); test('a handover and a delivery are never worded the same', () { // Activity has to be able to tell "handed across a counter" from "given // to the person it belongs to". One verb for both is a false record. expect( NextLeg.hub.completionVerb, isNot(equals(NextLeg.customer.completionVerb)), ); expect(NextLeg.customer.completionVerb, contains('customer')); }); test('no leg vocabulary says "hub" to a rider', () { // The house rule, enforced for every rider-facing string by // `no_hub_in_rider_copy_test.dart`. Pinned here too because this is where // the words are chosen: "base" for the place, "your office" for people. for (final leg in NextLeg.values) { expect(leg.eyebrow.toLowerCase(), isNot(contains('hub'))); expect(leg.completionVerb.toLowerCase(), isNot(contains('hub'))); } }); test('a closed order has no eyebrow to draw', () { expect(NextLeg.closed.eyebrow, isEmpty); }); }); group('one owner per order', () { test('a picked parcel is never on Home and Deliveries at once', () { final day = [ row('A', consignment: 'Out_for_Delivery'), // hyperlocal, customer leg row('B', consignment: 'Created'), // hub-routed row('C', status: 'assigned', consignment: ''), // not collected ]; const pivot = {'B': NextActionWord.inwardAtHub}; final home = WorkBoundary.pickupQueue(day, pivotActions: pivot); final deliveries = WorkBoundary.deliveryQueue(day, pivotActions: pivot); final homeIds = home.map(MilkRun.idOf).toSet(); final deliveryIds = deliveries.map(MilkRun.idOf).toSet(); expect(homeIds.intersection(deliveryIds), isEmpty, reason: 'dual ownership is the failure this boundary exists to stop'); expect(deliveryIds, containsAll({'A', 'B'})); expect(homeIds, contains('C')); }); test('restart and poll preserve the leg', () { // Same row, read again on a later poll with nothing but the persisted // pivot to go on — which is exactly the cold-start case. final stop = row('B', consignment: 'Created'); const pivot = {'B': NextActionWord.inwardAtHub}; for (var i = 0; i < 3; i++) { expect( WorkBoundary.domainOf(stop, pivotActions: pivot), WorkDomain.delivery, ); expect(MilkRun.navigatesToCustomer(stop, pivotActions: pivot), isFalse); } }); }); }