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/api_config.dart'; import 'package:miler/data/api_status.dart'; import 'package:miler/data/accepted_store.dart'; import 'package:miler/data/assignment_lookup.dart'; import 'package:miler/data/service_profile.dart'; import 'package:miler/data/work_domain.dart'; import 'package:miler/views/Dashboard/home/trip.dart'; import 'package:miler/providers/pickuplog/pickuplog_provider.dart'; /// ───────────────────────────────────────────────────────────────────────── /// THE BACKEND CONTRACT, AS THIS APP READS IT /// /// Every status `GET /miler/bookings` can carry, taken from the API reference /// and the flow doc in the console repo rather than from what the app happened /// to have seen. The list is the contract; the assertions are this app's /// reading of it. /// /// Three defects these pin: /// /// • **`Out_for_Delivery` was not in [BookingStatus].** `pickup-complete` /// releases a hyperlocal booking straight to it — which is every DailyGrubs /// run — so a bag already in the rider's box parsed as `unknown`, translated /// to the empty string, and read as *undecided*. The local collected record /// hid it on the phone that did the pickup and nowhere else: after a restart /// or on a second device, collected work sat on Home as pending. /// • **An unmapped status write was reported as success.** Nothing left the /// phone and the caller was told it had landed. /// • **The hub's solved stop order was discarded.** `step` lives on the /// assignment row; the app read those rows for their ids and dropped it. /// ───────────────────────────────────────────────────────────────────────── void main() { TestWidgetsFlutterBinding.ensureInitialized(); tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel)); /// Exactly the strings the backend sends, in the backend's own casing — /// including `Out_for_Delivery`, whose lower-case `f` differs from the /// consignment enum's `Out_For_Delivery`. const backendBookingStatuses = [ 'Created', 'Pending_Pickup', 'Miler_Assigned', 'Pickup_Scheduled', 'Picked_Up', 'Converted_To_Consignment', 'Out_for_Delivery', 'Delivered', 'Cancelled', ]; group('every status the backend sends is typed', () { test('none of them fall through to unknown', () { for (final raw in backendBookingStatuses) { expect( BookingStatus.parse(raw), isNot(BookingStatus.unknown), reason: '$raw is in the contract and must parse', ); } }); test('and each one survives the translation to the row vocabulary', () { // `legacyStatusFromNew` is the seam between the v1 enum and the lowercase // vocabulary every row, card and filter in this app reads. A status that // parses but translates to something `stopStatusFromRaw` cannot read is // the same bug one layer further on. for (final raw in backendBookingStatuses) { final legacy = ApiConfig.legacyStatusFromNew(raw); expect( stopStatusFromRaw(legacy), isNot(StopStatus.unknown), reason: '$raw translated to "$legacy", which the app cannot read', ); } }); test('both casings of out-for-delivery land on the same rung', () { expect( BookingStatus.parse('Out_for_Delivery'), BookingStatus.outForDelivery, ); expect( BookingStatus.parse('Out_For_Delivery'), BookingStatus.outForDelivery, ); }); }); group('collected is a fact about the backend, not about this device', () { setUp(() => ServiceProfile.setActive(ServiceProfile.milkMan)); test( 'a released hyperlocal booking is delivery work with no local record', () { // The restart case: `collectedIds` is empty because this install never // performed the pickup. The status alone has to carry it. final stop = { 'orderid': 'A1', 'type': 'pickup', 'orderstatus': ApiConfig.legacyStatusFromNew('Out_for_Delivery'), }; expect(WorkBoundary.pickupComplete(stop), isTrue); expect(WorkBoundary.domainOf(stop), WorkDomain.delivery); }, ); test('and a delivered one is finished, not pending', () { final stop = { 'orderid': 'A1', 'type': 'pickup', 'orderstatus': ApiConfig.legacyStatusFromNew('Delivered'), }; expect(WorkBoundary.domainOf(stop), WorkDomain.closed); }); test('an assignment the rider has not acted on is still Home\'s', () { for (final raw in ['Created', 'Pending_Pickup', 'Miler_Assigned']) { final stop = { 'orderid': 'A1', 'orderstatus': ApiConfig.legacyStatusFromNew(raw), }; expect(WorkBoundary.domainOf(stop), WorkDomain.pickup, reason: raw); } }); }); group('the rungs come in order', () { setUp(() => ServiceProfile.setActive(ServiceProfile.milkMan)); test('accepting does not skip the arrival rung', () { // The reported bug: accept a stop, select it again, and the bar offered // **Mark as Picked** for a kitchen the rider had not reached. // // `Pickup_Scheduled` is what the backend writes on accept, and it was // translated to `active` — which `stopStateOf` reads as *the rider is on // the stop*, ahead of the local accepted record. So the queue's own // answer promoted every accepted stop straight past arrival, and // `reached` — the one rung with a real endpoint behind it — could never // be sent. final stop = { 'orderid': 'A1', 'orderstatus': ApiConfig.legacyStatusFromNew('Pickup_Scheduled'), }; expect( stopStateOf(stop, acceptedIds: const {}, rejectedIds: const {}), StopState.accepted, reason: 'accepted, not on the stop', ); }); test('and arriving is what puts him on the stop', () { final stop = {'orderid': 'A1', 'orderstatus': 'arrived'}; expect( stopStateOf(stop, acceptedIds: const {}, rejectedIds: const {}), StopState.active, ); }); test('the local accept record reads the same rung as the server\'s', () { // Optimistic accept, before the queue has caught up: both paths must // land on the same rung or the bar changes its offer under him. final stop = {'orderid': 'A1', 'orderstatus': 'assigned'}; expect( stopStateOf(stop, acceptedIds: const {'A1'}, rejectedIds: const {}), StopState.accepted, ); }); }); group('an unknown status is never a settled one', () { test('a future booking status reads as undecided, not as finished', () { const future = 'Rescheduled_By_Hub'; expect(BookingStatus.parse(future), BookingStatus.unknown); expect(BookingStatus.parse(future).isCollected, isFalse); final stop = { 'orderid': 'A1', 'orderstatus': ApiConfig.legacyStatusFromNew(future), }; ServiceProfile.setActive(ServiceProfile.milkMan); expect( WorkBoundary.domainOf(stop), WorkDomain.pickup, reason: 'wrong-but-safe: it stays where the rider can still act on it', ); expect(WorkBoundary.isClosed(stop), isFalse); }); test('a future assignment status is not settled either', () { expect(AssignmentStatus.parse('Escalated'), AssignmentStatus.unknown); expect(AssignmentStatus.unknown.isSettled, isFalse); expect(AssignmentStatus.unknown.isLive, isFalse); expect(AssignmentStatus.unknown.needsDecision, isFalse); }); test('a future consignment status is not delivered', () { expect( ConsignmentStatus.parse('Held_At_Customs'), ConsignmentStatus.unknown, ); expect(ConsignmentStatus.unknown.isDelivered, isFalse); }); }); group('a write this app cannot map is a failure, not a success', () { test('the unmapped branch reports failure and sends nothing', () async { // No network stub: reaching one would itself be the bug. The branch must // refuse before any request is built. final res = await UpdatePickupProvider().updatePickup({ 'pickupid': 1, 'orderstatus': 'teleported', }); expect(res, isNotNull); expect(res!['status'], isFalse); expect(res['message'], contains('teleported')); }); }); group('the consignment id survives the pivot', () { setUp(() => SharedPreferences.setMockInitialValues({})); test('it is recorded at pickup-complete and read back at the door', () async { // The reported failure: press **Delivered** at a customer's door and the // app answers "could not update — check your connection", with no request // ever made. // // `deliver` and `skip` key on the CONSIGNMENT, which only exists after // `pickup-complete` creates it. That response was wrapped and discarded, // and the row the rider works from on Deliveries is a snapshot taken // *before* the conversion — so nothing on the device knew what to // deliver, and the refusal came from the app, not the network. await rememberConsignmentId('A1', 'CN-9001'); expect((await getConsignmentIds())['A1'], 'CN-9001'); }); test('and is dropped once the stop is finished', () async { await rememberConsignmentId('A1', 'CN-9001'); await forgetConsignmentIds(['A1']); expect((await getConsignmentIds()).containsKey('A1'), isFalse); }); test('an empty id is not worth recording', () async { await rememberConsignmentId('A1', ''); await rememberConsignmentId('', 'CN-9001'); expect(await getConsignmentIds(), isEmpty); }); }); group('the hub\'s solved stop order is kept', () { test('step is read from the assignment row', () { final index = AssignmentLookup.buildStepIndex([ { 'bookingid': 118, 'bookingassignmentid': 1, 'step': 2, 'assignmentstatus': 'Accepted', }, { 'bookingid': 119, 'bookingassignmentid': 2, 'step': 1, 'assignmentstatus': 'Assigned', }, ]); expect(index['118'], 2); expect(index['119'], 1); }); test('step 0 means unsequenced and is not stored as a position', () { // `step: 0` sorts to the end in `Trip.sortStops`; storing it would make an // unsequenced stop look like it had been solved into first place. final index = AssignmentLookup.buildStepIndex([ { 'bookingid': 120, 'bookingassignmentid': 3, 'step': 0, 'assignmentstatus': 'Assigned', }, ]); expect(index.containsKey('120'), isFalse); }); test('a reassigned booking takes the live assignment\'s sequence', () { // Same rule as the id index: newest first, but an actionable row beats a // newer terminal one, so the two indexes cannot describe different rows. final rows = [ { 'bookingid': 118, 'bookingassignmentid': 9, 'step': 5, 'assignmentstatus': 'Rejected', }, { 'bookingid': 118, 'bookingassignmentid': 10, 'step': 3, 'assignmentstatus': 'Accepted', }, ]; expect(AssignmentLookup.buildStepIndex(rows)['118'], 3); expect(AssignmentLookup.buildIndex(rows)['118'], 10); }); }); }