diet-guard/app/test/services/budget_schedule_test.dart
Krzysztof kuhy Rudnicki f7f06aee53
Some checks failed
pre-commit / pre-commit (push) Has been cancelled
Tests / test (3.10) (push) Has been cancelled
Tests / test (3.11) (push) Has been cancelled
Tests / test (3.12) (push) Has been cancelled
Remove unused features; fix reminder false alarms; share all state
Four asks, plus the fixes two review passes turned up.

Removals (all unused):
- "Repeat last meal" button, and the reward/temptation-bundling prompt it
  was the sole trigger for. Replaced with a post-log progress card showing
  kcal against budget, macros, and the adherence streak.
- The multi-item meal builder, on both the gate and the phone. The stored
  `components` field stays: nothing writes it now, but historical composite
  entries and the food bank still read it and it is part of the wire format.
- Meal photos entirely: 13 files, `imagePath`, the photo filter, the
  wrapper's /blobs/ route, image_picker, and the CAMERA permission.

Reminder false alarms had three real causes, all fixed:
- Logging never reconciled notifications, so a reminder sat on screen up to
  15 minutes after the meal was logged -- on the phone itself.
- The background check never pulled, so a PC-logged meal nagged forever
  until the app was opened. Now mirrors _cli_gate._should_lock: cheap local
  check, network only when a slot looks due, then re-check.
- Meals before 08:00 / after 22:00 were attributed to no slot at all. New
  slot_for_log/slotForLog clamps them to the first/last slot, byte-identical
  across Python and Dart. Deliberately separate from elapsed_slots, which
  would otherwise make every slot fall due at 23:00.

Budget history, so lowering the budget stops rewriting the past:
- Forward-only (effective_from, kcal) entries; each day is judged against
  the budget in force then. Verified live: setting 2000 left a 36-day
  adherence streak and the whole calendar pixel-identical.
- Rides as hist:<date> fields on the existing budget record. crdt_sync
  merges per-field over the union of field names and both devices push the
  merged log, so a device that predates the feature relays them untouched --
  no coordinated rollout, no second document.
- The seed guard tests emptiness, not file presence: _sync can write an
  empty history after merging with a pre-feature peer, and a presence check
  would then never seed again, silently reclassifying every past day.

One shared source of truth for every piece of data:
- Body weight now travels as its own `weight` CRDT field. Inside `value` it
  was collateral damage of whole-map LWW -- any phone budget edit deleted it,
  and the protein target with it.
- Both halves of the food bank sync. The curated half (foods added without
  eating them) was the last device-local state. The derived half merges with
  count as the clock, so LWW means max-count-wins; the log stays
  authoritative for membership, because a CRDT union never shrinks and a
  peer's stale copy would otherwise resurrect a deleted food permanently.
- Bank adapters honour tombstones, and Dart parses bank records defensively:
  a cast failure there is an Error, not an Exception, so one malformed
  remote record would have broken phone sync permanently.

_test_guard.py blocks any pathlib write to real user state while pytest is
in the process, whatever conftest is loaded. A throwaway test run outside
the package conftest destroyed a real GitHub token during this work; this
makes that class of accident impossible rather than merely discouraged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 16:39:52 +02:00

169 lines
5.0 KiB
Dart

