import 'package:flutter_test/flutter_test.dart'; import 'package:miler/Models/stop_status.dart'; import 'package:miler/data/api_config.dart'; import 'package:miler/data/consignment_state.dart'; import 'package:miler/data/lifecycle.dart'; import 'package:miler/data/milk_run.dart'; import 'package:miler/data/miler_api.dart'; import 'package:miler/data/next_leg.dart'; import 'package:miler/data/service_profile.dart'; import 'package:miler/data/work_domain.dart'; /// ───────────────────────────────────────────────────────────────────────── /// THE BASE-HANDOVER CONTRACT, AS THE BACKEND SHIPPED IT /// /// Requests 25–31 landed together, and the app's half of them is: read the four /// new fields off the row, understand the six `next_action` words, and be /// correct in **both positions of `MILER_HUB_HANDOVER_ENABLED`** — because that /// flag is flipped server-side and no app release gets to sit in between. /// /// off pickup-complete → Inwarded_at_Hub + handed_to_hub rider is done /// on pickup-complete → Created + inward_at_hub rider carries it /// /// These pin both, plus the field plumbing that makes them readable at all: the /// booking adapter builds a **fixed map**, so a field it does not name is a /// field no screen can ever see, however faithfully the backend sends it. /// ───────────────────────────────────────────────────────────────────────── void main() { setUp(() => ServiceProfile.setActive(ServiceProfile.parcel)); tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel)); group('the adapter carries the new fields', () { // The trap `step`, `stoptype` and every timestamp were in before it: the // server sends the field, the adapter has no key for it, and the app is // blind to something that was on the wire all along. final adapted = ApiConfig.pickupFromBooking(const { 'bookingid': 4821, 'status': 'Converted_To_Consignment', 'consignmentstatus': 'Created', 'next_action': 'inward_at_hub', 'next_hub': { 'id': 1, 'name': 'Coimbatore Hub', 'address': '14 Avinashi Road, Peelamedu, Coimbatore', 'pincode': '641004', 'latitude': 11.0272, 'longitude': 76.9905, }, 'pickup_source_type': 'customer', 'pickup_source_name': 'Anitha R', }); test('next_action survives', () { expect(adapted['next_action'], 'inward_at_hub'); expect(NextLegResolver.rowNextAction(adapted), 'inward_at_hub'); }); test('next_hub survives whole, and is navigable', () { final base = HandoverHub.from(adapted['next_hub']); expect(base, isNotNull); expect(base!.id, '1'); expect(base.name, 'Coimbatore Hub'); expect(base.pincode, '641004'); expect(base.latitude, 11.0272); expect(base.isNavigable, isTrue, reason: 'coordinates are the whole point of request 25'); }); test('pickup_source_type and _source_name survive', () { expect(adapted['pickup_source_type'], 'customer'); expect(PickupSource.of(adapted), PickupSource.customer); expect(adapted['pickup_source_name'], 'Anitha R'); }); test('the counter name outranks the account name', () { // `sourcename` is filled from providercompany/providerlocation and is a // contact person on some rows — this is the fix for `Sudharsan` heading a // route card. final row = ApiConfig.pickupFromBooking(const { 'bookingid': 7, 'sourcename': 'Sudharsan', 'pickup_source_name': 'Sri Balaji Stores', }); expect(MilkRun.sourceNameOf(row), 'Sri Balaji Stores'); }); }); group('the full next_action vocabulary', () { Map row(String action, {String consignment = ''}) => { 'orderid': 'A1', 'orderstatus': 'picked', 'consignmentstatus': consignment, 'next_action': action, }; test('deliver and start_delivery are both customer legs', () { expect(NextLegResolver.resolve(row('start_delivery')).leg, NextLeg.customer); expect(NextLegResolver.resolve(row('deliver')).leg, NextLeg.customer); }); test('inward_at_hub is a base leg, handed_to_hub is finished', () { expect(NextLegResolver.resolve(row('inward_at_hub')).leg, NextLeg.hub); expect(NextLegResolver.resolve(row('handed_to_hub')).leg, NextLeg.closed); }); test('pickup and none decide nothing on their own', () { // `pickup` is "not collected yet", which is not a leg. `none` means // terminal — and rung 1 reads that off the consignment's own state, which // is stronger evidence than a bare word. expect(NextLegResolver.resolve(row('pickup')).leg, NextLeg.unknown); expect(NextLegResolver.resolve(row('none')).leg, NextLeg.unknown); }); test('an unknown word falls through instead of failing', () { final decision = NextLegResolver.resolve( row('teleport_to_mars', consignment: 'Out_for_Delivery'), ); expect(decision.leg, NextLeg.customer, reason: 'the consignment state still answers'); }); }); group('correct in both positions of the flag', () { // The row as `GET /miler/bookings` returns it after the pivot, adapted. Map afterPickup({ required String consignment, required String action, }) => ApiConfig.pickupFromBooking({ 'bookingid': 4821, 'status': 'Converted_To_Consignment', 'consignmentstatus': consignment, 'next_action': action, }); test('FLAG OFF — the rider is done at the pickup', () { // pickup-complete inwards it immediately; the assignment closes there. final row = afterPickup( consignment: 'Inwarded_at_Hub', action: 'handed_to_hub', ); expect(stopStatusOf(row).isPicked, isTrue, reason: 'the booking reached Converted_To_Consignment'); expect(NextLegResolver.resolve(row).leg, NextLeg.closed); expect(WorkBoundary.domainOf(row), WorkDomain.closed); }); test('FLAG ON — the rider is carrying it to a base', () { final row = afterPickup(consignment: 'Created', action: 'inward_at_hub'); final decision = NextLegResolver.resolve(row); expect(decision.leg, NextLeg.hub); // ── Read from the row, not from the handset ── // // The pivot cache was only ever a workaround for `next_action` not being // on the queue read. Request 27 put it there, so the live field answers // and the cache is not consulted at all. expect(decision.source, 'row.next_action'); expect(WorkBoundary.isClosed(row), isFalse); expect(WorkBoundary.domainOf(row), WorkDomain.delivery); expect(MilkRun.navigatesToCustomer(row), isFalse, reason: 'a base-routed parcel never points at its receiver'); }); test('and neither position needs the local cache', () { // Passing no pivotAction at all — a fresh install mid-day. for (final pair in const [ ('Inwarded_at_Hub', 'handed_to_hub', NextLeg.closed), ('Created', 'inward_at_hub', NextLeg.hub), ('Collected_By_Miler', 'start_delivery', NextLeg.customer), ('Out_for_Delivery', 'deliver', NextLeg.customer), ]) { final row = afterPickup(consignment: pair.$1, action: pair.$2); expect(NextLegResolver.resolve(row).leg, pair.$3, reason: '${pair.$1} / ${pair.$2}'); } }); }); group('the handover response is read for what it proves', () { ApiResult ok(Map data) => ApiResult(ok: true, status: 200, data: data); test('a fresh handover is confirmed by the state it names', () { final t = MilerLifecycle.inwardAtHub(ok(const { 'consignmentstatus': 'Inwarded_at_Hub', 'inwardedat': '2026-09-02T14:22:10Z', 'next_action': 'handed_to_hub', 'already_inwarded': false, })); expect(t.isConfirmed, isTrue); expect(t.consignmentState, ConsignmentState.inwardedAtHub); }); test('already_inwarded is a success, never a failure', () { // He handed it over, the reply was lost, he pressed again. The parcel is // on the base's counter and he cannot prove that from where he stands — // so the second answer confirms the first. final t = MilerLifecycle.inwardAtHub(ok(const { 'consignmentstatus': 'Inwarded_at_Hub', 'already_inwarded': true, })); expect(t.isConfirmed, isTrue); }); test('a refusal carries its code', () { final t = MilerLifecycle.inwardAtHub(const ApiResult( ok: false, status: 400, code: 'INVALID_STATE', message: 'already out for delivery', )); expect(t.isRefused, isTrue); expect(t.code, 'INVALID_STATE'); }); test('a 200 that names no state is not proof', () { // The rule request 15 exists for: a bare success is not a transition. final t = MilerLifecycle.inwardAtHub(ok(const {'success': true})); expect(t.isUnconfirmed, isTrue); }); }); group('pickup source types', () { test('each type reads off the row', () { for (final pair in const [ ('hub', PickupSource.hub), ('customer', PickupSource.customer), ('merchant', PickupSource.merchant), ('store', PickupSource.store), ]) { expect(PickupSource.of({'pickup_source_type': pair.$1}), pair.$2); } }); test('an unknown or missing type is a plain pickup, never a guess', () { expect(PickupSource.of(const {}), PickupSource.unknown); expect( PickupSource.of(const {'pickup_source_type': 'spaceport'}), PickupSource.unknown, ); expect(PickupSource.unknown.eyebrow, 'PICKUP'); }); test('no source eyebrow says "hub" to a rider', () { // The house rule — see `no_hub_in_rider_copy_test.dart`. Base, not hub. for (final s in PickupSource.values) { expect(s.eyebrow.toLowerCase(), isNot(contains('hub'))); } expect(PickupSource.hub.eyebrow, 'BASE PICKUP'); }); test('counters are collected in bulk; a door is not', () { expect(PickupSource.hub.isCounter, isTrue); expect(PickupSource.merchant.isCounter, isTrue); expect(PickupSource.store.isCounter, isTrue); expect(PickupSource.customer.isCounter, isFalse); }); }); group('At_Customer is the pickup arrival', () { test('it no longer reads as arrival at the receiver', () { // Settled by the backend: it is a milerprofiles.availabilitystatus value // written only by `/reached`, so it says where the RIDER is. Reading it // as `deliveryArrived` moved a stop into the delivery half of the day. for (final raw in const ['At_Customer', 'at_customer', 'at customer']) { expect(stopStatusFromRaw(raw), StopStatus.arrived, reason: raw); } }); test('the real delivery-arrival spellings are untouched', () { expect(stopStatusFromRaw('delivery_arrived'), StopStatus.deliveryArrived); expect(stopStatusFromRaw('deliveryarrived'), StopStatus.deliveryArrived); }); }); group('a base without coordinates is a caption, not a destination', () { test('isNavigable is false and the card must not open a map', () { final base = HandoverHub.from(const {'id': 2, 'name': 'Salem Hub'}); expect(base, isNotNull); expect(base!.isNavigable, isFalse, reason: 'navigating to 0,0 lands in the Gulf of Guinea'); }); test('an empty object is not a base at all', () { expect(HandoverHub.from(const {}), isNull); expect(HandoverHub.from(null), isNull); }); }); }