306 lines
12 KiB
Dart
306 lines
12 KiB
Dart
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<String, dynamic> booking({
|
||
required String id,
|
||
String status = 'Miler_Assigned',
|
||
Map<String, dynamic> extra = const {},
|
||
}) => <String, dynamic>{
|
||
'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<String, dynamic> 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<String, dynamic> 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);
|
||
});
|
||
});
|
||
}
|