// Mirror of `diet_guard/tests/test_budget_history.py`'s pure half. Keep the
// two in lockstep: a divergence means the PC and the phone resolve the same
// day to different budgets.
import 'dart:convert';
import 'package:diet_guard_app/services/budget_schedule.dart';
import 'package:flutter_test/flutter_test.dart';
BudgetEntry _entry(
String day,
int kcal, [
String t = '2026-01-01T00:00:00.000Z',
]) => BudgetEntry(effectiveFrom: day, kcal: kcal, editedAt: t);
void main() {
group('forDay', () {
test('empty history falls back to the default', () {
expect(BudgetSchedule.empty.forDay('2026-06-01'), kDefaultDailyKcalGoal);
});
test('day before every entry falls back to the fallback', () {
final schedule = BudgetSchedule([
_entry('2026-07-26', 2000),
], fallback: 2200);
expect(schedule.forDay('2026-06-01'), 2200);
});
test('exact effective-from day uses the new value', () {
final schedule = BudgetSchedule([
_entry('2026-07-26', 2000),
], fallback: 2200);
expect(schedule.forDay('2026-07-26'), 2000);
});
test('day after uses the new value', () {
final schedule = BudgetSchedule([
_entry('2026-07-26', 2000),
], fallback: 2200);
expect(schedule.forDay('2026-07-27'), 2000);
});
test('between entries uses the earlier one', () {
final schedule = BudgetSchedule([
_entry(kEpochDay, 2200),
_entry('2026-07-26', 2000),
], fallback: 1);
expect(schedule.forDay('2026-07-25'), 2200);
expect(schedule.forDay('2026-07-26'), 2000);
});
test('picks the latest of several changes', () {
final schedule = BudgetSchedule([
_entry(kEpochDay, 2400),
_entry('2026-03-01', 2200),
_entry('2026-07-26', 2000),
], fallback: 1);
expect(schedule.forDay('2026-02-28'), 2400);
expect(schedule.forDay('2026-05-05'), 2200);
expect(schedule.forDay('2026-12-31'), 2000);
});
test('current resolves today', () {
final schedule = BudgetSchedule([_entry(kEpochDay, 1900)], fallback: 1);
expect(schedule.current, 1900);
});
});
group('parse', () {
test('round trips entries', () {
final entries = [_entry(kEpochDay, 2200), _entry('2026-07-26', 2000)];
final raw = jsonDecode(jsonEncode(BudgetSchedule.encode(entries)));
expect(BudgetSchedule.parse(raw), entries);
});
test('sorts ascending regardless of stored order', () {
final raw = {
'v': 1,
'e': {
'2026-07-26': {'b': 2000, 't': 'x'},
'1970-01-01': {'b': 2200, 't': 'y'},
},
};
expect(BudgetSchedule.parse(raw).map((e) => e.effectiveFrom).toList(), [
'1970-01-01',
'2026-07-26',
]);
});
test('non-map is empty', () {
expect(BudgetSchedule.parse([1, 2, 3]), isEmpty);
});
test('wrong version is empty', () {
expect(
BudgetSchedule.parse({'v': 99, 'e': <String, dynamic>{}}),
isEmpty,
);
});
test('non-map entries container is empty', () {
expect(BudgetSchedule.parse({'v': 1, 'e': 'nope'}), isEmpty);
});
test('malformed entries are skipped', () {
final raw = {
'v': 1,
'e': {
'2026-07-26': 'not a record',
'2026-07-27': {'b': 'not an int'},
'2026-07-29': {'b': 2000, 't': '2026-07-29T00:00:00.000Z'},
},
};
expect(BudgetSchedule.parse(raw).map((e) => e.effectiveFrom).toList(), [
'2026-07-29',
]);
});
test('missing edit time falls back to the epoch', () {
final entries = BudgetSchedule.parse({
'v': 1,
'e': {
'2026-07-26': {'b': 2000},
},
});
expect(entries.single.editedAt, startsWith('1970-01-01'));
});
});
group('upsert', () {
test('appends a new day', () {
final schedule = BudgetSchedule([
_entry(kEpochDay, 2200),
]).upsert(2000, when: DateTime(2026, 7, 26, 10));
expect(schedule.entries.map((e) => e.effectiveFrom).toList(), [
kEpochDay,
'2026-07-26',
]);
expect(schedule.entries.last.kcal, 2000);
});
test('a second edit the same day replaces rather than appends', () {
var schedule = BudgetSchedule.empty.upsert(
2000,
when: DateTime(2026, 7, 26, 10),
);
schedule = schedule.upsert(1900, when: DateTime(2026, 7, 26, 18, 30));
expect(schedule.entries, hasLength(1));
expect(schedule.entries.single.kcal, 1900);
});
});
group('seed', () {
test('grandfathers the value to the epoch day', () {
final seeded = BudgetSchedule.seed(2200, DateTime.utc(2026, 7, 13, 19));
expect(seeded.single.effectiveFrom, kEpochDay);
expect(seeded.single.kcal, 2200);
expect(seeded.single.editedAt, startsWith('2026-07-13'));
});
test('a null edit time falls back to the epoch', () {
expect(
BudgetSchedule.seed(2200, null).single.editedAt,
startsWith('1970'),
);
});
});
}