import 'package:flutter_test/flutter_test.dart'; import 'package:miler/data/api_config.dart'; import 'package:miler/data/service_day.dart'; import 'package:miler/views/Dashboard/home/trip.dart'; /// ───────────────────────────────────────────────────────────────────────── /// HOME IS TODAY, AND TODAY IS THREE TRIPS /// /// Two bugs with one cause, and this is the file that holds the cause down. /// /// ── What the rider saw ── /// /// 1. **Yesterday's assignments were on Home.** `GET /miler/bookings` returns /// his whole *open* set, not his day, so a booking raised on Tuesday and /// never closed came back on Wednesday and on Thursday — inside the kitchen /// dropdown, and inside every count above it. /// 2. **Every run was Trip 1.** The hub assigns morning, afternoon and evening /// slots and the tabs are those slots, but an evening run opened filed /// under the morning. /// /// ── The one cause ── /// /// `ApiConfig.pickupFromBooking` builds a **fixed map**: a field it does not /// name is a field no screen can ever see. Every timestamp on the booking was /// unnamed, so it was thrown away at the adapter — and both features downstream /// are clocks. [ServiceDay] asked "which day is this" in field names the /// adapted row no longer spoke and got `''`; [Trip] asked "which part of the /// day" and got the same silence, dropped every stop into one undated bucket, /// and that bucket landed in the first free slot. /// /// So the fix is at the adapter, and these tests start there. The two features /// are then checked on the shapes the wire really produces. /// ───────────────────────────────────────────────────────────────────────── void main() { /// A booking as `GET /miler/bookings` returns it — the new shape, which is /// the one that goes through the translation. Map booking({ required String id, String status = 'Miler_Assigned', Map extra = const {}, }) => { 'bookingid': id, 'bookingreference': 'DM-$id', 'status': status, 'customername': 'Joe Mathew', 'pickupaddress': '12 SNS Colony, Peelamedu, Coimbatore', ...extra, }; group('the adapter keeps the clocks', () { test('a booking that carries a timestamp still carries it afterwards', () { final row = ApiConfig.pickupFromBooking( booking( id: '1', extra: const { 'createdat': '2026-08-27T09:14:00', 'updatedon': '2026-08-27T11:02:00', }, ), ); // The whole failure was here: these came back and went nowhere. expect(row['createdat'], '2026-08-27T09:14:00'); expect(row['updatedon'], '2026-08-27T11:02:00'); expect(ServiceDay.of(row), '2026-08-27'); }); test('every name ServiceDay reads survives the translation', () { // A regression net for the shape of the bug rather than one instance of // it: the adapter and the day filter must agree on the vocabulary, or a // key added to one and not the other silently un-dates the row again. for (final key in ServiceDay.timeKeys) { // Written by the local stores, never by the wire. if (key == 'completedat' || key == 'skippedat') continue; final row = ApiConfig.pickupFromBooking( booking(id: 'x', extra: {key: '2026-08-27T10:00:00'}), ); expect( ServiceDay.of(row), '2026-08-27', reason: '$key was dropped at the adapter', ); } }); test('an absent timestamp is not invented', () { final row = ApiConfig.pickupFromBooking(booking(id: '2')); expect(row.containsKey('pickedtime'), isFalse); expect(ServiceDay.of(row), ''); }); }); group("yesterday's assignment is not today's work", () { const today = '2026-08-27'; test('a booking still open from yesterday does not belong to today', () { final row = ApiConfig.pickupFromBooking( booking(id: '3', extra: const {'createdat': '2026-08-26T18:40:00'}), ); expect(ServiceDay.belongsToToday(row, now: today), isFalse); }); test('the same booking, raised this morning, does', () { final row = ApiConfig.pickupFromBooking( booking(id: '4', extra: const {'createdat': '2026-08-27T07:05:00'}), ); expect(ServiceDay.belongsToToday(row, now: today), isTrue); }); test('tomorrow is not today either', () { // A pre-assigned booking is real work and it is not this shift's. It // reappears on its own morning, when the same predicate admits it. final row = ApiConfig.pickupFromBooking( booking(id: '5', extra: const {'createdat': '2026-08-28T06:00:00'}), ); expect(ServiceDay.belongsToToday(row, now: today), isFalse); }); test('the local assignment stamp dates a row the backend does not', () { // The live contract sends no clock the app can trust as an assignment // time, so [WorkRepository] writes when the booking first reached this // rider. It is the weakest key on the list and it is the one that // actually fires on Doormile. final row = ApiConfig.pickupFromBooking(booking(id: '6')) ..['assignedat'] = '2026-08-26T09:00:00'; expect(ServiceDay.of(row), '2026-08-26'); expect(ServiceDay.belongsToToday(row, now: today), isFalse); }); test('a clock the backend sends outranks the local guess', () { // The rider's phone met this booking on the 27th; the hub finished it on // the 26th. The hub is right — the app's first sighting says only when // the app was open. final row = ApiConfig.pickupFromBooking( booking( id: '7', status: 'Picked', extra: const {'pickedtime': '2026-08-26T20:10:00'}, ), )..['assignedat'] = '2026-08-27T08:00:00'; expect(ServiceDay.of(row), '2026-08-26'); }); }); group('the work screens filter one day, by one rule', () { const today = '2026-08-27'; Map dated(String id, String at) => ApiConfig.pickupFromBooking(booking(id: id))..['assignedat'] = at; test("yesterday's leftovers are dropped and today's kept", () { final kept = ServiceDay.onlyToday([ dated('old', '2026-08-26T09:00:00'), dated('new', '2026-08-27T09:00:00'), ], now: today); expect(kept.map((s) => s['orderid']), ['DM-new']); }); test('an undated row among dated ones is dropped', () { // The burden of proof is on the row — the same rule Activity applies. final kept = ServiceDay.onlyToday([ dated('a', '2026-08-27T09:00:00'), ApiConfig.pickupFromBooking(booking(id: 'nodate')), ], now: today); expect(kept.map((s) => s['orderid']), ['DM-a']); }); test('a day where NOTHING can be dated is kept whole', () { // The narrow safety net. Not a stale queue — a build that has lost the // ability to date anything — and blanking a rider's screen would hide // that behind something that reads like good news. final rows = [ ApiConfig.pickupFromBooking(booking(id: 'a')), ApiConfig.pickupFromBooking(booking(id: 'b')), ]; expect(ServiceDay.onlyToday(rows, now: today).length, 2); }); test('an empty day stays empty', () { expect(ServiceDay.onlyToday(const [], now: today), isEmpty); }); test('a picked stop finished today stays — the rail needs it', () { // Deliveries and Home both count finished work into the day's figures. // Dropping it would empty the progress ring every time a rider succeeds. final done = ApiConfig.pickupFromBooking( booking( id: 'p', status: 'Picked', extra: const {'pickedtime': '2026-08-27T11:20:00'}, ), ); expect(ServiceDay.onlyToday([done], now: today).length, 1); }); }); group('the slot a stop lands in is the part of the day', () { /// The adapted shape plus the stamp the repository merges on. Map assigned(String id, DateTime at) => ApiConfig.pickupFromBooking(booking(id: id)) ..['assignedat'] = at.toIso8601String(); test('assigned in the morning is Trip 1', () { final trips = Trip.groupIntoTrips([ assigned('a', DateTime(2026, 8, 27, 8, 30)), ]); expect(trips.single.dayPart, DayPart.morning); expect(trips.single.tripNumber, 1); expect(trips.slots.indexOf(trips.single), 0); }); test('assigned in the afternoon is Trip 2, on a day with no morning', () { // The point of the whole slot model. A rider with only afternoon work // used to see it as Trip 1, so his "Trip 2" and the hub's were different // runs — and dispatch rings him about the hub's. final trips = Trip.groupIntoTrips([ assigned('b', DateTime(2026, 8, 27, 14, 5)), ]); expect(trips.single.tripNumber, 2); final slots = trips.slots; expect(slots[0], isNull); expect(slots[1], isNotNull); }); test('assigned in the evening is Trip 3', () { final trips = Trip.groupIntoTrips([ assigned('c', DateTime(2026, 8, 27, 19, 0)), ]); expect(trips.single.tripNumber, 3); expect(trips.slots[2], isNotNull); }); test('a full day fills all three slots in order', () { final trips = Trip.groupIntoTrips([ assigned('m', DateTime(2026, 8, 27, 9, 0)), assigned('a', DateTime(2026, 8, 27, 13, 0)), assigned('e', DateTime(2026, 8, 27, 18, 0)), ]); expect(trips.length, 3); expect([for (final t in trips) t.tripNumber], [1, 2, 3]); // `orderid` is the booking *reference* after translation — the id the // rider reads, not the row's primary key. expect(trips.slots.map((t) => t?.stops.single['orderid']), [ 'DM-m', 'DM-a', 'DM-e', ]); }); test('two runs in one part stay one trip', () { // A day part is not a rolling window: two stops handed over an hour apart // in the same morning are the same run, and which trip a stop is on must // never depend on what else is in the day. final trips = Trip.groupIntoTrips([ assigned('a', DateTime(2026, 8, 27, 7, 30)), assigned('b', DateTime(2026, 8, 27, 11, 45)), ]); expect(trips.length, 1); expect(trips.single.stops.length, 2); expect(trips.single.tripNumber, 1); }); test('a real due time still outranks the assignment clock', () { // Handed over at eight in the morning, due at three in the afternoon. The // work is the afternoon's; the handover time was only ever a stand-in for // a field the backend does not send. final stop = assigned('d', DateTime(2026, 8, 27, 8, 0)); stop['expected_pickup_time'] = DateTime( 2026, 8, 27, 15, 0, ).toIso8601String(); expect(Trip.groupIntoTrips([stop]).single.tripNumber, 2); }); test('an explicit backend slot window still wins outright', () { // If the console ever grows real slots, they are the answer and none of // the inference above runs. final stop = assigned('s', DateTime(2026, 8, 27, 19, 30)) ..['slotstarttime'] = DateTime(2026, 8, 27, 10).toIso8601String() ..['slotendtime'] = DateTime(2026, 8, 27, 13).toIso8601String(); final trip = Trip.groupIntoTrips([stop]).single; expect(trip.slotLabel, '10:00 AM – 1:00 PM'); expect(trip.tripNumber, 1); }); test('a stop with no clock at all keeps its arrival order', () { // Unchanged behaviour, stated so it stays deliberate: a trip that cannot // be placed by day part must not be guessed at from the wall clock, or it // would move between tabs as the afternoon wore on. final trips = Trip.groupIntoTrips([ ApiConfig.pickupFromBooking(booking(id: 'z')), ]); expect(trips.single.dayPart, isNull); expect(trips.slots[0], isNotNull); }); }); }