374 lines
13 KiB
Dart
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));
|
|
});
|
|
});
|
|
}
|