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

870 lines
29 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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',
);
});
});
}