diet-guard/app/test/widgets/today_progress_card_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

94 lines
2.8 KiB
Dart

import 'package:diet_guard_app/widgets/today_progress_card.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
TodayProgress _progress({
String justLogged = 'toast',
double consumedKcal = 1450,
int budgetKcal = 2200,
double proteinG = 90,
double carbsG = 120,
double fatG = 40,
int adherenceStreak = 3,
}) => TodayProgress(
justLogged: justLogged,
consumedKcal: consumedKcal,
budgetKcal: budgetKcal,
proteinG: proteinG,
carbsG: carbsG,
fatG: fatG,
adherenceStreak: adherenceStreak,
);
Future<void> _pump(WidgetTester tester, TodayProgress progress) =>
tester.pumpWidget(
MaterialApp(
home: Scaffold(body: TodayProgressCard(progress: progress)),
),
);
void main() {
group('TodayProgress', () {
test('remaining is budget minus consumed', () {
expect(_progress().remainingKcal, 750);
expect(_progress().isOverBudget, isFalse);
});
test('remaining goes negative once past the budget', () {
final over = _progress(consumedKcal: 2500);
expect(over.remainingKcal, -300);
expect(over.isOverBudget, isTrue);
});
test('exactly on the budget is not over', () {
final exact = _progress(consumedKcal: 2200);
expect(exact.remainingKcal, 0);
expect(exact.isOverBudget, isFalse);
});
});
group('TodayProgressCard', () {
testWidgets('shows the meal, the kcal position, and macros', (
tester,
) async {
await _pump(tester, _progress());
expect(find.text('Logged "toast".'), findsOneWidget);
expect(find.text('1450 / 2200'), findsOneWidget);
expect(find.text('750 left'), findsOneWidget);
expect(find.text('P 90g · C 120g · F 40g'), findsOneWidget);
});
testWidgets('reports the overshoot in the error color when over', (
tester,
) async {
await _pump(tester, _progress(consumedKcal: 2500));
expect(find.text('300 over'), findsOneWidget);
final total = tester.widget<Text>(find.text('2500 / 2200'));
final context = tester.element(find.text('2500 / 2200'));
expect(total.style?.color, Theme.of(context).colorScheme.error);
});
testWidgets('pluralises the adherence streak', (tester) async {
await _pump(tester, _progress(adherenceStreak: 1));
expect(find.text('Adherence streak: 1 day'), findsOneWidget);
await _pump(tester, _progress(adherenceStreak: 12));
expect(find.text('Adherence streak: 12 days'), findsOneWidget);
});
testWidgets('rounds fractional kcal and macros for display', (
tester,
) async {
await _pump(
tester,
_progress(consumedKcal: 1450.6, proteinG: 90.4, carbsG: 0, fatG: 0),
);
expect(find.text('1451 / 2200'), findsOneWidget);
expect(find.text('P 90g · C 0g · F 0g'), findsOneWidget);
});
});
}