mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-11 15:33:57 +01:00
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>
169 lines
5.0 KiB
Dart
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'),
|
|
);
|
|
});
|
|
});
|
|
}
|