import 'dart:io'; 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/api_status.dart'; import 'package:miler/data/miler_api.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/service_profile.dart'; /// ───────────────────────────────────────────────────────────────────────── /// PICKED IS NOT ACTIVE, AND ONLY THE SLIDER MOVES IT /// /// The production lifecycle the hub, the console and this app must all agree /// on: /// /// ``` /// Pending → Accepted → Arrived → Picked → Active → Delivered /// ▲ ▲ /// pickup-complete start-delivery /// Collected_By_Miler Out_for_Delivery /// ``` /// /// Two separate defects collapsed **Picked** into **Active**, and fixing either /// alone leaves the symptom: /// /// 1. **Backend.** With `MILER_COLLECTED_STATE_ENABLED` off, `pickup-complete` /// writes `Out_for_Delivery` itself. Not reachable from this app and not /// testable here — it is the deployment's behaviour. What IS pinned below /// is that the app never claims Picked over such a response. /// /// 2. **App.** `MyPickups.startPickup` released the consignment when the rider /// merely *tapped a card* — one tap after Picked, before any slider. That /// is invisible while the flag is off (the pivot has already released, so /// the release costs no request) and becomes the whole bug the moment the /// flag is flipped. Removed; pinned here. /// /// These tests are the reason the flag flip is safe. Read them before changing /// any call site that reaches `MilerApi.startDelivery`. /// ───────────────────────────────────────────────────────────────────────── void main() { TestWidgetsFlutterBinding.ensureInitialized(); tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel)); // ══════════════════════════════════════════════════════════════════════ // 1 · PICKED DOES NOT CALL START-DELIVERY // // Enforced at the source, because this is a statement about *call sites* // and no behavioural test can prove the absence of one. Every route to // `MilerApi.startDelivery` is enumerated; a new one fails this test and has // to be justified here rather than discovered in production. // ══════════════════════════════════════════════════════════════════════ group('only the rider setting off releases a consignment', () { /// Every `lib/` file that mentions a symbol, with its line numbers. Map> sitesOf(String symbol) { final out = >{}; for (final e in Directory('lib').listSync(recursive: true)) { if (e is! File || !e.path.endsWith('.dart')) continue; final lines = e.readAsLinesSync(); for (var i = 0; i < lines.length; i++) { final l = lines[i]; // Prose in a doc comment is not a call. if (l.trimLeft().startsWith('//') || l.trimLeft().startsWith('///')) { continue; } if (l.contains(symbol)) (out[e.path] ??= []).add(i + 1); } } return out; } test('`MilerApi.startDelivery` has exactly one caller', () { final sites = sitesOf('MilerApi.startDelivery('); expect( sites.keys.toSet(), {'lib/views/Dashboard/pickups/delivery_actions.dart'}, reason: 'the release is `releaseForDelivery`\'s alone. A second caller is ' 'a second lifecycle, and the console sees both.', ); }); test('`releaseForDelivery` is reached only from setting off or the door', () { final sites = sitesOf('releaseForDelivery(') ..remove('lib/views/Dashboard/pickups/delivery_actions.dart'); expect( sites.keys.toSet(), { // The Start-ride slider on the stop's own map. 'lib/views/Dashboard/pickups/map.dart', // The Start-round bar, and nothing else on that screen. This file // held the defect: `startPickup` called `startRound` on card open. 'lib/views/Dashboard/pickups/pickups.dart', }, reason: 'a new caller must be a deliberate "the rider has set off"', ); }); test('tapping a card does not release — the defect, pinned', () { // `startPickup` opens a stop's map. It must contain no release: the // rider tapping a card to read an address has not set off, and // `navigatesToCustomer` is true from the instant Picked succeeds. final src = File( 'lib/views/Dashboard/pickups/pickups.dart', ).readAsStringSync(); final start = src.indexOf('Future startPickup('); expect(start, greaterThan(0), reason: 'startPickup was renamed'); final end = src.indexOf('Future resumePickup(', start); expect(end, greaterThan(start)); final body = src .substring(start, end) .split('\n') .where((l) => !l.trimLeft().startsWith('//')) .join('\n'); expect( body.contains('startRound('), isFalse, reason: 'opening a stop released it, so the console read Active one tap ' 'after Picked and before any slider was touched', ); expect(body.contains('releaseForDelivery('), isFalse); }); }); // ══════════════════════════════════════════════════════════════════════ // 2 · COLLECTED_BY_MILER IS PICKED, AND STAYS PICKED // ══════════════════════════════════════════════════════════════════════ group('Collected_By_Miler reads as Picked', () { test('the pivot response is read as collected, not as released', () { final t = MilerLifecycle.pickupComplete( ApiResult( ok: true, status: 200, data: const { 'bookingid': 78, 'consignmentid': 34, 'status': 'Converted_To_Consignment', 'consignmentstatus': 'Collected_By_Miler', 'next_action': 'start_delivery', }, ), ); expect(t.isConfirmed, isTrue); expect(t.bookingStatus, BookingStatus.convertedToConsignment); expect(t.consignmentState, ConsignmentState.collectedByMiler); expect(t.consignmentId, '34'); expect(t.awaitsStartDelivery, isTrue); expect( t.isCompatibilityMode, isFalse, reason: 'the flag is ON in this response — the release is still to come', ); }); test('a collected stop shows the Picked rung', () { ServiceProfile.setActive(ServiceProfile.milkMan); final stop = { 'orderid': 'A1', 'orderstatus': ApiConfig.legacyStatusFromConsignment( 'Collected_By_Miler', ), 'consignmentstatus': 'Collected_By_Miler', }; expect(stop['orderstatus'], 'picked', reason: 'adapter contract'); expect( MilkRun.stageOf(stop, acceptedIds: const {}, collectedIds: const {'A1'}), StopStatus.picked, ); }); test('and it still needs releasing — the bar has work to offer', () { expect( consignmentStateFromRaw('Collected_By_Miler').needsRelease, isTrue, ); expect( consignmentStateFromRaw('Collected_By_Miler').isDeliverable, isFalse, reason: '`deliver` accepts Out_for_Delivery only', ); }); }); // ══════════════════════════════════════════════════════════════════════ // 3 · START RIDE IS WHAT MAKES IT ACTIVE // ══════════════════════════════════════════════════════════════════════ group('Start ride moves collected → out for delivery', () { test('the released state is the Active rung, and only via the local set', () { ServiceProfile.setActive(ServiceProfile.milkMan); final stop = { 'orderid': 'A1', 'orderstatus': ApiConfig.legacyStatusFromConsignment('Out_for_Delivery'), 'consignmentstatus': 'Out_for_Delivery', }; // Before the slider: the rider's own record does not carry it, so the // rung is Picked even though the consignment is released. expect( MilkRun.stageOf(stop, acceptedIds: const {}, collectedIds: const {'A1'}), StopStatus.picked, ); // After the slider: `startRound` writes the id, and only then. expect( MilkRun.stageOf( stop, acceptedIds: const {}, collectedIds: const {'A1'}, outForDeliveryIds: const {'A1'}, ), StopStatus.outForDelivery, ); }); test('a released consignment is deliverable and needs no second release', () { final s = consignmentStateFromRaw('Out_for_Delivery'); expect(s.isDeliverable, isTrue); expect(s.needsRelease, isFalse); expect(ConsignmentGate.gateFor(s), DeliverGate.deliverable); }); }); // ══════════════════════════════════════════════════════════════════════ // 4 · A STALE OR EARLY ROW CANNOT COLLAPSE PICKED INTO ACTIVE // // `GET /miler/bookings` is a poll behind the rider by design. With the flag // OFF the pivot releases the consignment itself, so the very next poll // reports `Out_for_Delivery` for a bag still on the counter. The rider's // card must not jump to Active on that — his own set is what says he set // off. This is the app half of the compatibility-mode disagreement, and it // is display only: nothing here is sent to the server. // ══════════════════════════════════════════════════════════════════════ group('polling cannot start the round for the rider', () { test('compatibility mode is reported, never silently adopted', () { final t = MilerLifecycle.pickupComplete( ApiResult( ok: true, status: 200, data: const { 'bookingid': 78, 'consignmentid': 34, 'consignmentstatus': 'Out_for_Delivery', }, ), ); expect(t.isConfirmed, isTrue); expect(t.consignmentState, ConsignmentState.outForDelivery); expect( t.isCompatibilityMode, isTrue, reason: 'the flag is OFF on this deployment — the pivot released it, and ' 'the console showing Active is the SERVER\'s state, not a client ' 'bug. Reported so the trace names the backend.', ); expect(t.awaitsStartDelivery, isFalse); }); test('an early Out_for_Delivery row still draws Picked', () { ServiceProfile.setActive(ServiceProfile.milkMan); // Exactly what the next poll returns in compatibility mode. final stop = { 'orderid': 'A1', 'orderstatus': 'outfordelivery', 'consignmentstatus': 'Out_for_Delivery', }; expect( MilkRun.stageOf(stop, acceptedIds: const {}, collectedIds: const {'A1'}), StopStatus.picked, reason: 'the rider has not slid Start ride; the row is the consignment\'s ' 'state, not his', ); }); test('but a genuinely finished stop is never dragged backwards', () { ServiceProfile.setActive(ServiceProfile.milkMan); for (final raw in const ['Delivered', 'delivered']) { // Built the way `ApiConfig.pickupFromBooking` builds a real row: the // consignment's word is translated into `orderstatus`, which is what // `stopStatusOf` reads. Setting `consignmentstatus` alone would be a // row this app never sees, and would prove nothing about the ladder. final stop = { 'orderid': 'A1', 'orderstatus': ApiConfig.legacyStatusFromConsignment(raw), 'consignmentstatus': raw, }; expect(stop['orderstatus'], 'delivered', reason: 'adapter contract'); expect( MilkRun.stageOf( stop, acceptedIds: const {}, collectedIds: const {'A1'}, ), StopStatus.delivered, reason: 'precedence must only ever hold a stop back, never a close', ); } }); }); }