import 'package:flutter_test/flutter_test.dart'; import 'package:miler/Models/stop_status.dart'; import 'package:miler/data/service_day.dart'; /// ───────────────────────────────────────────────────────────────────────── /// ACTIVITY IS STRICTLY TODAY /// /// The page is a shift log, not an archive, and the rule it rests on is one /// predicate: [ServiceDay.belongsToToday]. These are the cases that broke it. /// /// ── What actually went wrong ── /// /// `ServiceDay.timeKeys` used to read `deliveredat`, `updatedat` and /// `createdat` — three field names the backend does not send. A booking /// finished yesterday comes back from `GET /miler/bookings` carrying /// `pickedtime` and `updatedon`, neither of which was looked at, so /// [ServiceDay.of] returned `''`, the predicate took its "undated rows are /// kept" branch, and yesterday's work was admitted as today's. /// /// Two things changed. The key list is now the one `happenedAt` sorts by — the /// same fact read out of one list instead of two — and an undated row is now /// **excluded and reported** rather than silently kept. The burden of proof /// sits with the record: a row that cannot show it belongs to today does not /// appear on a screen headed Today. /// /// Nothing is deleted anywhere. This is a view filter, and [ServiceDay.of] /// still answers for any caller that legitimately wants history. /// ───────────────────────────────────────────────────────────────────────── void main() { const today = '2026-08-27'; const yesterday = '2026-08-26'; /// A row as the backend sends it: no `completedday`/`skippedday` stamp, /// because it has never been through either local store. This is the shape /// that used to slip through. Map apiRow({ required String id, required String status, required String at, String key = 'pickedtime', }) => {'orderid': id, 'orderstatus': status, key: at}; group('yesterday does not appear on today', () { test('a Delivered booking finished yesterday is excluded', () { final row = apiRow( id: 'D-1', status: 'delivered', // The false trailing `Z` the backend really sends — IST wall-clock in // a naive Postgres column, marshalled with a zone marker it never had. at: '2026-08-26T14:20:00Z', ); expect(stopStatusOf(row).isDelivered, isTrue); expect(ServiceDay.of(row), yesterday); expect(ServiceDay.belongsToToday(row, now: today), isFalse); }); test('a Skipped stop parked yesterday is excluded', () { // The skipped store stamps its own records, so this carries the // authoritative pair — and it still must not show today. final row = { 'orderid': 'S-1', 'orderstatus': 'skipped', 'skippedat': '2026-08-26T19:05:00.000', 'skippedday': yesterday, }; expect(stopStatusOf(row).isSkipped, isTrue); expect(ServiceDay.of(row), yesterday); expect(ServiceDay.belongsToToday(row, now: today), isFalse); }); test('a Cancelled booking from yesterday is excluded', () { final row = apiRow( id: 'C-1', status: 'cancelled', at: '2026-08-26T09:00:00Z', key: 'updatedon', ); expect(stopStatusOf(row).isCancelled, isTrue); expect(ServiceDay.of(row), yesterday); expect(ServiceDay.belongsToToday(row, now: today), isFalse); }); test('the three slices are filtered by one rule, not three', () { // Delivered, Skipped and Cancelled all pass through the same boundary // filter before the page ever slices them, which is what stops the three // tabs from disagreeing about what "today" means. final rows = [ apiRow(id: 'D-1', status: 'delivered', at: '2026-08-26T14:20:00Z'), apiRow(id: 'S-1', status: 'skipped', at: '2026-08-26T19:05:00Z'), apiRow(id: 'C-1', status: 'cancelled', at: '2026-08-26T09:00:00Z'), ]; expect( rows.where((r) => ServiceDay.belongsToToday(r, now: today)), isEmpty, ); }); }); group("today's records are visible", () { test('a booking finished today is admitted', () { final row = apiRow( id: 'D-2', status: 'delivered', at: '2026-08-27T11:42:00Z', ); expect(ServiceDay.of(row), today); expect(ServiceDay.belongsToToday(row, now: today), isTrue); }); test("this session's local completion stamp is proof enough", () { // `accepted_store.dart` writes `completedat` + `completedday` at the // moment the rider acts. That pair is the *only* record of a stop the // backend has not caught up with, and the strict rule must not cost it. final row = { 'orderid': 'D-3', 'orderstatus': 'picked', 'completedat': '2026-08-27T08:15:00.000', 'completedday': today, }; expect(ServiceDay.of(row), today); expect(ServiceDay.belongsToToday(row, now: today), isTrue); }); test("this session's local skip stamp is proof enough", () { final row = { 'orderid': 'S-2', 'orderstatus': 'skipped', 'skippedat': '2026-08-27T10:02:00.000', 'skippedday': today, }; expect(ServiceDay.belongsToToday(row, now: today), isTrue); }); test('a day stamp outranks a stale API timestamp on the same row', () { // The merge in `_load` overlays the local store onto the API row, so both // can be present. `dayKeys` is read first, which is what makes the // rider's own action the answer. final row = { 'orderid': 'D-4', 'orderstatus': 'picked', 'updatedon': '2026-08-26T22:00:00Z', 'completedday': today, }; expect(ServiceDay.belongsToToday(row, now: today), isTrue); }); }); group('the day boundary is IST wall-clock, not UTC', () { // The old `of` did `DateTime.tryParse` then `toLocal()`. On a timestamp // carrying the false `Z`, that shifted an IST evening by +5:30 — so a stop // finished at 23:50 was filed under the *next* day and vanished from // Activity the instant it was completed. Every evening shift lost its last // stop. `parseStamp` strips the marker and reads the digits the backend // meant, which is the day the rider would name. test('23:50 stays on its own day', () { final row = apiRow( id: 'L-1', status: 'delivered', at: '2026-08-26T23:50:00Z', ); expect(ServiceDay.of(row), yesterday); expect(ServiceDay.belongsToToday(row, now: yesterday), isTrue); expect( ServiceDay.belongsToToday(row, now: today), isFalse, reason: 'a 23:50 stop must not leak into the next day', ); }); test('00:10 belongs to the new day', () { final row = apiRow( id: 'L-2', status: 'delivered', at: '2026-08-27T00:10:00Z', ); expect(ServiceDay.of(row), today); expect(ServiceDay.belongsToToday(row, now: today), isTrue); expect(ServiceDay.belongsToToday(row, now: yesterday), isFalse); }); test('a stamp with no zone marker reads the same as one with a false Z', () { final naive = apiRow( id: 'L-3', status: 'delivered', at: '2026-08-26T23:50:00', ); final falseZ = apiRow( id: 'L-4', status: 'delivered', at: '2026-08-26T23:50:00Z', ); expect(ServiceDay.of(naive), ServiceDay.of(falseZ)); }); }); group('undated rows are excluded and reported', () { test('a historical row carrying no usable timestamp is excluded', () { final row = { 'orderid': 'U-1', 'orderstatus': 'delivered', }; expect(ServiceDay.of(row), isEmpty); expect( ServiceDay.belongsToToday(row, now: today), isFalse, reason: 'this branch is the hole yesterday came through', ); }); test('exclusion is announced, not silent', () { final row = {'orderid': 'U-2', 'orderstatus': 'skipped'}; final seen = >[]; ServiceDay.belongsToToday(row, now: today, onUndated: seen.add); expect(seen, [row], reason: 'a data defect worth counting'); }); test('a dated row never reports itself as undated', () { final row = apiRow( id: 'U-3', status: 'delivered', at: '2026-08-27T11:42:00Z', ); var reported = false; ServiceDay.belongsToToday( row, now: today, onUndated: (_) => reported = true, ); expect(reported, isFalse); }); test('the fields the backend really sends are all read', () { // The regression in one line: every one of these was invisible to the // old key list, and each is enough on its own to date a row. for (final key in const [ 'pickedtime', 'picked_time', 'deliverytime', 'updatedon', 'modifiedon', 'expected_pickup_time', ]) { final row = {key: '2026-08-26T14:20:00Z'}; expect( ServiceDay.of(row), yesterday, reason: '$key must date a row', ); } }); }); group('the day turns over under an open screen', () { // `_load` filters against the day as read at fetch time, so the list is // only ever as fresh as the last fetch. [ServiceDayRollover] is what // notices that the wall clock has moved past it. test('midnight rollover reports the old day as ended', () { var now = DateTime(2026, 8, 26, 23, 59); final day = ServiceDayRollover(clock: () => now); expect(day.day, yesterday); expect(day.hasRolled, isFalse); expect(day.rollIfNeeded(), isFalse, reason: 'still the same day'); // The timer fires a second past midnight. now = DateTime(2026, 8, 27, 0, 0, 1); expect(day.hasRolled, isTrue); expect(day.rollIfNeeded(), isTrue); expect(day.day, today); }); test("yesterday's rows do not survive the rollover", () { // What the page does with that answer: it clears state *before* the // refetch, so a new day starts empty even with no signal. The alternative // is a rider in a dead spot at 6am reading yesterday's stops under a // heading that says Today. var now = DateTime(2026, 8, 26, 22, 0); final day = ServiceDayRollover(clock: () => now); final rows = [ apiRow(id: 'D-1', status: 'delivered', at: '2026-08-26T14:20:00Z'), apiRow(id: 'S-1', status: 'skipped', at: '2026-08-26T19:05:00Z'), apiRow(id: 'C-1', status: 'cancelled', at: '2026-08-26T09:00:00Z'), ]; expect( rows.where((r) => ServiceDay.belongsToToday(r, now: day.day)).length, 3, ); now = DateTime(2026, 8, 27, 0, 0, 1); day.rollIfNeeded(); expect( rows.where((r) => ServiceDay.belongsToToday(r, now: day.day)), isEmpty, ); }); test('resume after midnight reports the old day as ended', () { // The timer case is the one a phone is *least* likely to deliver: both // platforms suspend timers in the background. This is the path that // actually runs — the app was backgrounded on Activity at 22:00 and the // rider opens it again at six. var now = DateTime(2026, 8, 26, 22, 0); final day = ServiceDayRollover(clock: () => now); now = DateTime(2026, 8, 27, 6, 0); expect(day.rollIfNeeded(), isTrue); expect(day.day, today); }); test('a resume inside the same day changes nothing', () { var now = DateTime(2026, 8, 27, 9, 0); final day = ServiceDayRollover(clock: () => now); now = DateTime(2026, 8, 27, 17, 30); expect(day.rollIfNeeded(), isFalse); expect(day.day, today); }); test('the check is idempotent, so both triggers are safe', () { // The timer and the resume listener both land in the same place; whichever // arrives second must find the day already current rather than clearing // the list a second time. var now = DateTime(2026, 8, 26, 23, 59); final day = ServiceDayRollover(clock: () => now); now = DateTime(2026, 8, 27, 0, 0, 1); expect(day.rollIfNeeded(), isTrue); expect(day.rollIfNeeded(), isFalse); }); test('the wake lands just past the next local midnight', () { final day = ServiceDayRollover( clock: () => DateTime(2026, 8, 26, 23, 50), ); expect(day.untilNextDay, const Duration(minutes: 10, seconds: 1)); }); test('the wake is correct across a month end', () { // `day + 1` relies on `DateTime` normalising 32 August into 1 September. final day = ServiceDayRollover( clock: () => DateTime(2026, 8, 31, 23, 30), ); expect(day.untilNextDay, const Duration(minutes: 30, seconds: 1)); }); }); }