mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-11 16:53:57 +01:00
A Duration is an absolute span, so stepping a local DateTime by
Duration(days: 1) moves 23 or 25 hours across a DST boundary and lands on
the wrong wall-clock day. Under Europe/Warsaw, spring-forward 2026-03-29:
lastCompleteDay(2026-03-30) -> 2026-03-28 23:00, the day before
yesterday, not yesterday
weeksAgo: 1 from 2026-04-01 -> 2026-03-24 23:00, a Tuesday, so the
wrong ISO week entirely
a 03-27..03-31 period -> emits four day keys, silently dropping
2026-03-31
The Python half computes all three correctly, because timedelta on a date
has no clock to drift against. So for about two weeks a year the phone and
the PC would report different averages for the same log -- exactly the kind
of device-local divergence the shared-source-of-truth rule exists to stop.
Passing out-of-range components to DateTime's constructor normalizes on the
calendar (day 0 is the previous month's last day), which is what timedelta
already does. The same fix goes to day_status_service's streak walker: it
had the identical pattern and predates this feature, so a streak that
spanned the clock change could break when it should have held.
None of the existing tests could see any of this -- every fixture date sits
in a DST-free span -- so the regression tests use dates that straddle
2026-03-29 and assert on the calendar day rather than the DateTime, which
keeps them meaningful whatever zone the runner is in. Verified they fail
against the old arithmetic before fixing it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DjJqpcEsaV5WRhPXyxCThd
300 lines
9.5 KiB
Dart
300 lines
9.5 KiB
Dart
import 'package:diet_guard_app/models/food_entry.dart';
|
|
import 'package:diet_guard_app/models/period_average.dart';
|
|
import 'package:diet_guard_app/services/average_service.dart';
|
|
import 'package:diet_guard_app/services/budget_schedule.dart';
|
|
import 'package:diet_guard_app/services/log_storage_service.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
|
|
FoodEntry buildEntry({required double kcal, bool deleted = false}) => FoodEntry(
|
|
time: '2026-01-01T12:00:00',
|
|
desc: 'test',
|
|
grams: 100,
|
|
kcal: kcal,
|
|
proteinG: 0,
|
|
carbsG: 0,
|
|
fatG: 0,
|
|
source: 'manual',
|
|
deleted: deleted,
|
|
);
|
|
|
|
BudgetSchedule flat(int budget) => BudgetSchedule(
|
|
[BudgetEntry(effectiveFrom: kEpochDay, kcal: budget, editedAt: '')],
|
|
fallback: budget,
|
|
);
|
|
|
|
DayLog logOf(Map<String, double> kcalByDay) => {
|
|
for (final e in kcalByDay.entries) e.key: [buildEntry(kcal: e.value)],
|
|
};
|
|
|
|
void main() {
|
|
group('bandFor', () {
|
|
test('exactly at budget is under', () {
|
|
expect(bandFor(2000, 2000), AverageBand.under);
|
|
});
|
|
test('one over is slightly over', () {
|
|
expect(bandFor(2001, 2000), AverageBand.slightlyOver);
|
|
});
|
|
test('exactly at the 120% ceiling is slightly over', () {
|
|
expect(bandFor(2400, 2000), AverageBand.slightlyOver);
|
|
});
|
|
test('just past the ceiling is very over', () {
|
|
expect(bandFor(2400.01, 2000), AverageBand.veryOver);
|
|
});
|
|
});
|
|
|
|
group('bandLabel', () {
|
|
test('mirrors the Python labels exactly', () {
|
|
expect(bandLabel(null), 'no data');
|
|
expect(bandLabel(AverageBand.under), 'under');
|
|
expect(bandLabel(AverageBand.slightlyOver), 'slightly over');
|
|
expect(bandLabel(AverageBand.veryOver), 'very over');
|
|
});
|
|
});
|
|
|
|
group('bounds', () {
|
|
test('week starts on Monday', () {
|
|
// 2026-01-07 is a Wednesday.
|
|
final (start, end) = weekBounds(DateTime(2026, 1, 7));
|
|
expect(start, DateTime(2026, 1, 5));
|
|
expect(end, DateTime(2026, 1, 11));
|
|
});
|
|
test('a Sunday looks back to its own Monday', () {
|
|
final (start, _) = weekBounds(DateTime(2026, 1, 11));
|
|
expect(start, DateTime(2026, 1, 5));
|
|
});
|
|
test('month bounds cover a short February', () {
|
|
final (start, end) = monthBounds(DateTime(2026, 2, 14));
|
|
expect(start, DateTime(2026, 2));
|
|
expect(end, DateTime(2026, 2, 28));
|
|
});
|
|
test('month bounds cover a leap February', () {
|
|
final (_, end) = monthBounds(DateTime(2024, 2, 14));
|
|
expect(end, DateTime(2024, 2, 29));
|
|
});
|
|
test('lastCompleteDay is yesterday', () {
|
|
expect(lastCompleteDay(DateTime(2026, 3)), DateTime(2026, 2, 28));
|
|
});
|
|
test('shiftMonths wraps the year', () {
|
|
expect(shiftMonths(DateTime(2026, 1, 20), 1), DateTime(2025, 12));
|
|
});
|
|
});
|
|
|
|
group('periodAverage', () {
|
|
test('averages over logged days only, not elapsed days', () {
|
|
final log = logOf({
|
|
'2026-01-05': 2000,
|
|
'2026-01-06': 3000,
|
|
'2026-01-09': 4000,
|
|
});
|
|
final result = periodAverage(
|
|
log,
|
|
schedule: flat(2500),
|
|
start: DateTime(2026, 1, 5),
|
|
end: DateTime(2026, 1, 9),
|
|
);
|
|
expect(result.avgKcal, 3000);
|
|
expect(result.loggedDays, 3);
|
|
expect(result.elapsedDays, 5);
|
|
});
|
|
|
|
test('tombstoned entries do not make a day count as logged', () {
|
|
final log = <String, List<FoodEntry>>{
|
|
'2026-01-05': [buildEntry(kcal: 9000, deleted: true)],
|
|
};
|
|
final result = periodAverage(
|
|
log,
|
|
schedule: flat(2000),
|
|
start: DateTime(2026, 1, 5),
|
|
end: DateTime(2026, 1, 5),
|
|
);
|
|
expect(result.loggedDays, 0);
|
|
expect(result.avgKcal, isNull);
|
|
});
|
|
|
|
test('an empty period has a null average, not zero', () {
|
|
final result = periodAverage(
|
|
const {},
|
|
schedule: flat(2000),
|
|
start: DateTime(2026, 1, 5),
|
|
end: DateTime(2026, 1, 11),
|
|
);
|
|
expect(result.avgKcal, isNull);
|
|
expect(result.band, isNull);
|
|
expect(result.elapsedDays, 7);
|
|
});
|
|
|
|
test('end before start is zero elapsed, not negative', () {
|
|
final result = periodAverage(
|
|
const {},
|
|
schedule: flat(2000),
|
|
start: DateTime(2026, 1, 5),
|
|
end: DateTime(2026, 1, 4),
|
|
);
|
|
expect(result.elapsedDays, 0);
|
|
});
|
|
|
|
test('the budget is averaged per logged day, not taken from today', () {
|
|
final schedule = BudgetSchedule([
|
|
BudgetEntry(effectiveFrom: kEpochDay, kcal: 1000, editedAt: ''),
|
|
BudgetEntry(effectiveFrom: '2026-01-06', kcal: 3000, editedAt: ''),
|
|
], fallback: 1000);
|
|
final log = logOf({'2026-01-05': 2000, '2026-01-06': 2000});
|
|
final result = periodAverage(
|
|
log,
|
|
schedule: schedule,
|
|
start: DateTime(2026, 1, 5),
|
|
end: DateTime(2026, 1, 6),
|
|
);
|
|
expect(result.avgBudget, 2000);
|
|
expect(result.band, AverageBand.under);
|
|
});
|
|
});
|
|
|
|
group('weeklyAverage', () {
|
|
test('the current week stops at yesterday', () {
|
|
final log = logOf({
|
|
'2026-01-05': 3000,
|
|
'2026-01-06': 3000,
|
|
'2026-01-07': 3000,
|
|
'2026-01-08': 100,
|
|
});
|
|
final result = weeklyAverage(
|
|
log,
|
|
schedule: flat(2000),
|
|
today: DateTime(2026, 1, 8),
|
|
);
|
|
expect(result.avgKcal, 3000);
|
|
expect(result.end, '2026-01-07');
|
|
expect(result.band, AverageBand.veryOver);
|
|
});
|
|
|
|
test('Monday has no complete days this week', () {
|
|
final result = weeklyAverage(
|
|
logOf({'2026-01-05': 3000}),
|
|
schedule: flat(2000),
|
|
today: DateTime(2026, 1, 5),
|
|
);
|
|
expect(result.elapsedDays, 0);
|
|
expect(result.avgKcal, isNull);
|
|
});
|
|
|
|
test('the previous week is whole', () {
|
|
final result = weeklyAverage(
|
|
logOf({'2026-01-05': 2100, '2026-01-11': 2100}),
|
|
schedule: flat(2000),
|
|
weeksAgo: 1,
|
|
today: DateTime(2026, 1, 14),
|
|
);
|
|
expect(result.start, '2026-01-05');
|
|
expect(result.end, '2026-01-11');
|
|
expect(result.elapsedDays, 7);
|
|
expect(result.band, AverageBand.slightlyOver);
|
|
});
|
|
});
|
|
|
|
group('monthlyAverage', () {
|
|
test('the current month stops at yesterday', () {
|
|
final log = logOf({
|
|
'2026-03-01': 1000,
|
|
'2026-03-02': 1000,
|
|
'2026-03-04': 9000,
|
|
});
|
|
final result = monthlyAverage(
|
|
log,
|
|
schedule: flat(2000),
|
|
today: DateTime(2026, 3, 4),
|
|
);
|
|
expect(result.avgKcal, 1000);
|
|
expect(result.end, '2026-03-03');
|
|
expect(result.band, AverageBand.under);
|
|
});
|
|
|
|
test('the first of the month has no complete days', () {
|
|
final result = monthlyAverage(
|
|
const {},
|
|
schedule: flat(2000),
|
|
today: DateTime(2026, 3),
|
|
);
|
|
expect(result.elapsedDays, 0);
|
|
});
|
|
|
|
test('the previous month is whole', () {
|
|
final result = monthlyAverage(
|
|
logOf({'2026-02-10': 2500}),
|
|
schedule: flat(2000),
|
|
monthsAgo: 1,
|
|
today: DateTime(2026, 3, 10),
|
|
);
|
|
expect(result.start, '2026-02-01');
|
|
expect(result.end, '2026-02-28');
|
|
expect(result.elapsedDays, 28);
|
|
expect(result.band, AverageBand.veryOver);
|
|
});
|
|
});
|
|
|
|
// Every other fixture in this file sits in a DST-free span, so none of them
|
|
// can see the bug these cover: stepping a local DateTime by
|
|
// Duration(days: 1) moves 23 or 25 hours across a DST boundary and lands on
|
|
// the wrong wall-clock day. Under TZ=Europe/Warsaw (spring-forward
|
|
// 2026-03-29) that made the phone and the PC report different averages for
|
|
// the same log. Assertions are on the calendar day, never on the DateTime,
|
|
// so they mean the same thing whatever zone the runner is in.
|
|
group('date arithmetic across a DST boundary', () {
|
|
test('lastCompleteDay is yesterday even across spring-forward', () {
|
|
final result = lastCompleteDay(DateTime(2026, 3, 30));
|
|
expect(
|
|
(result.year, result.month, result.day),
|
|
(2026, 3, 29),
|
|
reason: 'Duration-based arithmetic returned 2026-03-28 23:00 here',
|
|
);
|
|
});
|
|
|
|
test('a period spanning spring-forward keeps every day', () {
|
|
final result = periodAverage(
|
|
logOf({
|
|
'2026-03-27': 2000,
|
|
'2026-03-28': 2000,
|
|
'2026-03-29': 2000,
|
|
'2026-03-30': 2000,
|
|
'2026-03-31': 2000,
|
|
}),
|
|
schedule: flat(2000),
|
|
start: DateTime(2026, 3, 27),
|
|
end: DateTime(2026, 3, 31),
|
|
);
|
|
expect(result.elapsedDays, 5);
|
|
expect(
|
|
result.loggedDays,
|
|
5,
|
|
reason: 'the day-stepping loop used to drop 2026-03-31',
|
|
);
|
|
});
|
|
|
|
test('the weeksAgo anchor stays in the right ISO week', () {
|
|
// 2026-04-01 is a Wednesday; one week back is the Mon 03-23..Sun 03-29
|
|
// week, the one containing the clock change.
|
|
final result = weeklyAverage(
|
|
logOf({'2026-03-23': 2000, '2026-03-29': 2000}),
|
|
schedule: flat(2000),
|
|
weeksAgo: 1,
|
|
today: DateTime(2026, 4),
|
|
);
|
|
expect(result.start, '2026-03-23');
|
|
expect(result.end, '2026-03-29');
|
|
expect(result.loggedDays, 2);
|
|
});
|
|
|
|
test('weekBounds spans Monday to Sunday across the change', () {
|
|
final (monday, sunday) = weekBounds(DateTime(2026, 3, 29));
|
|
expect((monday.year, monday.month, monday.day), (2026, 3, 23));
|
|
expect((sunday.year, sunday.month, sunday.day), (2026, 3, 29));
|
|
});
|
|
|
|
test('monthBounds ends on the real last day of a DST month', () {
|
|
final (first, last) = monthBounds(DateTime(2026, 3, 15));
|
|
expect((first.year, first.month, first.day), (2026, 3, 1));
|
|
expect((last.year, last.month, last.day), (2026, 3, 31));
|
|
});
|
|
});
|
|
}
|