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>
This commit is contained in:
@@ -2,7 +2,6 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:miler/views/Dashboard/home/trip.dart';
|
||||
import 'package:miler/views/Dashboard/pickups/route_metrics.dart';
|
||||
import 'package:miler/views/Dashboard/pickups/stop_type.dart';
|
||||
import 'fixtures/demo_stops.dart';
|
||||
|
||||
Map<String, dynamic> stop({
|
||||
required String id,
|
||||
@@ -13,17 +12,18 @@ Map<String, dynamic> stop({
|
||||
double lat = 11.03,
|
||||
double lng = 76.96,
|
||||
DateTime? due,
|
||||
}) =>
|
||||
{
|
||||
'orderid': id,
|
||||
if (step != null) 'step': step,
|
||||
'type': type,
|
||||
'Quantity': '$qty',
|
||||
if (cash != null) 'collectionamt': '$cash',
|
||||
'pickuplat': '$lat',
|
||||
'pickuplon': '$lng',
|
||||
if (due != null) 'expected_pickup_time': due.toIso8601String(),
|
||||
};
|
||||
String? address,
|
||||
}) => {
|
||||
'orderid': id,
|
||||
if (step != null) 'step': step,
|
||||
'type': type,
|
||||
'Quantity': '$qty',
|
||||
if (cash != null) 'collectionamt': '$cash',
|
||||
'pickuplat': '$lat',
|
||||
'pickuplon': '$lng',
|
||||
if (due != null) 'expected_pickup_time': due.toIso8601String(),
|
||||
if (address != null) 'pickupaddress': address,
|
||||
};
|
||||
|
||||
void main() {
|
||||
group('RouteMetricsHelper — distance units', () {
|
||||
@@ -56,14 +56,20 @@ void main() {
|
||||
|
||||
group('RouteMetricsHelper — speed and ETA', () {
|
||||
test('falls back to the urban average when speed is unusable', () {
|
||||
expect(RouteMetricsHelper.usableSpeedMps(null),
|
||||
RouteMetricsHelper.kDefaultSpeedMps);
|
||||
expect(
|
||||
RouteMetricsHelper.usableSpeedMps(null),
|
||||
RouteMetricsHelper.kDefaultSpeedMps,
|
||||
);
|
||||
// Stopped at a light must not stretch the ETA to an hour.
|
||||
expect(RouteMetricsHelper.usableSpeedMps(0.4),
|
||||
RouteMetricsHelper.kDefaultSpeedMps);
|
||||
expect(
|
||||
RouteMetricsHelper.usableSpeedMps(0.4),
|
||||
RouteMetricsHelper.kDefaultSpeedMps,
|
||||
);
|
||||
// A GPS spike is a bad fix, not a fast rider.
|
||||
expect(RouteMetricsHelper.usableSpeedMps(60),
|
||||
RouteMetricsHelper.kDefaultSpeedMps);
|
||||
expect(
|
||||
RouteMetricsHelper.usableSpeedMps(60),
|
||||
RouteMetricsHelper.kDefaultSpeedMps,
|
||||
);
|
||||
});
|
||||
|
||||
test('uses a real riding speed when it is plausible', () {
|
||||
@@ -79,31 +85,123 @@ void main() {
|
||||
});
|
||||
|
||||
test('formats travel time readably', () {
|
||||
expect(RouteMetricsHelper.formatDuration(const Duration(minutes: 4)),
|
||||
'4 min');
|
||||
expect(
|
||||
RouteMetricsHelper.formatDuration(
|
||||
const Duration(hours: 1, minutes: 5)),
|
||||
'1h 05m');
|
||||
RouteMetricsHelper.formatDuration(const Duration(minutes: 4)),
|
||||
'4 min',
|
||||
);
|
||||
expect(
|
||||
RouteMetricsHelper.formatDuration(const Duration(hours: 1, minutes: 5)),
|
||||
'1h 05m',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('Trip — grouping into slots', () {
|
||||
test('buckets stops into clock-aligned slot windows', () {
|
||||
test('buckets stops by day part — morning, afternoon, evening', () {
|
||||
final trips = Trip.groupIntoTrips([
|
||||
stop(id: 'a', due: DateTime(2026, 7, 27, 10, 15)),
|
||||
stop(id: 'b', due: DateTime(2026, 7, 27, 12, 40)),
|
||||
stop(id: 'c', due: DateTime(2026, 7, 27, 14, 5)),
|
||||
]);
|
||||
|
||||
// 10:15 and 12:40 both fall in the 09:00–12:00 / 12:00–15:00 blocks.
|
||||
// 12:40 and 14:05 are both the afternoon run. Under the old rolling
|
||||
// three-hour windows they landed on different trips, which made "which
|
||||
// trip is this on?" depend on the clock rather than on the shift.
|
||||
expect(trips.length, 2);
|
||||
expect(trips.first.slotStart, DateTime(2026, 7, 27, 9));
|
||||
expect(trips.first.slotEnd, DateTime(2026, 7, 27, 12));
|
||||
expect(trips.first.dayPart, DayPart.morning);
|
||||
expect(trips.first.tripNumber, 1);
|
||||
expect(trips.first.stopCount, 1);
|
||||
expect(trips.last.dayPart, DayPart.afternoon);
|
||||
expect(trips.last.tripNumber, 2);
|
||||
expect(trips.last.stopCount, 2);
|
||||
});
|
||||
|
||||
test('an afternoon-only day is Trip 2, not Trip 1', () {
|
||||
// The number is the day part. A rider whose work starts at two o'clock
|
||||
// used to see it as Trip 1, so his Trip 2 and the hub's were different
|
||||
// runs — the exact confusion this rule removes.
|
||||
final trips = Trip.groupIntoTrips([
|
||||
stop(id: 'a', due: DateTime(2026, 7, 27, 14, 0)),
|
||||
]);
|
||||
expect(trips.single.tripNumber, 2);
|
||||
expect(trips.tripAt(0), isNull, reason: 'no morning work');
|
||||
expect(trips.tripAt(1), isNotNull);
|
||||
expect(trips.tripAt(2), isNull, reason: 'no evening work');
|
||||
});
|
||||
|
||||
test('two runs in one part keep their order, at any hour', () {
|
||||
// ── The flake this pins ──
|
||||
//
|
||||
// Day-part slotting placed the first trip in its part and sent the
|
||||
// second to the first free slot *anywhere*, so two evening runs came out
|
||||
// `[2, 0]`: trip one under Trip 3, trip two under Trip 1 — inverted, and
|
||||
// only between 5pm and midnight. A layout that depends on the hour the
|
||||
// screen was opened is worse than one that ignores day parts.
|
||||
//
|
||||
// All or nothing: every trip has its own part, or the run is laid out in
|
||||
// the order it arrived. Checked at all three parts so no hour is special.
|
||||
for (final hour in const [9, 14, 19]) {
|
||||
final trips = Trip.groupIntoTrips([
|
||||
{
|
||||
...stop(id: 'first'),
|
||||
'tripid': 'A',
|
||||
'expected_pickup_time': DateTime(
|
||||
2026,
|
||||
8,
|
||||
21,
|
||||
hour,
|
||||
0,
|
||||
).toIso8601String(),
|
||||
},
|
||||
{
|
||||
...stop(id: 'second'),
|
||||
'tripid': 'B',
|
||||
'expected_pickup_time': DateTime(
|
||||
2026,
|
||||
8,
|
||||
21,
|
||||
hour,
|
||||
30,
|
||||
).toIso8601String(),
|
||||
},
|
||||
]);
|
||||
expect(trips.length, 2, reason: 'two hub trips at $hour:00');
|
||||
|
||||
final slots = trips.slots;
|
||||
expect(
|
||||
slots[0]?.stops.first['orderid'],
|
||||
'first',
|
||||
reason: 'the earlier run leads, at $hour:00',
|
||||
);
|
||||
expect(
|
||||
slots[1]?.stops.first['orderid'],
|
||||
'second',
|
||||
reason: 'and the later one follows, at $hour:00',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('the three parts split at noon and five', () {
|
||||
expect(DayPart.of(DateTime(2026, 7, 27, 11, 59)), DayPart.morning);
|
||||
expect(DayPart.of(DateTime(2026, 7, 27, 12, 0)), DayPart.afternoon);
|
||||
expect(DayPart.of(DateTime(2026, 7, 27, 16, 59)), DayPart.afternoon);
|
||||
expect(DayPart.of(DateTime(2026, 7, 27, 17, 0)), DayPart.evening);
|
||||
expect(DayPart.of(DateTime(2026, 7, 27, 23, 30)), DayPart.evening);
|
||||
});
|
||||
|
||||
test('a full day is three trips, never four', () {
|
||||
final trips = Trip.groupIntoTrips([
|
||||
for (var h = 6; h < 22; h += 2)
|
||||
stop(id: 'h$h', due: DateTime(2026, 7, 27, h, 30)),
|
||||
]);
|
||||
expect(trips.length, 3);
|
||||
expect(
|
||||
trips.map((t) => t.tripNumber),
|
||||
[1, 2, 3],
|
||||
reason: 'the day has three parts and the tabs have three slots',
|
||||
);
|
||||
});
|
||||
|
||||
test('an explicit backend trip id wins over time bucketing', () {
|
||||
final trips = Trip.groupIntoTrips([
|
||||
{...stop(id: 'a', due: DateTime(2026, 7, 27, 10, 0)), 'tripid': 'T1'},
|
||||
@@ -129,12 +227,30 @@ void main() {
|
||||
expect(trips.first.stops.first['orderid'], 'early');
|
||||
});
|
||||
|
||||
test('formats the slot window as a clock range', () {
|
||||
final trips = Trip.groupIntoTrips(
|
||||
[stop(id: 'a', due: DateTime(2026, 7, 27, 11, 30))],
|
||||
windowHours: 3,
|
||||
test('names the part of the day rather than spelling out a range', () {
|
||||
final trips = Trip.groupIntoTrips([
|
||||
stop(id: 'a', due: DateTime(2026, 7, 27, 11, 30)),
|
||||
]);
|
||||
expect(trips.single.slotLabel, 'Morning');
|
||||
expect(
|
||||
Trip.groupIntoTrips([
|
||||
stop(id: 'b', due: DateTime(2026, 7, 27, 19, 0)),
|
||||
]).single.slotLabel,
|
||||
'Evening',
|
||||
);
|
||||
expect(trips.single.slotLabel, '9:00 AM – 12:00 PM');
|
||||
});
|
||||
|
||||
test('a backend slot window is still shown as a clock range', () {
|
||||
// A tenant that sends real slot times means them literally, and
|
||||
// 10:00–13:00 is not "the morning".
|
||||
final trips = Trip.groupIntoTrips([
|
||||
{
|
||||
...stop(id: 'a', due: DateTime(2026, 7, 27, 11, 30)),
|
||||
'slotstarttime': DateTime(2026, 7, 27, 10).toIso8601String(),
|
||||
'slotendtime': DateTime(2026, 7, 27, 13).toIso8601String(),
|
||||
},
|
||||
]);
|
||||
expect(trips.single.slotLabel, '10:00 AM – 1:00 PM');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -202,11 +318,13 @@ void main() {
|
||||
});
|
||||
|
||||
test('round trip includes the return leg to the hub', () {
|
||||
final stops = [
|
||||
stop(id: 'a', step: 1, lat: 11.05, lng: 76.98),
|
||||
];
|
||||
final stops = [stop(id: 'a', step: 1, lat: 11.05, lng: 76.98)];
|
||||
final withHub = Trip.fromStops(
|
||||
id: 'h', stops: stops, hubLat: 11.01, hubLng: 76.95);
|
||||
id: 'h',
|
||||
stops: stops,
|
||||
hubLat: 11.01,
|
||||
hubLng: 76.95,
|
||||
);
|
||||
final withoutHub = Trip.fromStops(id: 'n', stops: stops);
|
||||
|
||||
// hub → stop → hub must be about twice the one-way leg, and strictly
|
||||
@@ -222,15 +340,21 @@ void main() {
|
||||
stop(id: 'b', step: 2, type: 'pickup'),
|
||||
],
|
||||
);
|
||||
expect(trip.averageStopDuration.inSeconds,
|
||||
trip.serviceDuration.inSeconds ~/ 2);
|
||||
expect(
|
||||
trip.averageStopDuration.inSeconds,
|
||||
trip.serviceDuration.inSeconds ~/ 2,
|
||||
);
|
||||
});
|
||||
|
||||
test('rounds estimates up to 5 minutes so they read as estimates', () {
|
||||
expect(roundTripDuration(const Duration(minutes: 37)),
|
||||
const Duration(minutes: 40));
|
||||
expect(roundTripDuration(const Duration(minutes: 1)),
|
||||
const Duration(minutes: 5));
|
||||
expect(
|
||||
roundTripDuration(const Duration(minutes: 37)),
|
||||
const Duration(minutes: 40),
|
||||
);
|
||||
expect(
|
||||
roundTripDuration(const Duration(minutes: 1)),
|
||||
const Duration(minutes: 5),
|
||||
);
|
||||
expect(formatTripDuration(const Duration(minutes: 100)), '1h 40m');
|
||||
});
|
||||
});
|
||||
@@ -321,18 +445,27 @@ void main() {
|
||||
|
||||
test('reads the backend status when there is no local decision', () {
|
||||
expect(
|
||||
stopStateOf({...s1, 'orderstatus': 'picked'},
|
||||
acceptedIds: const {}, rejectedIds: const {}),
|
||||
stopStateOf(
|
||||
{...s1, 'orderstatus': 'picked'},
|
||||
acceptedIds: const {},
|
||||
rejectedIds: const {},
|
||||
),
|
||||
StopState.done,
|
||||
);
|
||||
expect(
|
||||
stopStateOf({...s1, 'orderstatus': 'ARRIVED'},
|
||||
acceptedIds: const {}, rejectedIds: const {}),
|
||||
stopStateOf(
|
||||
{...s1, 'orderstatus': 'ARRIVED'},
|
||||
acceptedIds: const {},
|
||||
rejectedIds: const {},
|
||||
),
|
||||
StopState.active,
|
||||
);
|
||||
expect(
|
||||
stopStateOf({...s1, 'orderstatus': 'skipped'},
|
||||
acceptedIds: const {}, rejectedIds: const {}),
|
||||
stopStateOf(
|
||||
{...s1, 'orderstatus': 'skipped'},
|
||||
acceptedIds: const {},
|
||||
rejectedIds: const {},
|
||||
),
|
||||
StopState.skipped,
|
||||
);
|
||||
expect(
|
||||
@@ -377,7 +510,9 @@ void main() {
|
||||
// the rider would see 100% before riding a metre.
|
||||
expect(
|
||||
trip.completionPercent(
|
||||
acceptedIds: {'a', 'b', 'c', 'd'}, rejectedIds: const {}),
|
||||
acceptedIds: {'a', 'b', 'c', 'd'},
|
||||
rejectedIds: const {},
|
||||
),
|
||||
0,
|
||||
);
|
||||
});
|
||||
@@ -402,22 +537,24 @@ void main() {
|
||||
});
|
||||
|
||||
test('accept-all only targets stops still awaiting a decision', () {
|
||||
expect(
|
||||
trip.pendingOrderIds(acceptedIds: {'a'}, rejectedIds: {'b'}),
|
||||
['c', 'd'],
|
||||
);
|
||||
expect(trip.pendingOrderIds(acceptedIds: {'a'}, rejectedIds: {'b'}), [
|
||||
'c',
|
||||
'd',
|
||||
]);
|
||||
});
|
||||
|
||||
test('completed work fills the ring', () {
|
||||
final worked = Trip.fromStops(id: 't', stops: [
|
||||
{...stops[0], 'orderstatus': 'picked'},
|
||||
{...stops[1], 'orderstatus': 'picked'},
|
||||
stops[2],
|
||||
stops[3],
|
||||
]);
|
||||
final worked = Trip.fromStops(
|
||||
id: 't',
|
||||
stops: [
|
||||
{...stops[0], 'orderstatus': 'picked'},
|
||||
{...stops[1], 'orderstatus': 'picked'},
|
||||
stops[2],
|
||||
stops[3],
|
||||
],
|
||||
);
|
||||
expect(
|
||||
worked.completionPercent(
|
||||
acceptedIds: const {}, rejectedIds: const {}),
|
||||
worked.completionPercent(acceptedIds: const {}, rejectedIds: const {}),
|
||||
50,
|
||||
);
|
||||
expect(
|
||||
@@ -427,12 +564,15 @@ void main() {
|
||||
});
|
||||
|
||||
test('a trip finishes when every stop is done or rejected', () {
|
||||
final worked = Trip.fromStops(id: 't', stops: [
|
||||
{...stops[0], 'orderstatus': 'picked'},
|
||||
{...stops[1], 'orderstatus': 'picked'},
|
||||
{...stops[2], 'orderstatus': 'picked'},
|
||||
stops[3],
|
||||
]);
|
||||
final worked = Trip.fromStops(
|
||||
id: 't',
|
||||
stops: [
|
||||
{...stops[0], 'orderstatus': 'picked'},
|
||||
{...stops[1], 'orderstatus': 'picked'},
|
||||
{...stops[2], 'orderstatus': 'picked'},
|
||||
stops[3],
|
||||
],
|
||||
);
|
||||
expect(
|
||||
worked.completionPercent(acceptedIds: const {}, rejectedIds: {'d'}),
|
||||
100,
|
||||
@@ -445,12 +585,14 @@ void main() {
|
||||
|
||||
test('an empty trip is never reported as finished', () {
|
||||
final empty = Trip.fromStops(id: 'e', stops: const []);
|
||||
expect(empty.isFinished(acceptedIds: const {}, rejectedIds: const {}),
|
||||
isFalse);
|
||||
expect(
|
||||
empty.completionPercent(
|
||||
acceptedIds: const {}, rejectedIds: const {}),
|
||||
0);
|
||||
empty.isFinished(acceptedIds: const {}, rejectedIds: const {}),
|
||||
isFalse,
|
||||
);
|
||||
expect(
|
||||
empty.completionPercent(acceptedIds: const {}, rejectedIds: const {}),
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test('tab labels are 1-based', () {
|
||||
@@ -459,7 +601,6 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
group('StopState — decision precedence', () {
|
||||
final s = stop(id: 'a', step: 1);
|
||||
|
||||
@@ -468,16 +609,22 @@ void main() {
|
||||
// The local decision is strictly newer and must win, or the undo would
|
||||
// silently bounce back.
|
||||
expect(
|
||||
stopStateOf({...s, 'orderstatus': 'rejected'},
|
||||
acceptedIds: {'a'}, rejectedIds: const {}),
|
||||
stopStateOf(
|
||||
{...s, 'orderstatus': 'rejected'},
|
||||
acceptedIds: {'a'},
|
||||
rejectedIds: const {},
|
||||
),
|
||||
StopState.accepted,
|
||||
);
|
||||
});
|
||||
|
||||
test('a local reject beats a stale server "accepted"', () {
|
||||
expect(
|
||||
stopStateOf({...s, 'orderstatus': 'accepted'},
|
||||
acceptedIds: const {}, rejectedIds: {'a'}),
|
||||
stopStateOf(
|
||||
{...s, 'orderstatus': 'accepted'},
|
||||
acceptedIds: const {},
|
||||
rejectedIds: {'a'},
|
||||
),
|
||||
StopState.rejected,
|
||||
);
|
||||
});
|
||||
@@ -486,144 +633,54 @@ void main() {
|
||||
// You cannot un-collect a parcel. The terminal server state wins over
|
||||
// any local tap.
|
||||
expect(
|
||||
stopStateOf({...s, 'orderstatus': 'picked'},
|
||||
acceptedIds: const {}, rejectedIds: {'a'}),
|
||||
stopStateOf(
|
||||
{...s, 'orderstatus': 'picked'},
|
||||
acceptedIds: const {},
|
||||
rejectedIds: {'a'},
|
||||
),
|
||||
StopState.done,
|
||||
);
|
||||
expect(
|
||||
stopStateOf({...s, 'orderstatus': 'cancelled'},
|
||||
acceptedIds: {'a'}, rejectedIds: const {}),
|
||||
stopStateOf(
|
||||
{...s, 'orderstatus': 'cancelled'},
|
||||
acceptedIds: {'a'},
|
||||
rejectedIds: const {},
|
||||
),
|
||||
StopState.done,
|
||||
);
|
||||
});
|
||||
|
||||
test('a stop being worked right now outranks a local decision', () {
|
||||
expect(
|
||||
stopStateOf({...s, 'orderstatus': 'arrived'},
|
||||
acceptedIds: const {}, rejectedIds: {'a'}),
|
||||
stopStateOf(
|
||||
{...s, 'orderstatus': 'arrived'},
|
||||
acceptedIds: const {},
|
||||
rejectedIds: {'a'},
|
||||
),
|
||||
StopState.active,
|
||||
);
|
||||
});
|
||||
|
||||
test('falls back to the server view with no local decision', () {
|
||||
expect(
|
||||
stopStateOf({...s, 'orderstatus': 'rejected'},
|
||||
acceptedIds: const {}, rejectedIds: const {}),
|
||||
stopStateOf(
|
||||
{...s, 'orderstatus': 'rejected'},
|
||||
acceptedIds: const {},
|
||||
rejectedIds: const {},
|
||||
),
|
||||
StopState.rejected,
|
||||
);
|
||||
expect(
|
||||
stopStateOf({...s, 'orderstatus': 'accepted'},
|
||||
acceptedIds: const {}, rejectedIds: const {}),
|
||||
stopStateOf(
|
||||
{...s, 'orderstatus': 'accepted'},
|
||||
acceptedIds: const {},
|
||||
rejectedIds: const {},
|
||||
),
|
||||
StopState.accepted,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
group('Demo trips fixture', () {
|
||||
test('groups into three trips covering every stop type', () {
|
||||
final trips = Trip.groupIntoTrips(getDemoTripStops());
|
||||
expect(trips.length, 3);
|
||||
|
||||
final kinds = {
|
||||
for (final t in trips)
|
||||
for (final s in t.stops) stopKindOf(s),
|
||||
};
|
||||
expect(kinds, containsAll(StopKind.values));
|
||||
});
|
||||
|
||||
test('trip 1 covers every state the card can render', () {
|
||||
final trips = Trip.groupIntoTrips(getDemoTripStops());
|
||||
final states = trips.first
|
||||
.states(acceptedIds: const {}, rejectedIds: const {});
|
||||
|
||||
// done + active exercise the locked rows; skipped the amber state;
|
||||
// pending the accept/reject buttons.
|
||||
expect(states, contains(StopState.done));
|
||||
expect(states, contains(StopState.active));
|
||||
expect(states, contains(StopState.skipped));
|
||||
expect(states, contains(StopState.pending));
|
||||
|
||||
// Part-worked, so the ring is neither empty nor full.
|
||||
final percent = trips.first
|
||||
.completionPercent(acceptedIds: const {}, rejectedIds: const {});
|
||||
expect(percent, greaterThan(0));
|
||||
expect(percent, lessThan(100));
|
||||
});
|
||||
|
||||
test('every stop type appears in more than one trip', () {
|
||||
final trips = Trip.groupIntoTrips(getDemoTripStops());
|
||||
for (final kind in StopKind.values) {
|
||||
final tripsWithKind = trips
|
||||
.where((t) => t.stops.any((s) => stopKindOf(s) == kind))
|
||||
.length;
|
||||
expect(tripsWithKind, greaterThan(1), reason: 'only $tripsWithKind trip(s) have $kind');
|
||||
}
|
||||
});
|
||||
|
||||
test('deliveries and combined stops are well represented', () {
|
||||
final stops = getDemoTripStops();
|
||||
int countOf(StopKind k) =>
|
||||
stops.where((s) => stopKindOf(s) == k).length;
|
||||
|
||||
// The card's delivery and combined branches were the thinnest on data,
|
||||
// so the fixture deliberately over-weights them.
|
||||
expect(countOf(StopKind.delivery), greaterThanOrEqualTo(6));
|
||||
expect(countOf(StopKind.combined), greaterThanOrEqualTo(6));
|
||||
expect(countOf(StopKind.pickup), greaterThanOrEqualTo(3));
|
||||
});
|
||||
|
||||
test('a finished DELIVERY exists, not just a finished pickup', () {
|
||||
final done = getDemoTripStops().where((s) =>
|
||||
stopStateOf(s, acceptedIds: const {}, rejectedIds: const {}) ==
|
||||
StopState.done);
|
||||
expect(done.any((s) => stopKindOf(s) == StopKind.delivery), isTrue);
|
||||
expect(done.any((s) => stopKindOf(s) == StopKind.pickup), isTrue);
|
||||
});
|
||||
|
||||
test('a combined stop carries cash on its pickup leg', () {
|
||||
final combinedWithCash = getDemoTripStops().where((s) =>
|
||||
stopKindOf(s) == StopKind.combined && stopCollectionAmount(s) > 0);
|
||||
expect(combinedWithCash, isNotEmpty);
|
||||
});
|
||||
|
||||
test('multi-parcel deliveries exist for the service-time estimate', () {
|
||||
final multi = getDemoTripStops().where((s) =>
|
||||
stopKindOf(s) == StopKind.delivery && deliveryParcelCount(s) > 2);
|
||||
expect(multi, isNotEmpty);
|
||||
});
|
||||
|
||||
test('trips 2 and 3 are entirely undecided', () {
|
||||
final trips = Trip.groupIntoTrips(getDemoTripStops());
|
||||
for (final t in trips.skip(1)) {
|
||||
expect(
|
||||
t.pendingOrderIds(acceptedIds: const {}, rejectedIds: const {})
|
||||
.length,
|
||||
t.stopCount,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('demo stops carry the fields the card renders', () {
|
||||
for (final s in getDemoTripStops()) {
|
||||
expect((s['orderid'] as String).isNotEmpty, isTrue);
|
||||
expect((s['pickupcustomer'] as String).isNotEmpty, isTrue);
|
||||
expect((s['pickupaddress'] as String).isNotEmpty, isTrue);
|
||||
expect(double.tryParse(s['pickuplat'] as String), isNotNull);
|
||||
expect(DateTime.tryParse(s['expected_pickup_time'] as String),
|
||||
isNotNull);
|
||||
}
|
||||
});
|
||||
|
||||
test('a combined demo stop splits deliver and collect counts', () {
|
||||
final combined = getDemoTripStops()
|
||||
.firstWhere((s) => stopKindOf(s) == StopKind.combined);
|
||||
expect(deliveryParcelCount(combined), greaterThan(0));
|
||||
expect(pickupParcelCount(combined), greaterThan(0));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
group('Trip tabs — always three slots', () {
|
||||
test('a one-trip day still fills three tab slots', () {
|
||||
final trips = Trip.groupIntoTrips([
|
||||
@@ -649,21 +706,25 @@ void main() {
|
||||
];
|
||||
final trips = Trip.groupIntoTrips(stops);
|
||||
|
||||
expect(trips.length, Trip.maxTripsPerDay,
|
||||
reason: 'a rider is assigned three slots, not eight');
|
||||
expect(
|
||||
trips.length,
|
||||
Trip.maxTripsPerDay,
|
||||
reason: 'a rider is assigned three slots, not eight',
|
||||
);
|
||||
|
||||
// The part that matters. Truncating the list would be the one-line fix and
|
||||
// it would silently hide real work: these are accepted bookings, and a
|
||||
// stop the rider cannot see is a stop he cannot deliver.
|
||||
final carried = trips.expand((t) => t.stops).length;
|
||||
expect(carried, stops.length,
|
||||
reason: 'every stop must survive the merge — $carried of '
|
||||
'${stops.length} came through');
|
||||
expect(
|
||||
carried,
|
||||
stops.length,
|
||||
reason:
|
||||
'every stop must survive the merge — $carried of '
|
||||
'${stops.length} came through',
|
||||
);
|
||||
|
||||
final ids = trips
|
||||
.expand((t) => t.stops)
|
||||
.map((s) => s['orderid'])
|
||||
.toSet();
|
||||
final ids = trips.expand((t) => t.stops).map((s) => s['orderid']).toSet();
|
||||
expect(ids.length, stops.length, reason: 'and none may be duplicated');
|
||||
|
||||
// The tail lands in the last trip, whose window widens to cover what it
|
||||
@@ -681,11 +742,11 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
group('Address compression', () {
|
||||
test('drops the tail every stop on the trip shares', () {
|
||||
const a = '5/2, North Avenue, Vadavalli, Coimbatore, Tamil Nadu, 641041';
|
||||
const b = '78, Cross Cut Road, Gandhipuram, Coimbatore, Tamil Nadu, 641012';
|
||||
const b =
|
||||
'78, Cross Cut Road, Gandhipuram, Coimbatore, Tamil Nadu, 641012';
|
||||
// Only the pincode differs, so "Coimbatore, Tamil Nadu" is redundant.
|
||||
final tail = commonAddressTail(const [a, b]);
|
||||
expect(tail, isEmpty); // pincodes differ → nothing shared at the very end
|
||||
@@ -708,8 +769,10 @@ void main() {
|
||||
});
|
||||
|
||||
test('is a no-op on a one-stop trip', () {
|
||||
expect(commonAddressTail(const ['5/2, North Avenue, Coimbatore']),
|
||||
isEmpty);
|
||||
expect(
|
||||
commonAddressTail(const ['5/2, North Avenue, Coimbatore']),
|
||||
isEmpty,
|
||||
);
|
||||
});
|
||||
|
||||
test('never strips an address down to nothing', () {
|
||||
@@ -727,19 +790,76 @@ void main() {
|
||||
expect(stripAddressTail(a, tail), '5/2, North Avenue');
|
||||
});
|
||||
|
||||
test('shortens the real demo trip addresses', () {
|
||||
final trips = Trip.groupIntoTrips(getDemoTripStops());
|
||||
for (final t in trips) {
|
||||
final tail = t.sharedAddressTail;
|
||||
// Every demo address ends in "Coimbatore".
|
||||
expect(tail, ['Coimbatore']);
|
||||
for (final stop in t.stops) {
|
||||
final short = t.shortAddress(stop, tail);
|
||||
expect(short.contains('Coimbatore'), isFalse);
|
||||
expect(short.isNotEmpty, isTrue);
|
||||
}
|
||||
test('strips the shared tail off every stop on a trip', () {
|
||||
// Built here rather than read from a fixture. The assertion is about
|
||||
// [Trip.sharedAddressTail] and [Trip.shortAddress]; a fixture only made
|
||||
// it harder to see which addresses produced the answer.
|
||||
final trip = Trip.fromStops(
|
||||
id: 't',
|
||||
stops: [
|
||||
stop(
|
||||
id: 'a',
|
||||
address: '45, Lake View Apartments, Race Course, Coimbatore',
|
||||
),
|
||||
stop(
|
||||
id: 'b',
|
||||
address: '23, Teachers Colony, Saibaba Colony, Coimbatore',
|
||||
),
|
||||
stop(id: 'c', address: '9, Cross Cut Road, Gandhipuram, Coimbatore'),
|
||||
],
|
||||
);
|
||||
|
||||
final tail = trip.sharedAddressTail;
|
||||
expect(tail, ['Coimbatore']);
|
||||
|
||||
for (final s in trip.stops) {
|
||||
final short = trip.shortAddress(s, tail);
|
||||
expect(short.contains('Coimbatore'), isFalse);
|
||||
expect(short.isNotEmpty, isTrue);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── The rail's legs run door to door ──
|
||||
//
|
||||
// `travelTimeToStop(deliveryLeg: true)` walks the drop pairs; the default
|
||||
// walks the pickup pairs exactly as before. The fixture puts the two drops
|
||||
// ~11 km apart while the two kitchens sit ~200 m apart, so a wrong leg is a
|
||||
// 50× error the assertion cannot miss.
|
||||
group('travelTimeToStop legs', () {
|
||||
final stops = [
|
||||
{
|
||||
'orderid': 'A',
|
||||
'type': 'delivery',
|
||||
'pickupcustomer': 'Joe',
|
||||
'pickuplat': 11.0000,
|
||||
'pickuplon': 76.9500,
|
||||
'droplat': 11.0000,
|
||||
'droplon': 76.9500,
|
||||
},
|
||||
{
|
||||
'orderid': 'B',
|
||||
'type': 'delivery',
|
||||
'pickupcustomer': 'Arun',
|
||||
'pickuplat': 11.0018, // ~200 m from A's kitchen
|
||||
'pickuplon': 76.9500,
|
||||
'droplat': 11.1000, // ~11 km from A's drop
|
||||
'droplon': 76.9500,
|
||||
},
|
||||
];
|
||||
|
||||
test('the default walks the pickup pairs — Home untouched', () {
|
||||
final t = Trip.fromStops(id: 't', stops: stops);
|
||||
expect(t.travelTimeToStop(1).inMinutes, lessThan(5));
|
||||
});
|
||||
|
||||
test('deliveryLeg walks the drop pairs', () {
|
||||
final t = Trip.fromStops(id: 't', stops: stops);
|
||||
expect(
|
||||
t.travelTimeToStop(1, deliveryLeg: true).inMinutes,
|
||||
greaterThan(20),
|
||||
reason: 'the leg between DOORS is the one the round actually rides',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user