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

374 lines
13 KiB
Dart

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<String, dynamic> apiRow({
required String id,
required String status,
required String at,
String key = 'pickedtime',
}) => <String, dynamic>{'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 = <String, dynamic>{
'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 = <String, dynamic>{
'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 = <String, dynamic>{
'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 = <String, dynamic>{
'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 = <String, dynamic>{
'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 = <String, dynamic>{'orderid': 'U-2', 'orderstatus': 'skipped'};
final seen = <Map<String, dynamic>>[];
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 = <String, dynamic>{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));
});
});
}