Files
doormile_milderapp/test/route_order_test.dart
2026-08-28 11:13:15 +05:30

404 lines
15 KiB
Dart

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() {
_adapterCarriesTheStamp();
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<String, dynamic> 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<String, dynamic> s) =>
((s['droplat'] as double) - 11.0).abs() * 1000;
List<String> idsOf(List<Map<String, dynamic>> 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, 'Assigned 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<String, dynamic> 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',
);
});
});
}
/// ── The adapter has to carry the stamp, or the contract is unreadable ──
///
/// `RouteOrder` gates on `sequencedat` exactly as the backend specified. That
/// is worth nothing if the field never reaches it: `pickupFromBooking` mapped
/// `step`, `stoptype`, `etaminutes`, `cumulativekms` and `cumulativeeta` and
/// dropped the one that says whether any of them mean anything — so every
/// adapted row looked unsequenced and the app fell back to nearest-first on
/// routes the hub had solved.
void _adapterCarriesTheStamp() {
group('the adapter carries sequencedat', () {
Map<String, dynamic> booking(Map<String, dynamic> extra) => {
'bookingid': 1,
'bookingno': 'B1',
'pickupaddress': '19 Thadagam Road',
...extra,
};
test('a sequenced row arrives sequenced', () {
final row = ApiConfig.pickupFromBooking(
booking({'step': 2, 'sequencedat': '2026-08-25T09:00:00+05:30'}),
);
expect(RouteOrder.isSequenced(row), isTrue);
expect(RouteOrder.sequenceOf(row), 2);
});
test('step 0 with no stamp is not a route, and that is not a bug', () {
// The backend's own words: `step: 0` + `sequencedat: null` means the
// rider holds fewer than two active stops, or the stop has no coords.
final row = ApiConfig.pickupFromBooking(
booking({'step': 0, 'sequencedat': null}),
);
expect(RouteOrder.isSequenced(row), isFalse);
expect(
RouteOrder.hasAdminSequence([row]),
isFalse,
reason: 'nearest-first is the correct answer here, not a fallback bug',
);
});
test('an empty stamp is no stamp', () {
final row = ApiConfig.pickupFromBooking(
booking({'step': 0, 'sequencedat': ' '}),
);
expect(RouteOrder.isSequenced(row), isFalse);
});
});
}