import 'package:flutter_test/flutter_test.dart'; import 'package:miler/data/milk_run.dart'; import 'package:miler/data/service_profile.dart'; import 'package:miler/data/work_domain.dart'; /// ───────────────────────────────────────────────────────────────────────── /// THE BOUNDARY BETWEEN THE TWO WORK SCREENS /// /// ``` /// PENDING ─ accept ─▶ ACCEPTED ─ navigate ─▶ ARRIVED ─ pick up ─▶ PICKED /// └────────────────── HOME owns all of this ───────────────┘ │ /// ▼ /// DELIVERIES owns from here on /// ``` /// /// The defect these pin: an **accepted** booking was claimed by both screens. /// It stayed on Home to be collected and it also appeared on Deliveries as live /// work, with a strip offering to continue it — which opened a map, an I'VE /// ARRIVED and a confirmation sheet for a collection the rider had not made. /// Two screens, two filters, one fact, and they disagreed. /// /// So the invariants are asserted on [WorkBoundary] itself rather than by /// pumping two widgets and comparing what they drew: it is the single rule both /// screens now read, and a rule that cannot be re-expressed per screen is the /// actual fix. /// ───────────────────────────────────────────────────────────────────────── void main() { TestWidgetsFlutterBinding.ensureInitialized(); tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel)); /// One order in the shape the booking adapter produces: the backend has no /// per-stop type, so every row calls itself a pickup whatever leg it is on. Map order( String id, { required String status, String kitchen = 'Vidhya Kitchen', }) => { 'orderid': id, 'pickupid': id, 'type': 'pickup', 'orderstatus': status, 'kitchenname': kitchen, 'pickupcustomer': kitchen, 'dropcustomer': 'Customer $id', }; group('a kitchen line — the boundary is pickup-complete', () { setUp(() => ServiceProfile.setActive(ServiceProfile.milkMan)); test('accepted is not picked up, and never leaves Home', () { // The whole bug in one assertion. Accepting is a decision about work // still to be done; nothing has changed hands. final accepted = order('A1', status: 'accepted'); expect( WorkBoundary.pickupComplete(accepted, collectedIds: const {}), isFalse, ); expect( WorkBoundary.domainOf( accepted, acceptedIds: const {'A1'}, collectedIds: const {}, ), WorkDomain.pickup, reason: 'an acceptance must never be read as a completed pickup', ); }); test('every rung before pickup-complete belongs to Home', () { for (final status in ['assigned', 'accepted', 'active', 'arrived']) { expect( WorkBoundary.domainOf( order('A1', status: status), acceptedIds: const {'A1'}, ), WorkDomain.pickup, reason: status, ); } }); test('the backend saying picked is what moves it', () { // Both spellings the contract produces: `Picked_Up` and the conversion // to a consignment. for (final status in ['picked', 'Picked up']) { expect( WorkBoundary.domainOf(order('A1', status: status)), WorkDomain.delivery, reason: status, ); } }); test('a confirmed pickup keeps it on Deliveries through a stale poll', () { // The queue is up to one poll behind, and it answers with what it knew // before the hand-over. Without the collected record the order would // bounce back to Home for a few seconds, which reads as the confirm // having failed — so the record wins, and it is written only after the // backend confirms. See `_advanceStop` on Home. final stale = order('A1', status: 'accepted'); expect( WorkBoundary.domainOf(stale, collectedIds: const {'A1'}), WorkDomain.delivery, ); }); test('the delivery rungs stay on Deliveries', () { for (final status in ['out_for_delivery', 'delivery_arrived']) { expect( WorkBoundary.domainOf(order('A1', status: status)), WorkDomain.delivery, reason: status, ); } }); test('delivered and cancelled leave both work screens', () { expect( WorkBoundary.domainOf(order('A1', status: 'delivered')), WorkDomain.closed, ); expect( WorkBoundary.domainOf(order('A1', status: 'cancelled')), WorkDomain.closed, ); }); test('a declined stop stays on Home — it is not delivery work', () { // It stays visible there precisely so the rider can change his mind. expect( WorkBoundary.domainOf(order('A1', status: 'rejected')), WorkDomain.pickup, ); }); test('picked is the middle of the morning, not the end of it', () { // The line does not end at a hub: he is holding the food and still owes // every door. Closing it here is what once sent a collected crate to // Activity as fifteen finished deliveries. expect(WorkBoundary.isClosed(order('A1', status: 'picked')), isFalse); }); }); group('one kitchen, partially accepted, partially collected', () { setUp(() => ServiceProfile.setActive(ServiceProfile.milkMan)); final day = [ order('A1', status: 'picked'), // collected at the counter order('A2', status: 'accepted'), // taken, still on the counter order('A3', status: 'assigned'), // not decided yet order('A4', status: 'delivered'), // done ]; test('the two screens split the day, with nothing shared', () { final home = WorkBoundary.pickupQueue(day, acceptedIds: const {'A2'}); final deliveries = WorkBoundary.deliveryQueue( day, acceptedIds: const {'A2'}, ); expect(home.map(MilkRun.idOf), ['A2', 'A3']); expect(deliveries.map(MilkRun.idOf), ['A1']); // The invariant that makes the pill trustworthy: no order can be counted // by both screens, and none can fall between them. final overlap = home .map(MilkRun.idOf) .toSet() .intersection(deliveries.map(MilkRun.idOf).toSet()); expect(overlap, isEmpty); expect( home.length + deliveries.length, day.length - 1, reason: 'A4 closed', ); }); test("Home's pill counts the other tab's rows, not its own", () { // The pill's tap goes to Deliveries, so its number has to be that tab's // — asked of the same rule, from the same day. It used to count accepted // work, so it read 3 while the tab it opened listed 1. expect( WorkBoundary.deliveryQueue(day, acceptedIds: const {'A2'}).length, 1, ); }); test('accepting the rest moves nothing across', () { final deliveries = WorkBoundary.deliveryQueue( day, acceptedIds: const {'A2', 'A3'}, ); expect(deliveries.map(MilkRun.idOf), ['A1']); }); test('a delivered stop leaves the count, not just the list', () { // ── The two screens disagreed because they asked different questions ── // // Both call `deliveryQueue`, which is supposed to be what stops that // happening. It cannot, when they pass different arguments: Deliveries // passed `closedIds`, Home's pill did not. The rider finished a drop, // watched the tab go 22 → 21, and came back to Home still holding 22. // // Worse on Home than a plain lag, because Home *stamps* a closed stop // `picked` so its row cannot flicker back to LIVE while the queue // catches up — and `picked` is squarely inside the delivery domain. The // pill was counting the stamp that existed to hide the stop. final day = [ order('B1', status: 'picked'), order('B2', status: 'picked'), ]; expect(WorkBoundary.deliveryQueue(day).length, 2); expect( WorkBoundary.deliveryQueue(day, closedIds: const {'B1'}).length, 1, reason: 'the finished drop is off both the list and the count', ); // And it does not reappear on Home instead — closed is closed, not // handed back across the boundary. expect( WorkBoundary.pickupQueue(day, closedIds: const {'B1'}).length, 0, ); }); }); group('logistics — the boundary is acceptance, and that is unchanged', () { setUp(() => ServiceProfile.setActive(ServiceProfile.parcel)); test("an accepted booking is the work tab's, as it always was", () { // There is no counter and nothing to batch on this line: the rider drives // to one customer and raises the shipment at the door, so the collection // *is* the job and it is worked from that tab. The rule is the same one, // read through [ServiceProfile.handoffAt]. expect( WorkBoundary.domainOf( order('P1', status: 'accepted'), acceptedIds: const {'P1'}, ), WorkDomain.delivery, ); }); test('an undecided booking is still Home\'s', () { expect( WorkBoundary.domainOf(order('P1', status: 'assigned')), WorkDomain.pickup, ); }); test('collected no longer ends the job by line default', () { // ── This assertion used to be the opposite, and it was the bug ── // // `isClosed` read `isPicked && ServiceProfile.active.endsAtHub`, which is // always true on logistics, so **every** collected parcel was written off // at the pickup — including a hyperlocal one the pivot had just released // into this rider's own hands. He left the sender's door carrying a // parcel his app had already filed as finished. // // Where a collected parcel goes next is the backend's decision, per // order, and this row carries no answer yet: no consignment state and no // recorded pivot instruction. So it resolves to [NextLeg.unknown], which // keeps custody rather than guessing — the parcel is in his hands, and // the honest screen is the work tab, not Activity. See [NextLegResolver]. expect(WorkBoundary.isClosed(order('P1', status: 'picked')), isFalse); expect( WorkBoundary.domainOf(order('P1', status: 'picked')), WorkDomain.delivery, ); }); test('the backend closing it is what closes it', () { // `Inwarded_at_Hub` is the hub taking the parcel into its own custody. // That is a real ending, and it comes from the server. final handed = order('P1', status: 'picked') ..['consignmentstatus'] = 'Inwarded_at_Hub'; expect(WorkBoundary.isClosed(handed), isTrue); expect(WorkBoundary.domainOf(handed), WorkDomain.closed); }); }); }