import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:miler/data/api_config.dart'; import 'package:miler/data/route_order.dart'; import 'package:miler/data/service_profile.dart'; import 'package:miler/views/Dashboard/home/trip.dart'; /// ───────────────────────────────────────────────────────────────────────── /// THE ADMIN'S ROUTE IS THE ROUTE /// /// ── The regression ── /// /// The pickup leg followed the hub's solved `step`. The delivery leg did not, /// and could not: `step` reached the app only through `GET /miler/assignments`, /// which is deliberately the *active* queue (`Assigned`/`Accepted`), and a /// booking leaves those two states the moment it is collected — which is /// exactly when the delivery leg begins. So every delivery arrived unsequenced /// and the tab re-sorted it **nearest-first from the rider's GPS fix**, while /// the hub believed its route was being followed. /// /// Two systems, two orders, and neither side had any way to notice. That is /// not a cosmetic difference: the sequence is what the customer's arrival /// window was quoted from. /// /// These pin the rule end to end — that an assigned order survives the whole /// pipeline, that nothing replaces it, and that the fallback is a fallback and /// says so. /// ───────────────────────────────────────────────────────────────────────── void main() { TestWidgetsFlutterBinding.ensureInitialized(); setUp(() { SharedPreferences.setMockInitialValues({'userid': 38}); ServiceProfile.setActive(ServiceProfile.milkMan); }); tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel)); /// A stop as the Deliveries tab holds it, with a position and a place. Map stop( String id, { int step = 0, double lat = 11.0, double lng = 76.9, String? due, String status = 'outfordelivery', }) => { 'orderid': id, 'pickupid': int.tryParse(id) ?? id.hashCode.abs(), 'orderstatus': status, 'step': step, 'pickuplat': lat, 'pickuplon': lng, 'droplat': lat, 'droplon': lng, if (due != null) 'expected_pickup_time': due, }; /// Straight-line-ish stand-in for the rider's distance to a stop, so the /// proximity branch is exercised without a GPS. double fromRider(Map s) => ((s['droplat'] as double) - 11.0).abs() * 1000; List idsOf(List> stops) => [ for (final s in stops) (s['orderid'] as String), ]; group('an assigned sequence is used, exactly', () { test('stops come back in the hub\'s order, not the list\'s', () { final (ordered, source) = RouteOrder.sort([ stop('3', step: 3), stop('1', step: 1), stop('2', step: 2), ]); expect(idsOf(ordered), ['1', '2', '3']); expect(source, RouteOrderSource.adminSequence); }); test('nearest-first never replaces an assigned order', () { // Stop 3 is on the rider's doorstep and stop 1 is furthest away. The old // Deliveries sort would have put 3 first. The hub said 1. final (ordered, source) = RouteOrder.sort([ stop('1', step: 1, lat: 11.9), stop('2', step: 2, lat: 11.5), stop('3', step: 3, lat: 11.0), ], distanceTo: fromRider); expect(idsOf(ordered), ['1', '2', '3']); expect( source, RouteOrderSource.adminSequence, reason: 'offering a distance function must not tempt the sort into ' 'using it while a route exists', ); }); test('a booked time never replaces an assigned order either', () { final (ordered, _) = RouteOrder.sort( [ stop('1', step: 1, due: '2026-08-21T18:00:00'), stop('2', step: 2, due: '2026-08-21T09:00:00'), ], bookedTimeOf: (s) => DateTime.tryParse('${s['expected_pickup_time']}'), ); expect(idsOf(ordered), ['1', '2']); }); test('one sequenced stop makes the whole set admin-ordered', () { // The old sort split the list — sequenced stops by step, the rest by // distance — and produced a list that was neither order. final (ordered, source) = RouteOrder.sort([ stop('far', lat: 11.9), stop('near', lat: 11.0), stop('assigned', step: 1, lat: 11.5), ], distanceTo: fromRider); expect(source, RouteOrderSource.adminSequence); expect( idsOf(ordered).first, 'assigned', reason: 'sequenced work leads the route the hub planned', ); expect( idsOf(ordered).sublist(1), ['far', 'near'], reason: 'the unplanned tail keeps the order it arrived in — it is not ' 're-sorted by distance behind a solved route', ); }); test('step 0 is "not sequenced", never "first"', () { expect(RouteOrder.sequenceOf(stop('a')), 0); expect(RouteOrder.sequenceOf(stop('a', step: 1)), 1); expect(RouteOrder.hasAdminSequence([stop('a'), stop('b')]), isFalse); expect( RouteOrder.hasAdminSequence([stop('a'), stop('b', step: 4)]), isTrue, ); }); test('the delivery leg\'s own spelling is read too', () { expect(RouteOrder.sequenceOf({'deliverystep': 2}), 2); expect(RouteOrder.sequenceOf({'routestep': 5}), 5); expect(RouteOrder.sequenceOf({'sequence': '7'}), 7); }); }); group('the fallback is a fallback', () { test('nearest-first is used only with no assigned sequence at all', () { final (ordered, source) = RouteOrder.sort([ stop('far', lat: 11.9), stop('near', lat: 11.0), stop('mid', lat: 11.5), ], distanceTo: fromRider); expect(idsOf(ordered), ['near', 'mid', 'far']); expect(source, RouteOrderSource.proximity); expect( source.isAdmin, isFalse, reason: 'the screen must be able to tell the rider this is the app\'s ' 'guess and not his route', ); }); test('booked time is preferred over the rider\'s position', () { final (ordered, source) = RouteOrder.sort( [ stop('later', lat: 11.0, due: '2026-08-21T18:00:00'), stop('sooner', lat: 11.9, due: '2026-08-21T09:00:00'), ], distanceTo: fromRider, bookedTimeOf: (s) => DateTime.tryParse('${s['expected_pickup_time']}'), ); expect(idsOf(ordered), ['sooner', 'later']); expect(source, RouteOrderSource.bookedTime); }); test('with nothing to go on, the backend order is kept', () { final (ordered, source) = RouteOrder.sort([stop('b'), stop('a')]); expect(idsOf(ordered), [ 'b', 'a', ], reason: 'unordered means unchanged, not shuffled'); expect(source, RouteOrderSource.backendOrder); }); test('no GPS is not a reason to invent an order', () { final (ordered, source) = RouteOrder.sort([ stop('b', lat: 11.9), stop('a', lat: 11.0), ]); expect(idsOf(ordered), ['b', 'a']); expect(source, RouteOrderSource.backendOrder); }); test('each source says out loud what it is', () { expect(RouteOrderSource.adminSequence.label, 'Hub route'); expect(RouteOrderSource.proximity.label, 'Nearest first'); expect( RouteOrderSource.proximity.explanation, contains('No route assigned'), ); expect(RouteOrderSource.adminSequence.explanation, contains('assigned')); }); }); group('API → repository → screen', () { test('the sequence survives the booking adapter', () { // The adapter builds a FIXED map: a field it does not name is a field no // screen can ever see. `step` was not named, so the hub's order was // dropped in translation — before any sort had a chance to honour it. final stop = ApiConfig.pickupFromBooking({ 'bookingid': 75, 'bookingreference': 'DM-BK-1', 'status': 'Converted_To_Consignment', 'consignmentid': 46, 'consignmentstatus': 'Out_for_Delivery', 'step': 4, 'stoptype': 'delivery', }); expect(RouteOrder.sequenceOf(stop), 4); expect( stop['type'], 'delivery', reason: 'the leg was hardcoded to "pickup" for every row', ); }); test('an unsequenced booking maps to 0, not to null or first', () { final stop = ApiConfig.pickupFromBooking({ 'bookingid': 82, 'status': 'Miler_Assigned', 'step': 0, }); expect(RouteOrder.sequenceOf(stop), 0); }); test('both legs order the same day identically', () { // Home orders through Trip.sortStops, Deliveries through RouteOrder. // They were two implementations; a stop third on one was second on the // other. One rule now, and this is the assertion that keeps it one. final day = [ stop('c', step: 3, lat: 11.0), stop('a', step: 1, lat: 11.9), stop('b', step: 2, lat: 11.5), ]; final home = Trip.sortStops([...day]); final (deliveries, source) = RouteOrder.sort([ ...day, ], distanceTo: fromRider); expect(idsOf(home), idsOf(deliveries)); expect(idsOf(home), ['a', 'b', 'c']); expect(source, RouteOrderSource.adminSequence); }); }); group('the order holds as the day progresses', () { test('a partly collected run keeps the assigned sequence', () { // Collection changes a stop's status, not its place in the route. final (ordered, source) = RouteOrder.sort([ stop('1', step: 1, status: 'picked'), stop('2', step: 2, status: 'accepted'), stop('3', step: 3, status: 'outfordelivery'), ], distanceTo: fromRider); expect(idsOf(ordered), ['1', '2', '3']); expect(source, RouteOrderSource.adminSequence); }); test('a fully collected run keeps it too', () { final (ordered, _) = RouteOrder.sort([ stop('1', step: 1, status: 'outfordelivery', lat: 11.9), stop('2', step: 2, status: 'outfordelivery', lat: 11.0), ], distanceTo: fromRider); expect( idsOf(ordered), ['1', '2'], reason: 'this is precisely where the old code switched to nearest-first', ); }); }); group('trips group the route without re-ordering it', () { Map at(String id, int step, DateTime due) => { ...stop(id, step: step), 'expected_pickup_time': due.toIso8601String(), }; test('grouping into day parts never reorders within a trip', () { final trips = Trip.groupIntoTrips([ at('m3', 3, DateTime(2026, 8, 21, 10)), at('m1', 1, DateTime(2026, 8, 21, 8)), at('m2', 2, DateTime(2026, 8, 21, 9)), ]); expect(trips.single.tripNumber, 1); expect(idsOf(trips.single.stops), ['m1', 'm2', 'm3']); }); test('the sequence is not re-based per trip', () { // Steps 4,5 are the hub's numbers for the afternoon leg of one solved // route. Grouping must not renumber them, or reverse them because they // are "later" in a list that starts at 1. final trips = Trip.groupIntoTrips([ at('a5', 5, DateTime(2026, 8, 21, 15)), at('a4', 4, DateTime(2026, 8, 21, 14)), at('m1', 1, DateTime(2026, 8, 21, 9)), ]); expect(trips.length, 2); expect(idsOf(trips.first.stops), ['m1']); expect(idsOf(trips.last.stops), ['a4', 'a5']); expect(RouteOrder.sequenceOf(trips.last.stops.first), 4); }); test('a backend trip id overrides the day-part grouping', () { // The hub's own grouping wins over the app's clock rule, and the stops // inside it stay in the hub's order. final trips = Trip.groupIntoTrips([ {...at('b', 2, DateTime(2026, 8, 21, 20)), 'tripid': 'T1'}, {...at('a', 1, DateTime(2026, 8, 21, 9)), 'tripid': 'T1'}, ]); expect(trips.length, 1, reason: 'morning and evening, one hub trip'); expect(idsOf(trips.single.stops), ['a', 'b']); }); test('a slot id overrides it as well', () { final trips = Trip.groupIntoTrips([ {...at('a', 1, DateTime(2026, 8, 21, 9)), 'slotid': 'S9'}, {...at('b', 2, DateTime(2026, 8, 21, 19)), 'slotid': 'S9'}, ]); expect(trips.length, 1); expect(idsOf(trips.single.stops), ['a', 'b']); }); test('day parts split the day but not the route', () { final trips = Trip.groupIntoTrips([ at('m', 1, DateTime(2026, 8, 21, 9)), at('a', 2, DateTime(2026, 8, 21, 14)), at('e', 3, DateTime(2026, 8, 21, 19)), ]); expect(trips.map((t) => t.tripNumber), [1, 2, 3]); expect( [for (final t in trips) ...idsOf(t.stops)], ['m', 'a', 'e'], reason: 'flattening the trips must give back the hub\'s sequence', ); }); }); }