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 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 {}, ), StopState.active, ); 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'}, ), StopState.active, ); }); 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', ); }); }); }