Files
doormile_milderapp/test/route_order_test.dart
Thiru-tenext d7348e253f Miler rider app: surface system, visible design language, backend lifecycle
Design system
- MilerSurface ladder (canvas → working → raised → floating) with MilerPanel
  as layer 1; canvas moved to #DEE3EA so white separates at 1.290:1.
- Visible vocabulary applied across Home, Deliveries, Activity, Account and
  the sheets: hero heads (tabular numeral + small caption, clamped at 1.3x),
  canvas wells for anything that opens, small filled tags for shelf labels,
  demoted placeholders. Recorded in DESIGN_SYSTEM.md §6.
- One icon family: 222 Material glyphs migrated to Lucide; none left outside
  lib/xpress.
- Colour semantics corrected: amber only for what is genuinely owed, brand red
  reserved for the live stop, disabled primaries go neutral rather than pale.

Data and lifecycle
- lib/data/lifecycle.dart reads mutations for what they prove; route_order.dart
  makes admin sequence the single ordering authority; service_day.dart, and
  stop_area.dart rewritten against live Coimbatore addresses (digit-token
  stripping, city stoplist, street suffixes, stammer collapse).
- countLabel states the load once, in bags.

Testing
- 1440 tests passing; golden shot harnesses for Home, Deliveries, Activity,
  sheets and verify, with test/failures/ now gitignored (diff debris).
- New pins: home_gutter_test, stop_area_test, plus updated structural bounds.

Note: this commit also carries pre-existing working-tree deletions that were
present before this work (API_SPEC.md, README.md, demo test fixtures).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 05:40:35 +05:30

354 lines
13 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() {
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, '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<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',
);
});
});
}