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