870 lines
29 KiB
Dart
870 lines
29 KiB
Dart
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';
|
||
|
||
Map<String, dynamic> stop({
|
||
required String id,
|
||
int? step,
|
||
String type = 'pickup',
|
||
int qty = 1,
|
||
double? cash,
|
||
double lat = 11.03,
|
||
double lng = 76.96,
|
||
DateTime? due,
|
||
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', () {
|
||
test('shows metres below 1 km, rounded to 10', () {
|
||
expect(RouteMetricsHelper.formatDistance(450), '450 m');
|
||
expect(RouteMetricsHelper.formatDistance(447), '450 m');
|
||
expect(RouteMetricsHelper.formatDistance(83), '80 m');
|
||
expect(RouteMetricsHelper.formatDistance(999), '1000 m');
|
||
});
|
||
|
||
test('shows kilometres with one decimal at and above 1 km', () {
|
||
expect(RouteMetricsHelper.formatDistance(1000), '1.0 km');
|
||
expect(RouteMetricsHelper.formatDistance(3200), '3.2 km');
|
||
});
|
||
|
||
test('drops the decimal past 100 km', () {
|
||
expect(RouteMetricsHelper.formatDistance(104400), '104 km');
|
||
});
|
||
|
||
test('never fakes a zero', () {
|
||
expect(RouteMetricsHelper.formatDistance(null), '—');
|
||
expect(RouteMetricsHelper.formatDistance(0), '—');
|
||
expect(RouteMetricsHelper.formatDistanceAway(null), 'Distance unknown');
|
||
});
|
||
|
||
test('rounds a tiny distance up to the 10 m floor', () {
|
||
expect(RouteMetricsHelper.formatDistance(3), '10 m');
|
||
});
|
||
});
|
||
|
||
group('RouteMetricsHelper — speed and ETA', () {
|
||
test('falls back to the urban average when speed is unusable', () {
|
||
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,
|
||
);
|
||
// A GPS spike is a bad fix, not a fast rider.
|
||
expect(
|
||
RouteMetricsHelper.usableSpeedMps(60),
|
||
RouteMetricsHelper.kDefaultSpeedMps,
|
||
);
|
||
});
|
||
|
||
test('uses a real riding speed when it is plausible', () {
|
||
expect(RouteMetricsHelper.usableSpeedMps(8.0), 8.0);
|
||
});
|
||
|
||
test('computes an arrival clock time', () {
|
||
final now = DateTime(2026, 7, 27, 16, 0);
|
||
// 1110 m at 5.55 m/s = 200 s ≈ 3 min.
|
||
final eta = RouteMetricsHelper.eta(1110, now: now)!;
|
||
expect(eta.difference(now).inSeconds, 200);
|
||
expect(RouteMetricsHelper.formatEta(1110, now: now), '4:03 PM');
|
||
});
|
||
|
||
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',
|
||
);
|
||
});
|
||
});
|
||
|
||
group('Trip — grouping into slots', () {
|
||
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)),
|
||
]);
|
||
|
||
// 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.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'},
|
||
{...stop(id: 'b', due: DateTime(2026, 7, 27, 20, 0)), 'tripid': 'T1'},
|
||
]);
|
||
|
||
expect(trips.length, 1);
|
||
expect(trips.single.stopCount, 2);
|
||
});
|
||
|
||
test('stops with no time still land in a trip rather than vanishing', () {
|
||
final trips = Trip.groupIntoTrips([stop(id: 'a'), stop(id: 'b')]);
|
||
expect(trips.length, 1);
|
||
expect(trips.single.stopCount, 2);
|
||
expect(trips.single.slotLabel, "Today's route");
|
||
});
|
||
|
||
test('trips come back earliest slot first', () {
|
||
final trips = Trip.groupIntoTrips([
|
||
stop(id: 'late', due: DateTime(2026, 7, 27, 16, 0)),
|
||
stop(id: 'early', due: DateTime(2026, 7, 27, 10, 0)),
|
||
]);
|
||
expect(trips.first.stops.first['orderid'], 'early');
|
||
});
|
||
|
||
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',
|
||
);
|
||
});
|
||
|
||
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');
|
||
});
|
||
});
|
||
|
||
group('Trip — ordering', () {
|
||
test('respects the admin step sequence, never distance', () {
|
||
final ordered = Trip.sortStops([
|
||
stop(id: 'c', step: 3),
|
||
stop(id: 'a', step: 1),
|
||
stop(id: 'b', step: 2),
|
||
]);
|
||
expect([for (final s in ordered) s['orderid']], ['a', 'b', 'c']);
|
||
});
|
||
|
||
test('falls back to booked time when step is missing', () {
|
||
final ordered = Trip.sortStops([
|
||
stop(id: 'later', due: DateTime(2026, 7, 27, 12, 0)),
|
||
stop(id: 'sooner', due: DateTime(2026, 7, 27, 10, 0)),
|
||
]);
|
||
expect(ordered.first['orderid'], 'sooner');
|
||
});
|
||
|
||
test('is stable when nothing distinguishes two stops', () {
|
||
final ordered = Trip.sortStops([stop(id: 'x'), stop(id: 'y')]);
|
||
expect([for (final s in ordered) s['orderid']], ['x', 'y']);
|
||
});
|
||
});
|
||
|
||
group('Trip — duration estimates', () {
|
||
test('service time varies by stop kind', () {
|
||
final pickup = Trip.serviceTimeFor(stop(id: 'p', type: 'pickup'));
|
||
final delivery = Trip.serviceTimeFor(stop(id: 'd', type: 'delivery'));
|
||
final combined = Trip.serviceTimeFor(stop(id: 'c', type: 'combined'));
|
||
|
||
// Handing over is quickest; doing both at one door is slowest.
|
||
expect(delivery < pickup, isTrue);
|
||
expect(pickup < combined, isTrue);
|
||
});
|
||
|
||
test('cash collection adds real minutes', () {
|
||
final free = Trip.serviceTimeFor(stop(id: 'a', type: 'pickup'));
|
||
final cod = Trip.serviceTimeFor(stop(id: 'b', type: 'pickup', cash: 250));
|
||
expect(cod - free, const Duration(seconds: 90));
|
||
});
|
||
|
||
test('extra parcels add handling time', () {
|
||
final one = Trip.serviceTimeFor(stop(id: 'a', type: 'pickup', qty: 1));
|
||
final five = Trip.serviceTimeFor(stop(id: 'b', type: 'pickup', qty: 5));
|
||
expect(five > one, isTrue);
|
||
});
|
||
|
||
test('total duration is ride time plus every doorstep minute', () {
|
||
final trip = Trip.fromStops(
|
||
id: 't',
|
||
stops: [
|
||
stop(id: 'a', step: 1, lat: 11.03, lng: 76.96),
|
||
stop(id: 'b', step: 2, lat: 11.05, lng: 76.98),
|
||
],
|
||
hubLat: 11.01,
|
||
hubLng: 76.95,
|
||
);
|
||
|
||
expect(trip.totalDuration, trip.travelDuration + trip.serviceDuration);
|
||
expect(trip.serviceDuration > Duration.zero, isTrue);
|
||
expect(trip.travelDuration > Duration.zero, isTrue);
|
||
});
|
||
|
||
test('round trip includes the return leg to the hub', () {
|
||
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,
|
||
);
|
||
final withoutHub = Trip.fromStops(id: 'n', stops: stops);
|
||
|
||
// hub → stop → hub must be about twice the one-way leg, and strictly
|
||
// longer than a chain with no hub at all.
|
||
expect(withHub.routeMeters, greaterThan(withoutHub.routeMeters));
|
||
});
|
||
|
||
test('average stop duration divides service time across stops', () {
|
||
final trip = Trip.fromStops(
|
||
id: 't',
|
||
stops: [
|
||
stop(id: 'a', step: 1, type: 'pickup'),
|
||
stop(id: 'b', step: 2, type: 'pickup'),
|
||
],
|
||
);
|
||
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(formatTripDuration(const Duration(minutes: 100)), '1h 40m');
|
||
});
|
||
});
|
||
|
||
group('Trip — totals', () {
|
||
test('splits deliver and collect parcels and sums cash', () {
|
||
final trip = Trip.fromStops(
|
||
id: 't',
|
||
stops: [
|
||
stop(id: 'a', step: 1, type: 'delivery', qty: 2),
|
||
stop(id: 'b', step: 2, type: 'pickup', qty: 3, cash: 250),
|
||
],
|
||
);
|
||
|
||
expect(trip.deliverParcels, 2);
|
||
expect(trip.collectParcels, 3);
|
||
expect(trip.cashToCollect, 250);
|
||
expect(trip.orderIds, ['a', 'b']);
|
||
});
|
||
|
||
test('a combined stop counts on both sides', () {
|
||
final combined = {
|
||
...stop(id: 'c', step: 1),
|
||
'type': 'combined',
|
||
'deliveryqty': '1',
|
||
'pickupqty': '2',
|
||
};
|
||
final trip = Trip.fromStops(id: 't', stops: [combined]);
|
||
|
||
expect(stopKindOf(combined), StopKind.combined);
|
||
expect(trip.deliverParcels, 1);
|
||
expect(trip.collectParcels, 2);
|
||
expect(trip.totalParcels, 3);
|
||
});
|
||
});
|
||
|
||
group('StopKind — three modes', () {
|
||
test('infers combined from a payload carrying both quantities', () {
|
||
final s = {'deliveryqty': '1', 'pickupqty': '2'};
|
||
expect(stopKindOf(s), StopKind.combined);
|
||
});
|
||
|
||
test('combined runs the delivery leg first', () {
|
||
expect(StopKind.combined.legs, [StopLeg.delivery, StopLeg.pickup]);
|
||
expect(StopKind.pickup.legs, [StopLeg.pickup]);
|
||
expect(StopKind.delivery.legs, [StopLeg.delivery]);
|
||
});
|
||
|
||
test('isDelivery stays narrow so combined keeps its payment path', () {
|
||
// A combined stop still has a pickup leg that can carry COD, so it must
|
||
// NOT be treated as a prepaid delivery-only stop.
|
||
expect(StopKind.combined.isDelivery, isFalse);
|
||
expect(StopKind.combined.hasDelivery, isTrue);
|
||
expect(StopKind.combined.hasPickup, isTrue);
|
||
});
|
||
|
||
test('labels and accents differ per mode', () {
|
||
expect(StopKind.combined.badgeLabel, 'PICKUP & DELIVERY');
|
||
expect(StopKind.combined.startCtaLabel, 'Start Combined Stop');
|
||
expect(StopKind.pickup.accent == StopKind.delivery.accent, isFalse);
|
||
expect(StopKind.combined.accent == StopKind.pickup.accent, isFalse);
|
||
});
|
||
|
||
test('survives a wire round trip', () {
|
||
for (final k in StopKind.values) {
|
||
expect(stopKindFromWire(k.wireName), k);
|
||
}
|
||
expect(stopKindFromWire('nonsense'), StopKind.pickup);
|
||
});
|
||
});
|
||
|
||
group('StopState — per-stop lifecycle', () {
|
||
final s1 = stop(id: 'a', step: 1);
|
||
final s2 = stop(id: 'b', step: 2);
|
||
|
||
test('local accept/reject wins over a stale server status', () {
|
||
// The optimistic write must not be bounced back to pending by a slow
|
||
// refetch that still says "new".
|
||
expect(
|
||
stopStateOf(s1, acceptedIds: {'a'}, rejectedIds: const {}),
|
||
StopState.accepted,
|
||
);
|
||
expect(
|
||
stopStateOf(s2, acceptedIds: const {}, rejectedIds: {'b'}),
|
||
StopState.rejected,
|
||
);
|
||
});
|
||
|
||
test('reads the backend status when there is no local decision', () {
|
||
expect(
|
||
stopStateOf(
|
||
{...s1, 'orderstatus': 'picked'},
|
||
acceptedIds: const {},
|
||
rejectedIds: const {},
|
||
),
|
||
StopState.done,
|
||
);
|
||
expect(
|
||
stopStateOf(
|
||
{...s1, 'orderstatus': 'ARRIVED'},
|
||
acceptedIds: const {},
|
||
rejectedIds: const {},
|
||
),
|
||
// Its own rung since the live chip stopped printing `Active` over it —
|
||
// see [StopState.arrived].
|
||
StopState.arrived,
|
||
);
|
||
expect(
|
||
stopStateOf(
|
||
{...s1, 'orderstatus': 'skipped'},
|
||
acceptedIds: const {},
|
||
rejectedIds: const {},
|
||
),
|
||
StopState.skipped,
|
||
);
|
||
expect(
|
||
stopStateOf(s1, acceptedIds: const {}, rejectedIds: const {}),
|
||
StopState.pending,
|
||
);
|
||
});
|
||
|
||
test('classifies states for the UI', () {
|
||
expect(StopState.done.isResolved, isTrue);
|
||
// Rejected is resolved: the rider has nothing left to do with it, so it
|
||
// must not strand the trip below 100%.
|
||
expect(StopState.rejected.isResolved, isTrue);
|
||
expect(StopState.accepted.isResolved, isFalse);
|
||
expect(StopState.accepted.isCommitted, isTrue);
|
||
expect(StopState.pending.needsDecision, isTrue);
|
||
});
|
||
});
|
||
|
||
group('Trip — completion and partial accepts', () {
|
||
final stops = [
|
||
stop(id: 'a', step: 1),
|
||
stop(id: 'b', step: 2),
|
||
stop(id: 'c', step: 3),
|
||
stop(id: 'd', step: 4),
|
||
];
|
||
final trip = Trip.fromStops(id: 't', stops: stops);
|
||
|
||
test('a fresh trip is 0% and all stops need a decision', () {
|
||
expect(
|
||
trip.completionPercent(acceptedIds: const {}, rejectedIds: const {}),
|
||
0,
|
||
);
|
||
expect(
|
||
trip.pendingOrderIds(acceptedIds: const {}, rejectedIds: const {}),
|
||
['a', 'b', 'c', 'd'],
|
||
);
|
||
});
|
||
|
||
test('accepting stops does NOT advance completion — work does', () {
|
||
// Accepting is a commitment, not progress. If accepting moved the ring
|
||
// the rider would see 100% before riding a metre.
|
||
expect(
|
||
trip.completionPercent(
|
||
acceptedIds: {'a', 'b', 'c', 'd'},
|
||
rejectedIds: const {},
|
||
),
|
||
0,
|
||
);
|
||
});
|
||
|
||
test('the rider can take 1, 2 and 4 and decline 3', () {
|
||
final accepted = {'a', 'b', 'd'};
|
||
const rejected = {'c'};
|
||
|
||
expect(
|
||
trip.pendingOrderIds(acceptedIds: accepted, rejectedIds: rejected),
|
||
isEmpty,
|
||
);
|
||
expect(
|
||
trip.hasPendingStops(acceptedIds: accepted, rejectedIds: rejected),
|
||
isFalse,
|
||
);
|
||
// The rejected stop counts as resolved → 1 of 4 = 25%.
|
||
expect(
|
||
trip.completionPercent(acceptedIds: accepted, rejectedIds: rejected),
|
||
25,
|
||
);
|
||
});
|
||
|
||
test('accept-all only targets stops still awaiting a decision', () {
|
||
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],
|
||
],
|
||
);
|
||
expect(
|
||
worked.completionPercent(acceptedIds: const {}, rejectedIds: const {}),
|
||
50,
|
||
);
|
||
expect(
|
||
worked.isFinished(acceptedIds: const {}, rejectedIds: const {}),
|
||
isFalse,
|
||
);
|
||
});
|
||
|
||
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],
|
||
],
|
||
);
|
||
expect(
|
||
worked.completionPercent(acceptedIds: const {}, rejectedIds: {'d'}),
|
||
100,
|
||
);
|
||
expect(
|
||
worked.isFinished(acceptedIds: const {}, rejectedIds: {'d'}),
|
||
isTrue,
|
||
);
|
||
});
|
||
|
||
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,
|
||
);
|
||
});
|
||
|
||
test('tab labels are 1-based', () {
|
||
expect(Trip.tabLabel(0), 'Trip 1');
|
||
expect(Trip.tabLabel(2), 'Trip 3');
|
||
});
|
||
});
|
||
|
||
group('StopState — decision precedence', () {
|
||
final s = stop(id: 'a', step: 1);
|
||
|
||
test('a local un-reject beats a stale server "rejected"', () {
|
||
// The rider tapped "Accept instead"; the next poll still says rejected.
|
||
// 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 {},
|
||
),
|
||
StopState.accepted,
|
||
);
|
||
});
|
||
|
||
test('a local reject beats a stale server "accepted"', () {
|
||
expect(
|
||
stopStateOf(
|
||
{...s, 'orderstatus': 'accepted'},
|
||
acceptedIds: const {},
|
||
rejectedIds: {'a'},
|
||
),
|
||
StopState.rejected,
|
||
);
|
||
});
|
||
|
||
test('collected work cannot be un-decided locally', () {
|
||
// You cannot un-collect a parcel. The terminal server state wins over
|
||
// any local tap.
|
||
expect(
|
||
stopStateOf(
|
||
{...s, 'orderstatus': 'picked'},
|
||
acceptedIds: const {},
|
||
rejectedIds: {'a'},
|
||
),
|
||
StopState.done,
|
||
);
|
||
expect(
|
||
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'},
|
||
),
|
||
// Arrived is its own rung now — see [StopState.arrived]. What the
|
||
// test is about is unchanged: a server rung outranks a local decision.
|
||
StopState.arrived,
|
||
);
|
||
});
|
||
|
||
test('falls back to the server view with no local decision', () {
|
||
expect(
|
||
stopStateOf(
|
||
{...s, 'orderstatus': 'rejected'},
|
||
acceptedIds: const {},
|
||
rejectedIds: const {},
|
||
),
|
||
StopState.rejected,
|
||
);
|
||
expect(
|
||
stopStateOf(
|
||
{...s, 'orderstatus': 'accepted'},
|
||
acceptedIds: const {},
|
||
rejectedIds: const {},
|
||
),
|
||
StopState.accepted,
|
||
);
|
||
});
|
||
});
|
||
|
||
group('Trip tabs — always three slots', () {
|
||
test('a one-trip day still fills three tab slots', () {
|
||
final trips = Trip.groupIntoTrips([
|
||
stop(id: 'a', due: DateTime(2026, 7, 27, 10, 0)),
|
||
]);
|
||
expect(trips.length, 1);
|
||
|
||
// Trip 2 and Trip 3 exist as tabs; indices past the end are legitimate
|
||
// "not assigned yet" slots, not errors.
|
||
const maxInline = 3;
|
||
final tabCount = trips.length > maxInline ? trips.length : maxInline;
|
||
expect(tabCount, 3);
|
||
expect(2.clamp(0, tabCount - 1), 2);
|
||
});
|
||
|
||
test('an unusual day folds into three trips without losing a stop', () {
|
||
// Bucketing is time-based, so a stop booked well outside the others — or
|
||
// simply a long day — used to produce a fourth, fifth or eighth tab.
|
||
// "Trip 8" is not a thing the hub assigns.
|
||
final stops = [
|
||
for (var h = 0; h < 24; h += 3)
|
||
stop(id: 'h$h', due: DateTime(2026, 7, 27, h, 30)),
|
||
];
|
||
final trips = Trip.groupIntoTrips(stops);
|
||
|
||
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',
|
||
);
|
||
|
||
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
|
||
// now holds, so the slot label stays honest.
|
||
expect(trips.last.stops.length, greaterThan(1));
|
||
expect(trips.last.slotStart!.isBefore(trips.last.slotEnd!), isTrue);
|
||
});
|
||
|
||
test('a normal day is left exactly as it is', () {
|
||
final trips = Trip.groupIntoTrips([
|
||
stop(id: 'a', due: DateTime(2026, 7, 27, 9, 30)),
|
||
stop(id: 'b', due: DateTime(2026, 7, 27, 13, 30)),
|
||
]);
|
||
expect(trips.length, 2, reason: 'the cap only bites past three');
|
||
});
|
||
});
|
||
|
||
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';
|
||
// 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
|
||
});
|
||
|
||
test('strips a fully shared tail', () {
|
||
const a = '5/2, North Avenue, Vadavalli, Coimbatore, Tamil Nadu';
|
||
const b = '78, Cross Cut Road, Gandhipuram, Coimbatore, Tamil Nadu';
|
||
final tail = commonAddressTail(const [a, b]);
|
||
expect(tail, ['Coimbatore', 'Tamil Nadu']);
|
||
expect(stripAddressTail(a, tail), '5/2, North Avenue, Vadavalli');
|
||
expect(stripAddressTail(b, tail), '78, Cross Cut Road, Gandhipuram');
|
||
});
|
||
|
||
test('is a no-op when nothing is shared', () {
|
||
const a = '5/2, North Avenue, Vadavalli';
|
||
const b = '78, Cross Cut Road, Chennai';
|
||
expect(commonAddressTail(const [a, b]), isEmpty);
|
||
expect(stripAddressTail(a, const []), a);
|
||
});
|
||
|
||
test('is a no-op on a one-stop trip', () {
|
||
expect(
|
||
commonAddressTail(const ['5/2, North Avenue, Coimbatore']),
|
||
isEmpty,
|
||
);
|
||
});
|
||
|
||
test('never strips an address down to nothing', () {
|
||
// Two identical addresses share everything; one component must survive.
|
||
const a = 'Coimbatore';
|
||
final tail = commonAddressTail(const [a, a]);
|
||
expect(stripAddressTail(a, tail).isNotEmpty, isTrue);
|
||
});
|
||
|
||
test('ignores case when matching the tail', () {
|
||
const a = '5/2, North Avenue, COIMBATORE';
|
||
const b = '78, Cross Cut Road, coimbatore';
|
||
final tail = commonAddressTail(const [a, b]);
|
||
expect(tail.length, 1);
|
||
expect(stripAddressTail(a, tail), '5/2, North Avenue');
|
||
});
|
||
|
||
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',
|
||
);
|
||
});
|
||
});
|
||
}
|