mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-10 19:27:14 +01:00
Add budget-adherence calendar, streaks, and freely-editable synced budget
Both platforms gain a calendar tab/screen (green/yellow/red/not-logged day coloring, logging/adherence streaks, year-to-date tally), and tapping a day drills into that day's logged entries. This requires the daily budget to become a normal, freely-editable value synced between devices like the food log — replaces the previous chattr +i seal (deliberately, per user decision) with plain last-edit-wins sync via a new budget.json alongside food_log.json. The budget field defaults to 2200 kcal, starts read-only, and unlocks via an Edit/Save toggle on both the PC gate's History tab and the phone app's Calendar screen. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016dHac1iYQKTMZEtL7xDJvA
This commit is contained in:
parent
09cf5b726a
commit
957769ff0e
29
CLAUDE.md
29
CLAUDE.md
@ -7,8 +7,14 @@ A log-to-unlock gate: every ~30 minutes, `diet-guard-gate.timer` runs
|
||||
16:00, 20:00) has elapsed without a logged meal. If so, it opens a fullscreen
|
||||
Tk window that blocks the desktop until the user logs what they ate (with
|
||||
autocomplete from a local food-name "bank", optionally seeded from Open Food
|
||||
Facts via `requests`). It also tracks a daily calorie/macro budget, sealed at
|
||||
init time and tamper-resistant via `chattr +i`.
|
||||
Facts via `requests`). It also tracks a daily calorie/macro budget, seeded
|
||||
from biometrics at `init` time but freely editable afterward on either
|
||||
device (PC gate or phone app), synced last-edit-wins like the food log (see
|
||||
"Cross-device sync"). A second tab in the gate window ("History", built by
|
||||
`_gatelock_calendar.py`) and a matching screen in the phone app show a
|
||||
budget-adherence calendar, logging/adherence streaks, and a year-to-date
|
||||
tally, both derived from the same day-status classification
|
||||
(`_daystatus.py` / `day_status_service.dart`).
|
||||
|
||||
See `docs/design.md` for the original feature spec (meal-slot timing logic,
|
||||
the Tue/Wed/Thu "filled most of the day" catch-up rule, multi-item meals).
|
||||
@ -88,11 +94,15 @@ silently does **not** reach the running service.
|
||||
|
||||
## Operational gotchas
|
||||
|
||||
- **The budget file is sealed immutable.** `~/.local/share/diet_guard/.budget`
|
||||
gets `chattr +i` after `init` (see `install.sh` step 6). This is the actual
|
||||
tamper-resistance mechanism — the budget can't be casually edited to "make
|
||||
room" once locked. To intentionally change it: `sudo chattr -i` the file,
|
||||
re-run `python -m diet_guard init`, then re-lock.
|
||||
- **The budget is a plain, freely-editable synced file, by design.**
|
||||
`~/.local/share/diet_guard/.budget` (`diet_guard/_budget.py`) has no seal
|
||||
or signing — this replaces a previous `chattr +i` anti-impulse-editing
|
||||
mechanism, removed deliberately so the same value can be edited from
|
||||
either device. Edit it from the gate's History tab, the phone app's
|
||||
Calendar screen, or `settings_screen.dart`; every write stamps an edit
|
||||
timestamp so `diet-guard-sync.timer`'s `budget.json` sync (mirroring the
|
||||
food-log sync) resolves concurrent edits last-writer-wins. Do not
|
||||
reintroduce the seal as a "fix" — the removal is the feature.
|
||||
- **Biometrics are used once and discarded.** `init` asks for biometrics to
|
||||
compute the daily budget, then the only persisted output is the computed
|
||||
budget number — never the biometrics themselves.
|
||||
@ -118,8 +128,9 @@ silently does **not** reach the running service.
|
||||
not accidental complexity.
|
||||
- Don't add a dependency without doing the production install-path check
|
||||
above.
|
||||
- Don't remove the `chattr +i` step from `install.sh` — it's the actual
|
||||
enforcement mechanism, not a formality.
|
||||
- Don't reintroduce a seal/lock/`chattr +i` on the budget file as a "fix" —
|
||||
it was deliberately removed so the budget is freely editable and synced
|
||||
between devices; see "Operational gotchas" above.
|
||||
|
||||
## Flutter/Dart AI rules
|
||||
|
||||
|
||||
57
app/lib/models/day_status.dart
Normal file
57
app/lib/models/day_status.dart
Normal file
@ -0,0 +1,57 @@
|
||||
/// Per-day budget-adherence status and year-to-date tally value types.
|
||||
library;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Qualitative per-day budget-adherence state, worst to best.
|
||||
///
|
||||
/// Kept in sync with `diet_guard/_daystatus.py`'s `DayStatus`: not-logged is
|
||||
/// considered worse than red, since an unlogged day carries no information
|
||||
/// about whether it went well or badly.
|
||||
enum DayStatus {
|
||||
/// No valid, non-tombstoned entries were logged for the day.
|
||||
notLogged,
|
||||
|
||||
/// Logged, and more than 20% over budget.
|
||||
red,
|
||||
|
||||
/// Logged, and up to 20% over budget.
|
||||
yellow,
|
||||
|
||||
/// Logged, and at or under budget.
|
||||
green,
|
||||
}
|
||||
|
||||
/// Year-to-date logging/adherence counts.
|
||||
@immutable
|
||||
class YtdTally {
|
||||
/// Creates a tally from its three component counts.
|
||||
const YtdTally({
|
||||
required this.loggedDays,
|
||||
required this.elapsedDays,
|
||||
required this.adherentDays,
|
||||
});
|
||||
|
||||
/// Days this year with at least one valid log entry.
|
||||
final int loggedDays;
|
||||
|
||||
/// Days from Jan 1 through the reference date, inclusive.
|
||||
final int elapsedDays;
|
||||
|
||||
/// Of [loggedDays], how many were green or yellow.
|
||||
final int adherentDays;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is YtdTally &&
|
||||
other.loggedDays == loggedDays &&
|
||||
other.elapsedDays == elapsedDays &&
|
||||
other.adherentDays == adherentDays;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(loggedDays, elapsedDays, adherentDays);
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'YtdTally(logged: $loggedDays/$elapsedDays, adherent: $adherentDays)';
|
||||
}
|
||||
223
app/lib/screens/calendar_screen.dart
Normal file
223
app/lib/screens/calendar_screen.dart
Normal file
@ -0,0 +1,223 @@
|
||||
/// Budget-adherence calendar, streaks, year-to-date tally, budget editing.
|
||||
///
|
||||
/// Mirrors the diet_guard gate's calendar tab (`_gatelock_calendar.py`): the
|
||||
/// budget field starts read-only, defaulting to
|
||||
/// [AppSettingsService.dailyKcalGoal]'s built-in 2200 kcal fallback when
|
||||
/// nothing has ever been saved, and an "Edit"/"Save" toggle button unlocks
|
||||
/// it for editing on the first click and validates+persists on the second.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:diet_guard_app/models/day_status.dart';
|
||||
import 'package:diet_guard_app/screens/history_screen.dart';
|
||||
import 'package:diet_guard_app/services/app_settings_service.dart';
|
||||
import 'package:diet_guard_app/services/day_status_service.dart';
|
||||
import 'package:diet_guard_app/services/log_storage_service.dart';
|
||||
import 'package:diet_guard_app/widgets/day_status_calendar.dart';
|
||||
import 'package:diet_guard_app/widgets/streak_summary_row.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// Screen showing the budget-adherence calendar, streaks, and budget entry.
|
||||
class CalendarScreen extends StatefulWidget {
|
||||
/// Creates a [CalendarScreen].
|
||||
const CalendarScreen({super.key});
|
||||
|
||||
@override
|
||||
State<CalendarScreen> createState() => _CalendarScreenState();
|
||||
}
|
||||
|
||||
class _CalendarScreenState extends State<CalendarScreen> {
|
||||
final _budgetController = TextEditingController();
|
||||
DateTime _month = DateTime.now();
|
||||
Map<String, DayStatus> _statusByDate = {};
|
||||
int _loggingStreak = 0;
|
||||
int _adherenceStreak = 0;
|
||||
YtdTally _tally = const YtdTally(
|
||||
loggedDays: 0,
|
||||
elapsedDays: 0,
|
||||
adherentDays: 0,
|
||||
);
|
||||
bool _loading = true;
|
||||
bool _editingBudget = false;
|
||||
String? _budgetStatus;
|
||||
bool _budgetError = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
unawaited(_load());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_budgetController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Loads the log, recomputes status/streaks/tally against the current
|
||||
/// budget, and refreshes the (read-only) budget field -- unless the user
|
||||
/// is mid-edit, in which case their unsaved text is left untouched.
|
||||
Future<void> _load() async {
|
||||
final log = await LogStorageService.instance.readLog();
|
||||
if (!mounted) return;
|
||||
final budget = AppSettingsService.dailyKcalGoal;
|
||||
final statusByDate = statusMap(log, budget: budget);
|
||||
setState(() {
|
||||
_statusByDate = statusByDate;
|
||||
_loggingStreak = loggingStreak(statusByDate);
|
||||
_adherenceStreak = adherenceStreak(statusByDate);
|
||||
_tally = yearToDateTally(statusByDate);
|
||||
_loading = false;
|
||||
if (!_editingBudget) {
|
||||
_budgetController.text = budget.toString();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _onPrevMonth() {
|
||||
setState(() => _month = DateTime(_month.year, _month.month - 1));
|
||||
}
|
||||
|
||||
void _onNextMonth() {
|
||||
setState(() => _month = DateTime(_month.year, _month.month + 1));
|
||||
}
|
||||
|
||||
/// Opens [HistoryScreen] pre-filtered to the tapped day.
|
||||
void _onDaySelected(DateTime day) {
|
||||
unawaited(
|
||||
Navigator.of(context).push<void>(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => HistoryScreen(
|
||||
initialDateRange: DateTimeRange(start: day, end: day),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// First click: unlocks the entry for typing, relabels the button "Save".
|
||||
/// Second click: validates and persists; a validation failure leaves
|
||||
/// editing open so the user can correct the value instead of silently
|
||||
/// discarding it.
|
||||
Future<void> _onEditOrSaveBudget() async {
|
||||
if (!_editingBudget) {
|
||||
setState(() {
|
||||
_editingBudget = true;
|
||||
_budgetStatus = null;
|
||||
_budgetError = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
final value = int.tryParse(_budgetController.text.trim());
|
||||
if (value == null) {
|
||||
setState(() {
|
||||
_budgetStatus = 'Enter a whole number of kcal.';
|
||||
_budgetError = true;
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (value <= 0) {
|
||||
setState(() {
|
||||
_budgetStatus = 'Budget must be a positive number.';
|
||||
_budgetError = true;
|
||||
});
|
||||
return;
|
||||
}
|
||||
await AppSettingsService.instance.saveDailyKcalGoal(value);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_editingBudget = false;
|
||||
_budgetStatus = 'Saved.';
|
||||
_budgetError = false;
|
||||
});
|
||||
await _load();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_loading) {
|
||||
return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
||||
}
|
||||
final theme = Theme.of(context);
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Calendar')),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
children: [
|
||||
_BudgetRow(
|
||||
controller: _budgetController,
|
||||
editing: _editingBudget,
|
||||
onEditOrSave: _onEditOrSaveBudget,
|
||||
),
|
||||
if (_budgetStatus != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text(
|
||||
_budgetStatus!,
|
||||
style: TextStyle(
|
||||
color: _budgetError ? theme.colorScheme.error : null,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
DayStatusCalendar(
|
||||
statusByDate: _statusByDate,
|
||||
month: _month,
|
||||
today: DateTime.now(),
|
||||
onPrevMonth: _onPrevMonth,
|
||||
onNextMonth: _onNextMonth,
|
||||
onDaySelected: _onDaySelected,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
StreakSummaryRow(
|
||||
loggingStreak: _loggingStreak,
|
||||
adherenceStreak: _adherenceStreak,
|
||||
tally: _tally,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BudgetRow extends StatelessWidget {
|
||||
const _BudgetRow({
|
||||
required this.controller,
|
||||
required this.editing,
|
||||
required this.onEditOrSave,
|
||||
});
|
||||
|
||||
final TextEditingController controller;
|
||||
final bool editing;
|
||||
final VoidCallback onEditOrSave;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Text('Daily budget (kcal):'),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(
|
||||
width: 90,
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
enabled: editing,
|
||||
textAlign: TextAlign.center,
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilledButton(
|
||||
onPressed: onEditOrSave,
|
||||
child: Text(editing ? 'Save' : 'Edit'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -8,6 +8,7 @@ import 'package:diet_guard_app/models/food_entry.dart';
|
||||
import 'package:diet_guard_app/screens/edit_entry_screen.dart';
|
||||
import 'package:diet_guard_app/screens/photo_viewer_screen.dart';
|
||||
import 'package:diet_guard_app/services/app_settings_service.dart';
|
||||
import 'package:diet_guard_app/services/day_status_service.dart';
|
||||
import 'package:diet_guard_app/services/log_storage_service.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
@ -230,7 +231,7 @@ List<_HistoryItem> _buildGroupedItems(List<FoodEntry> entries) {
|
||||
final items = <_HistoryItem>[];
|
||||
for (final day in days) {
|
||||
final dayEntries = byDay[day]!;
|
||||
final totalKcal = dayEntries.fold<double>(0, (s, e) => s + e.kcal);
|
||||
final totalKcal = sumKcal(dayEntries);
|
||||
final totalProtein = dayEntries.fold<double>(0, (s, e) => s + e.proteinG);
|
||||
final totalCarbs = dayEntries.fold<double>(0, (s, e) => s + e.carbsG);
|
||||
final totalFat = dayEntries.fold<double>(0, (s, e) => s + e.fatG);
|
||||
@ -286,7 +287,14 @@ String _formatDay(String dateKey) {
|
||||
/// filtering and sorting.
|
||||
class HistoryScreen extends StatefulWidget {
|
||||
/// Creates a [HistoryScreen].
|
||||
const HistoryScreen({super.key});
|
||||
///
|
||||
/// [initialDateRange], when given, pre-applies a date-range filter (e.g.
|
||||
/// the Calendar screen navigating here after a day is tapped, filtering
|
||||
/// to just that one day).
|
||||
const HistoryScreen({this.initialDateRange, super.key});
|
||||
|
||||
/// A date-range filter applied on first load; null shows everything.
|
||||
final DateTimeRange? initialDateRange;
|
||||
|
||||
@override
|
||||
State<HistoryScreen> createState() => _HistoryScreenState();
|
||||
@ -295,13 +303,14 @@ class HistoryScreen extends StatefulWidget {
|
||||
class _HistoryScreenState extends State<HistoryScreen> {
|
||||
List<FoodEntry>? _allEntries;
|
||||
List<FoodEntry> _displayed = const [];
|
||||
HistoryFilter _filter = HistoryFilter();
|
||||
late HistoryFilter _filter;
|
||||
HistorySortField _sortField = HistorySortField.date;
|
||||
bool _sortAscending = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_filter = HistoryFilter(dateRange: widget.initialDateRange);
|
||||
unawaited(_load());
|
||||
}
|
||||
|
||||
|
||||
@ -9,6 +9,7 @@ import 'package:crdt_sync/crdt_sync.dart';
|
||||
import 'package:diet_guard_app/models/food_suggestion.dart';
|
||||
import 'package:diet_guard_app/models/nutrition.dart';
|
||||
import 'package:diet_guard_app/models/slot.dart';
|
||||
import 'package:diet_guard_app/screens/calendar_screen.dart';
|
||||
import 'package:diet_guard_app/screens/food_bank_screen.dart';
|
||||
import 'package:diet_guard_app/screens/history_screen.dart';
|
||||
import 'package:diet_guard_app/screens/meal_builder_screen.dart';
|
||||
@ -256,6 +257,14 @@ class _LogMealScreenState extends State<LogMealScreen>
|
||||
);
|
||||
}
|
||||
|
||||
void _onOpenCalendar() {
|
||||
unawaited(
|
||||
Navigator.of(context).push<void>(
|
||||
MaterialPageRoute(builder: (_) => const CalendarScreen()),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onOpenFoodBank() {
|
||||
unawaited(
|
||||
Navigator.of(context).push<void>(
|
||||
@ -290,6 +299,11 @@ class _LogMealScreenState extends State<LogMealScreen>
|
||||
tooltip: 'History',
|
||||
onPressed: _onOpenHistory,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.calendar_month),
|
||||
tooltip: 'Calendar',
|
||||
onPressed: _onOpenCalendar,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.settings),
|
||||
tooltip: 'Sync settings',
|
||||
|
||||
@ -12,7 +12,11 @@ import 'package:path_provider/path_provider.dart';
|
||||
///
|
||||
/// The static [dailyKcalGoal] getter returns the default (2200) when the
|
||||
/// singleton has not been initialised — safe to read in widget tests that
|
||||
/// never call [init].
|
||||
/// never call [init]. [dailyKcalGoalUpdatedAt] is null until the goal has
|
||||
/// been explicitly set (by the user or a sync merge) at least once on this
|
||||
/// device -- that null is what tells the sync layer this device has
|
||||
/// nothing of its own to contribute yet, rather than silently syncing the
|
||||
/// unset default.
|
||||
class AppSettingsService {
|
||||
AppSettingsService._(this._file);
|
||||
|
||||
@ -23,10 +27,15 @@ class AppSettingsService {
|
||||
|
||||
final File _file;
|
||||
int _dailyKcalGoal = 2200;
|
||||
DateTime? _dailyKcalGoalUpdatedAt;
|
||||
|
||||
/// Returns the configured daily kcal goal, or 2200 when uninitialised.
|
||||
static int get dailyKcalGoal => _instance?._dailyKcalGoal ?? 2200;
|
||||
|
||||
/// Returns when the goal was last set on this device, or null if never.
|
||||
static DateTime? get dailyKcalGoalUpdatedAt =>
|
||||
_instance?._dailyKcalGoalUpdatedAt;
|
||||
|
||||
/// Initialises the singleton, pointing at the app's documents directory.
|
||||
static Future<AppSettingsService> init() async {
|
||||
if (_instance != null) return _instance!;
|
||||
@ -73,15 +82,40 @@ class AppSettingsService {
|
||||
if (data is Map && data['daily_kcal_goal'] is int) {
|
||||
_dailyKcalGoal = data['daily_kcal_goal'] as int;
|
||||
}
|
||||
if (data is Map && data['daily_kcal_goal_updated_at'] is String) {
|
||||
_dailyKcalGoalUpdatedAt = DateTime.tryParse(
|
||||
data['daily_kcal_goal_updated_at'] as String,
|
||||
);
|
||||
}
|
||||
} on Exception {
|
||||
// Ignore parse errors and keep default.
|
||||
// Ignore parse errors and keep defaults.
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates the in-memory value and persists [goal] to disk.
|
||||
Future<void> saveDailyKcalGoal(int goal) async {
|
||||
/// Updates the in-memory value and persists [goal] to disk, stamping the
|
||||
/// edit with the current time -- the timestamp a sync merge compares
|
||||
/// against another device's edit to resolve last-writer-wins.
|
||||
Future<void> saveDailyKcalGoal(int goal) => _persist(goal, DateTime.now());
|
||||
|
||||
/// Applies a synced budget value without stamping a fresh edit time.
|
||||
///
|
||||
/// Used only by the sync layer to write back a merge's winning value: it
|
||||
/// persists [updatedAt] verbatim (the winning side's real edit time), not
|
||||
/// "now", so re-syncing an unchanged value stays idempotent instead of
|
||||
/// making this device's copy look newer than it actually is on every
|
||||
/// tick.
|
||||
Future<void> applySyncedBudget(int goal, {DateTime? updatedAt}) =>
|
||||
_persist(goal, updatedAt);
|
||||
|
||||
Future<void> _persist(int goal, DateTime? updatedAt) async {
|
||||
_dailyKcalGoal = goal;
|
||||
_dailyKcalGoalUpdatedAt = updatedAt;
|
||||
await _file.parent.create(recursive: true);
|
||||
await _file.writeAsString(jsonEncode({'daily_kcal_goal': goal}));
|
||||
await _file.writeAsString(
|
||||
jsonEncode({
|
||||
'daily_kcal_goal': goal,
|
||||
'daily_kcal_goal_updated_at': updatedAt?.toIso8601String(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
library;
|
||||
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
import 'package:diet_guard_app/services/app_settings_service.dart';
|
||||
import 'package:diet_guard_app/services/foodbank_service.dart';
|
||||
import 'package:diet_guard_app/services/log_storage_service.dart';
|
||||
import 'package:diet_guard_app/services/sync_service.dart';
|
||||
@ -26,9 +27,13 @@ const String syncPushTaskName = 'diet_guard.sync_push';
|
||||
/// reason. The service singletons are (re)initialised here because a fresh
|
||||
/// background isolate has none; [LogStorageService.init] is idempotent, so
|
||||
/// this is a no-op when a test has already pointed it at a temp dir.
|
||||
/// [AppSettingsService.init] must run too: [runSync] now also syncs the
|
||||
/// budget, and applies a merge winner via `AppSettingsService.instance`,
|
||||
/// which throws if the singleton was never initialised in this isolate.
|
||||
Future<bool> backgroundSyncPush({http.Client? httpClient}) async {
|
||||
await LogStorageService.init();
|
||||
await FoodBankService.init();
|
||||
await AppSettingsService.init();
|
||||
final SyncSettings settings;
|
||||
try {
|
||||
settings = await SyncSettings.load();
|
||||
|
||||
155
app/lib/services/day_status_service.dart
Normal file
155
app/lib/services/day_status_service.dart
Normal file
@ -0,0 +1,155 @@
|
||||
/// Per-day budget-adherence status, streaks, and year-to-date tallies.
|
||||
///
|
||||
/// DAY STATUS SPEC (keep in sync with `diet_guard/_daystatus.py`):
|
||||
///
|
||||
/// * green: `total_kcal <= budget`
|
||||
/// * yellow: `budget < total_kcal <= budget * kOverBudgetYellowCeiling`
|
||||
/// * red: `total_kcal > budget * kOverBudgetYellowCeiling`
|
||||
/// * notLogged: the day is absent from the log entirely (no valid,
|
||||
/// non-tombstoned entries).
|
||||
/// * ordering, worst to best: notLogged > red > yellow > green.
|
||||
/// * the logging streak breaks on a not-logged day.
|
||||
/// * the adherence streak breaks on a red or a not-logged day; yellow and
|
||||
/// green both keep it alive.
|
||||
///
|
||||
/// Every function here is a pure function of an explicit `log`/`statusMap`
|
||||
/// argument (never reaching into on-disk state itself), so the boundary
|
||||
/// matrix above is trivially testable with synthetic data.
|
||||
library;
|
||||
|
||||
import 'package:diet_guard_app/models/day_status.dart';
|
||||
import 'package:diet_guard_app/models/food_entry.dart';
|
||||
import 'package:diet_guard_app/services/log_storage_service.dart';
|
||||
|
||||
/// The multiplier defining the top of the "yellow" band above budget: a 20%
|
||||
/// margin, mirroring `diet_guard._constants.BUDGET_WARN_FRACTION` (0.80)'s
|
||||
/// 20% margin below 100% on the "approaching limit" side, so the two bands
|
||||
/// are symmetric around the budget line.
|
||||
const double kOverBudgetYellowCeiling = 1.20;
|
||||
|
||||
const Set<DayStatus> _loggedStatuses = {
|
||||
DayStatus.green,
|
||||
DayStatus.yellow,
|
||||
DayStatus.red,
|
||||
};
|
||||
|
||||
const Set<DayStatus> _adherentStatuses = {DayStatus.green, DayStatus.yellow};
|
||||
|
||||
/// Returns the summed kcal across [entries].
|
||||
///
|
||||
/// Shared by [dayTotalKcal] and `history_screen.dart`'s day-header total, so
|
||||
/// the "sum a day's entries" logic exists in exactly one place regardless of
|
||||
/// whether the caller already has a day's entries grouped or is looking one
|
||||
/// up by date key.
|
||||
double sumKcal(Iterable<FoodEntry> entries) {
|
||||
var total = 0.0;
|
||||
for (final entry in entries) {
|
||||
total += entry.kcal;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/// Returns [log]'s entries for [day] with tombstoned (deleted) ones
|
||||
/// removed -- `readLog()` returns the raw log including tombstones, so
|
||||
/// every consumer that classifies a day (rather than just displaying an
|
||||
/// already-filtered list) must drop them here first.
|
||||
Iterable<FoodEntry> _validEntriesForDay(DayLog log, String day) =>
|
||||
(log[day] ?? const []).where((entry) => !entry.deleted);
|
||||
|
||||
/// Returns the summed kcal for one `YYYY-MM-DD` key in [log], excluding
|
||||
/// tombstoned entries.
|
||||
double dayTotalKcal(DayLog log, String day) =>
|
||||
sumKcal(_validEntriesForDay(log, day));
|
||||
|
||||
/// Classifies one day's budget adherence.
|
||||
///
|
||||
/// A day with no valid, non-tombstoned entries is [DayStatus.notLogged] --
|
||||
/// a day whose only entries were later deleted must not read as green at
|
||||
/// zero kcal.
|
||||
DayStatus dayStatus(DayLog log, String day, int budget) {
|
||||
final entries = _validEntriesForDay(log, day).toList();
|
||||
if (entries.isEmpty) return DayStatus.notLogged;
|
||||
final total = sumKcal(entries);
|
||||
if (total <= budget) return DayStatus.green;
|
||||
if (total <= budget * kOverBudgetYellowCeiling) return DayStatus.yellow;
|
||||
return DayStatus.red;
|
||||
}
|
||||
|
||||
/// Returns a [DayStatus] for every date key present in [log].
|
||||
///
|
||||
/// Days with no log entries are simply absent from the result; a caller
|
||||
/// rendering a full calendar treats a missing key as [DayStatus.notLogged].
|
||||
Map<String, DayStatus> statusMap(DayLog log, {required int budget}) {
|
||||
return {for (final day in log.keys) day: dayStatus(log, day, budget)};
|
||||
}
|
||||
|
||||
DateTime _dateOnly(DateTime d) => DateTime(d.year, d.month, d.day);
|
||||
|
||||
String _dateKey(DateTime d) {
|
||||
final y = d.year.toString().padLeft(4, '0');
|
||||
final m = d.month.toString().padLeft(2, '0');
|
||||
final day = d.day.toString().padLeft(2, '0');
|
||||
return '$y-$m-$day';
|
||||
}
|
||||
|
||||
int _streak(
|
||||
Map<String, DayStatus> statusMap,
|
||||
Set<DayStatus> keeps, {
|
||||
DateTime? today,
|
||||
}) {
|
||||
var day = _dateOnly(today ?? DateTime.now());
|
||||
if (statusMap[_dateKey(day)] == null) {
|
||||
day = day.subtract(const Duration(days: 1));
|
||||
}
|
||||
var count = 0;
|
||||
while (keeps.contains(statusMap[_dateKey(day)])) {
|
||||
count++;
|
||||
day = day.subtract(const Duration(days: 1));
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/// Returns the consecutive-day logging streak ending at [today].
|
||||
///
|
||||
/// A [today] absent from [statusMap] (not yet logged) is skipped rather
|
||||
/// than treated as a break, so the streak does not appear broken every
|
||||
/// morning before the user has eaten; counting resumes from yesterday in
|
||||
/// that case. Any other day missing from [statusMap] ends the streak.
|
||||
int loggingStreak(Map<String, DayStatus> statusMap, {DateTime? today}) =>
|
||||
_streak(statusMap, _loggedStatuses, today: today);
|
||||
|
||||
/// Returns the consecutive-day budget-adherence streak ending at [today].
|
||||
///
|
||||
/// Breaks on a red or not-logged day; green and yellow days both keep it
|
||||
/// alive ("not too aggressively crossed").
|
||||
int adherenceStreak(Map<String, DayStatus> statusMap, {DateTime? today}) =>
|
||||
_streak(statusMap, _adherentStatuses, today: today);
|
||||
|
||||
int _dayOfYear(DateTime d) {
|
||||
final start = DateTime.utc(d.year);
|
||||
final day = DateTime.utc(d.year, d.month, d.day);
|
||||
return day.difference(start).inDays + 1;
|
||||
}
|
||||
|
||||
/// Returns this year's logged/elapsed/adherent day counts.
|
||||
///
|
||||
/// [today] is the reference date; defaults to the real current date.
|
||||
/// [elapsedDays] is inclusive of [today].
|
||||
YtdTally yearToDateTally(Map<String, DayStatus> statusMap, {DateTime? today}) {
|
||||
final ref = _dateOnly(today ?? DateTime.now());
|
||||
final elapsedDays = _dayOfYear(ref);
|
||||
var loggedDays = 0;
|
||||
var adherentDays = 0;
|
||||
for (final mapEntry in statusMap.entries) {
|
||||
if (mapEntry.value == DayStatus.notLogged) continue;
|
||||
final day = DateTime.parse(mapEntry.key);
|
||||
if (day.year != ref.year || day.isAfter(ref)) continue;
|
||||
loggedDays++;
|
||||
if (_adherentStatuses.contains(mapEntry.value)) adherentDays++;
|
||||
}
|
||||
return YtdTally(
|
||||
loggedDays: loggedDays,
|
||||
elapsedDays: elapsedDays,
|
||||
adherentDays: adherentDays,
|
||||
);
|
||||
}
|
||||
@ -20,6 +20,14 @@
|
||||
/// tries the new Record-based wire format first and falls back to the old
|
||||
/// plain-DayLog format, converting old-format entries through the same
|
||||
/// adapter used for the local log.
|
||||
///
|
||||
/// The budget adapters at the bottom of this file ([budgetToLog] etc.)
|
||||
/// follow the same Record/Log shape, but a budget record is edited
|
||||
/// repeatedly (not immutable-after-creation like a food-log entry), so its
|
||||
/// [Hlc] is derived from a `t` edit timestamp
|
||||
/// (`AppSettingsService.dailyKcalGoalUpdatedAt`) rather than a birth time
|
||||
/// that never changes -- mirrors `diet_guard/_sync_merge.py`'s budget
|
||||
/// adapters field-for-field.
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
@ -177,3 +185,86 @@ String encodeLogForPush(Log log) {
|
||||
};
|
||||
return jsonEncode(encoded);
|
||||
}
|
||||
|
||||
/// Stable id: exactly one budget record per device-pushed `budget.json`.
|
||||
const budgetRecordId = 'budget';
|
||||
|
||||
/// Derives a deterministic [Hlc] for a raw budget record from its `t` field.
|
||||
///
|
||||
/// Mirrors [entryHlc]'s determinism -- the same unedited record always
|
||||
/// yields the same Hlc, so re-syncing an unchanged budget is a no-op -- but
|
||||
/// reads `t` (bumped on every explicit edit) rather than a fixed birth
|
||||
/// time, since a budget can be edited repeatedly and the *edit* time is
|
||||
/// what last-writer-wins must compare.
|
||||
Hlc budgetHlc(Map<String, dynamic> record) {
|
||||
final wallTimeMs =
|
||||
DateTime.tryParse(
|
||||
record['t'] as String? ?? '',
|
||||
)?.toUtc().millisecondsSinceEpoch ??
|
||||
0;
|
||||
return Hlc.newTick(_syncDeviceId, wallTimeMsOverride: wallTimeMs);
|
||||
}
|
||||
|
||||
/// Converts a raw local/remote budget record into a single-record [Log].
|
||||
///
|
||||
/// Returns an empty [Log] when [record] is null (this device has never
|
||||
/// explicitly set a budget), so it contributes nothing to the merge rather
|
||||
/// than clobbering another device's real value with the unset default.
|
||||
Log budgetToLog(Map<String, dynamic>? record) {
|
||||
if (record == null) return {};
|
||||
final hlc = budgetHlc(record);
|
||||
final value = Map<String, dynamic>.from(record)..remove('t');
|
||||
return {
|
||||
budgetRecordId: Record(id: budgetRecordId, fields: {'value': (value, hlc)}),
|
||||
};
|
||||
}
|
||||
|
||||
/// Converts a merged budget [Log] back into a raw budget record.
|
||||
///
|
||||
/// Returns null when the log has no budget record at all (neither device
|
||||
/// has ever set one yet) -- callers treat that as "nothing to apply
|
||||
/// locally", not an error.
|
||||
Map<String, dynamic>? logToBudget(Log log) {
|
||||
final record = log[budgetRecordId];
|
||||
if (record == null) return null;
|
||||
final field = record.fields['value'];
|
||||
final value = field?.$1;
|
||||
final result = value is Map
|
||||
? Map<String, dynamic>.from(value.cast<String, dynamic>())
|
||||
: <String, dynamic>{};
|
||||
final hlc = field?.$2;
|
||||
if (hlc != null) {
|
||||
result['t'] = DateTime.fromMillisecondsSinceEpoch(
|
||||
hlc.wallTimeMs,
|
||||
isUtc: true,
|
||||
).toLocal().toIso8601String();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Parses one device's pushed `budget.json` text into a crdt_sync [Log].
|
||||
///
|
||||
/// Throwing [FormatException] or [TypeError] is treated as unparsable by
|
||||
/// the caller (`syncLog`'s `decode` callback), matching [parseRemoteLog]'s
|
||||
/// tolerance for a corrupt/truncated push. There is no legacy plain-format
|
||||
/// fallback here -- `budget.json` is a brand-new sync payload.
|
||||
Log parseRemoteBudget(String text) {
|
||||
final raw = jsonDecode(text);
|
||||
if (raw is! Map) {
|
||||
throw const FormatException(
|
||||
'top-level budget payload is not a JSON object',
|
||||
);
|
||||
}
|
||||
final rawMap = raw.cast<String, dynamic>();
|
||||
return rawMap.map(
|
||||
(id, data) => MapEntry(id, Record.fromJson(data as Map<String, dynamic>)),
|
||||
);
|
||||
}
|
||||
|
||||
/// Serializes a merged budget [Log] for push.
|
||||
String encodeBudgetForPush(Log log) {
|
||||
final encoded = <String, dynamic>{
|
||||
for (final entry in log.entries) entry.key: entry.value.toJson(),
|
||||
};
|
||||
return jsonEncode(encoded);
|
||||
}
|
||||
|
||||
@ -9,10 +9,16 @@
|
||||
/// entry never carries `imagePath` (stripped before push, meaningless on
|
||||
/// another device), so it must not null out a local photo attachment for
|
||||
/// the same `id` (plan decision 10).
|
||||
///
|
||||
/// The daily budget syncs alongside the food log in the same tick (see
|
||||
/// [_syncBudget]): a sibling `budget.json` per device, merged
|
||||
/// last-writer-wins by edit time rather than union-of-immutable-entries,
|
||||
/// since a budget (unlike a food-log entry) can be edited repeatedly.
|
||||
library;
|
||||
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
import 'package:diet_guard_app/models/food_entry.dart';
|
||||
import 'package:diet_guard_app/services/app_settings_service.dart';
|
||||
import 'package:diet_guard_app/services/foodbank_service.dart';
|
||||
import 'package:diet_guard_app/services/log_storage_service.dart';
|
||||
import 'package:diet_guard_app/services/sync_merge.dart';
|
||||
@ -51,9 +57,49 @@ Future<DayLog> runSync(GitHubClient client) async {
|
||||
|
||||
await logService.writeLog(merged);
|
||||
await FoodBankService.instance.rebuildAndPersist(merged);
|
||||
await _syncBudget(client);
|
||||
return merged;
|
||||
}
|
||||
|
||||
/// Pulls other devices' budgets, merges, applies the winner locally, pushes.
|
||||
///
|
||||
/// Reuses [syncLog] (it always pushes the merged result, even an empty
|
||||
/// one -- harmless and consistent with the food-log sync above). This
|
||||
/// device contributes nothing to the merge until the goal has been
|
||||
/// explicitly set at least once (see
|
||||
/// [AppSettingsService.dailyKcalGoalUpdatedAt]), so a fresh install's
|
||||
/// unset 2200 default can never spuriously outrank a real budget synced
|
||||
/// from elsewhere.
|
||||
Future<void> _syncBudget(GitHubClient client) async {
|
||||
final updatedAt = AppSettingsService.dailyKcalGoalUpdatedAt;
|
||||
final localRecord = updatedAt == null
|
||||
? null
|
||||
: <String, dynamic>{
|
||||
'v': 2,
|
||||
'b': AppSettingsService.dailyKcalGoal,
|
||||
't': updatedAt.toIso8601String(),
|
||||
};
|
||||
|
||||
final mergedBudgetLog = await syncLog(
|
||||
client: client,
|
||||
deviceId: phoneDeviceId,
|
||||
pathPrefix: _devicesDir,
|
||||
localLog: budgetToLog(localRecord),
|
||||
encode: encodeBudgetForPush,
|
||||
decode: parseRemoteBudget,
|
||||
filename: 'budget.json',
|
||||
commitMessage: 'diet_guard_app sync',
|
||||
);
|
||||
|
||||
final merged = logToBudget(mergedBudgetLog);
|
||||
final mergedKcal = merged?['b'];
|
||||
if (merged == null || mergedKcal is! int) return;
|
||||
await AppSettingsService.instance.applySyncedBudget(
|
||||
mergedKcal,
|
||||
updatedAt: DateTime.tryParse(merged['t'] as String? ?? ''),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, String> _imagePathsById(DayLog log) {
|
||||
final result = <String, String>{};
|
||||
for (final entries in log.values) {
|
||||
|
||||
201
app/lib/widgets/day_status_calendar.dart
Normal file
201
app/lib/widgets/day_status_calendar.dart
Normal file
@ -0,0 +1,201 @@
|
||||
/// Monthly calendar colored by daily budget-adherence status.
|
||||
library;
|
||||
|
||||
import 'package:diet_guard_app/models/day_status.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Monthly calendar widget that colors each day by its [DayStatus].
|
||||
///
|
||||
/// Mirrors the diet_guard gate's calendar tab (`_gatelock_calendar.py` /
|
||||
/// `_calendar_view.py`): not-logged renders as a black cell with a visible
|
||||
/// outline (so it doesn't vanish into the dark background); a day after
|
||||
/// [today] -- nothing has happened yet to judge -- renders neutrally,
|
||||
/// never as a false "not logged" black cell.
|
||||
class DayStatusCalendar extends StatelessWidget {
|
||||
/// Creates a [DayStatusCalendar].
|
||||
const DayStatusCalendar({
|
||||
required this.statusByDate,
|
||||
required this.month,
|
||||
required this.today,
|
||||
required this.onPrevMonth,
|
||||
required this.onNextMonth,
|
||||
this.onDaySelected,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// Per-day status, keyed `YYYY-MM-DD`. A past-or-today date absent from
|
||||
/// this map is treated as [DayStatus.notLogged].
|
||||
final Map<String, DayStatus> statusByDate;
|
||||
|
||||
/// Only the year and month of this DateTime are used.
|
||||
final DateTime month;
|
||||
|
||||
/// The reference "today"; days after this render neutrally.
|
||||
final DateTime today;
|
||||
|
||||
/// Called when the user taps the previous-month chevron.
|
||||
final VoidCallback onPrevMonth;
|
||||
|
||||
/// Called when the user taps the next-month chevron.
|
||||
final VoidCallback onNextMonth;
|
||||
|
||||
/// Called with a day's date when the user taps that day's cell.
|
||||
///
|
||||
/// Null disables tapping (cells render but do not respond). A cell
|
||||
/// outside the displayed month is never tappable regardless.
|
||||
final void Function(DateTime day)? onDaySelected;
|
||||
|
||||
static const _weekHeaders = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'];
|
||||
|
||||
static const _monthNames = [
|
||||
'January',
|
||||
'February',
|
||||
'March',
|
||||
'April',
|
||||
'May',
|
||||
'June',
|
||||
'July',
|
||||
'August',
|
||||
'September',
|
||||
'October',
|
||||
'November',
|
||||
'December',
|
||||
];
|
||||
|
||||
String _dateKey(int year, int m, int day) =>
|
||||
'$year-${m.toString().padLeft(2, '0')}-${day.toString().padLeft(2, '0')}';
|
||||
|
||||
/// Returns the status to render for [year]/[m]/[day], or null for a
|
||||
/// future day (nothing to judge yet).
|
||||
DayStatus? _statusFor(int year, int m, int day) {
|
||||
final date = DateTime(year, m, day);
|
||||
final todayOnly = DateTime(today.year, today.month, today.day);
|
||||
if (date.isAfter(todayOnly)) return null;
|
||||
return statusByDate[_dateKey(year, m, day)] ?? DayStatus.notLogged;
|
||||
}
|
||||
|
||||
Color _cellColor(DayStatus? status) => switch (status) {
|
||||
null => Colors.grey.shade800,
|
||||
DayStatus.notLogged => Colors.black,
|
||||
DayStatus.red => Colors.red.shade400,
|
||||
DayStatus.yellow => Colors.amber.shade700,
|
||||
DayStatus.green => Colors.green.shade700,
|
||||
};
|
||||
|
||||
Color _textColor(DayStatus? status) => switch (status) {
|
||||
null => Colors.white38,
|
||||
DayStatus.notLogged => Colors.white,
|
||||
DayStatus.red || DayStatus.yellow || DayStatus.green => Colors.black,
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final year = month.year;
|
||||
final m = month.month;
|
||||
final daysInMonth = DateTime(year, m + 1, 0).day;
|
||||
// weekday: 1=Mon..7=Sun -> offset 0..6
|
||||
final firstWeekday = DateTime(year, m).weekday - 1;
|
||||
final totalCells = firstWeekday + daysInMonth;
|
||||
final rows = (totalCells / 7).ceil();
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade800,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.chevron_left, color: Colors.white70),
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: EdgeInsets.zero,
|
||||
onPressed: onPrevMonth,
|
||||
),
|
||||
Text(
|
||||
'${_monthNames[m - 1]} $year',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.chevron_right, color: Colors.white70),
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: EdgeInsets.zero,
|
||||
onPressed: onNextMonth,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: _weekHeaders
|
||||
.map(
|
||||
(h) => SizedBox(
|
||||
width: 30,
|
||||
child: Text(
|
||||
h,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
color: Colors.white38,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
...List.generate(rows, (row) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: List.generate(7, (col) {
|
||||
final cell = row * 7 + col;
|
||||
final day = cell - firstWeekday + 1;
|
||||
if (day < 1 || day > daysInMonth) {
|
||||
return const SizedBox(width: 30, height: 30);
|
||||
}
|
||||
final status = _statusFor(year, m, day);
|
||||
final cellWidget = Container(
|
||||
width: 30,
|
||||
height: 30,
|
||||
margin: const EdgeInsets.symmetric(vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: _cellColor(status),
|
||||
border: status == DayStatus.notLogged
|
||||
? Border.all(color: Colors.white70)
|
||||
: null,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
'$day',
|
||||
style: TextStyle(
|
||||
color: _textColor(status),
|
||||
fontSize: 12,
|
||||
fontWeight:
|
||||
status != null && status != DayStatus.notLogged
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
);
|
||||
if (onDaySelected == null) return cellWidget;
|
||||
return GestureDetector(
|
||||
onTap: () => onDaySelected!(DateTime(year, m, day)),
|
||||
child: cellWidget,
|
||||
);
|
||||
}),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
53
app/lib/widgets/streak_summary_row.dart
Normal file
53
app/lib/widgets/streak_summary_row.dart
Normal file
@ -0,0 +1,53 @@
|
||||
/// Streak and year-to-date tally summary row for the History screen.
|
||||
library;
|
||||
|
||||
import 'package:diet_guard_app/models/day_status.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Displays the logging streak, adherence streak, and year-to-date tally.
|
||||
///
|
||||
/// Mirrors `_calendar_view.py`'s `streaks_text`/`ytd_text` formatting:
|
||||
/// "Logging streak: N day(s) · Adherence streak: N day(s)" and
|
||||
/// "This year: logged X/Y days · Z within budget".
|
||||
class StreakSummaryRow extends StatelessWidget {
|
||||
/// Creates a [StreakSummaryRow].
|
||||
const StreakSummaryRow({
|
||||
required this.loggingStreak,
|
||||
required this.adherenceStreak,
|
||||
required this.tally,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// Consecutive-day logging streak.
|
||||
final int loggingStreak;
|
||||
|
||||
/// Consecutive-day budget-adherence streak.
|
||||
final int adherenceStreak;
|
||||
|
||||
/// This year's logged/elapsed/adherent day counts.
|
||||
final YtdTally tally;
|
||||
|
||||
static String _plural(int count) => count == 1 ? 'day' : 'days';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Column(
|
||||
children: [
|
||||
Text(
|
||||
'Logging streak: $loggingStreak ${_plural(loggingStreak)} · '
|
||||
'Adherence streak: $adherenceStreak ${_plural(adherenceStreak)}',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.titleSmall,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'This year: logged ${tally.loggedDays}/${tally.elapsedDays} days · '
|
||||
'${tally.adherentDays} within budget',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
39
app/test/models/day_status_test.dart
Normal file
39
app/test/models/day_status_test.dart
Normal file
@ -0,0 +1,39 @@
|
||||
import 'package:diet_guard_app/models/day_status.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('YtdTally', () {
|
||||
test('equal tallies compare equal and share hashCode', () {
|
||||
const a = YtdTally(loggedDays: 5, elapsedDays: 10, adherentDays: 3);
|
||||
const b = YtdTally(loggedDays: 5, elapsedDays: 10, adherentDays: 3);
|
||||
expect(a, equals(b));
|
||||
expect(a.hashCode, equals(b.hashCode));
|
||||
});
|
||||
|
||||
test('differing tallies compare unequal', () {
|
||||
const a = YtdTally(loggedDays: 5, elapsedDays: 10, adherentDays: 3);
|
||||
const b = YtdTally(loggedDays: 4, elapsedDays: 10, adherentDays: 3);
|
||||
expect(a, isNot(equals(b)));
|
||||
});
|
||||
|
||||
test('is not equal to an unrelated object', () {
|
||||
const a = YtdTally(loggedDays: 5, elapsedDays: 10, adherentDays: 3);
|
||||
const Object other = 'not a tally';
|
||||
expect(a == other, isFalse);
|
||||
});
|
||||
|
||||
test('toString is human-readable', () {
|
||||
const a = YtdTally(loggedDays: 5, elapsedDays: 10, adherentDays: 3);
|
||||
expect(a.toString(), 'YtdTally(logged: 5/10, adherent: 3)');
|
||||
});
|
||||
});
|
||||
|
||||
test('DayStatus has exactly the four expected values', () {
|
||||
expect(DayStatus.values, [
|
||||
DayStatus.notLogged,
|
||||
DayStatus.red,
|
||||
DayStatus.yellow,
|
||||
DayStatus.green,
|
||||
]);
|
||||
});
|
||||
}
|
||||
175
app/test/screens/calendar_screen_test.dart
Normal file
175
app/test/screens/calendar_screen_test.dart
Normal file
@ -0,0 +1,175 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:diet_guard_app/screens/calendar_screen.dart';
|
||||
import 'package:diet_guard_app/screens/history_screen.dart';
|
||||
import 'package:diet_guard_app/services/app_settings_service.dart';
|
||||
import 'package:diet_guard_app/services/log_storage_service.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
const _monthNames = [
|
||||
'January',
|
||||
'February',
|
||||
'March',
|
||||
'April',
|
||||
'May',
|
||||
'June',
|
||||
'July',
|
||||
'August',
|
||||
'September',
|
||||
'October',
|
||||
'November',
|
||||
'December',
|
||||
];
|
||||
|
||||
String _label(DateTime d) => '${_monthNames[d.month - 1]} ${d.year}';
|
||||
|
||||
void main() {
|
||||
late Directory tempDir;
|
||||
|
||||
setUp(() async {
|
||||
tempDir = await Directory.systemTemp.createTemp('diet_guard_calendar_');
|
||||
LogStorageService.resetForTesting(testDir: tempDir);
|
||||
await AppSettingsService.initForTesting(tempDir);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
LogStorageService.resetForTesting();
|
||||
AppSettingsService.resetForTesting();
|
||||
await tempDir.delete(recursive: true);
|
||||
});
|
||||
|
||||
Future<void> pumpScreen(WidgetTester tester) async {
|
||||
await tester.pumpWidget(const MaterialApp(home: CalendarScreen()));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
/// `FilledButton.onPressed` only accepts a synchronous `VoidCallback`, so
|
||||
/// `_onEditOrSaveBudget`'s real dart:io write is fired-and-forgotten by
|
||||
/// `tester.tap()` -- `pumpAndSettle()` alone can return before it
|
||||
/// resolves. Poll for the observable effect instead, mirroring
|
||||
/// settings_screen_test.dart's `pumpUntil` for the same class of issue.
|
||||
Future<void> pumpUntil(
|
||||
WidgetTester tester,
|
||||
bool Function() done, {
|
||||
int maxTries = 200,
|
||||
}) async {
|
||||
for (var i = 0; i < maxTries && !done(); i++) {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 10));
|
||||
await tester.pump();
|
||||
}
|
||||
}
|
||||
|
||||
testWidgets('shows the default 2200 kcal budget, read-only, with an Edit '
|
||||
'button', (tester) async {
|
||||
await pumpScreen(tester);
|
||||
expect(find.widgetWithText(TextField, '2200'), findsOneWidget);
|
||||
expect(find.widgetWithText(FilledButton, 'Edit'), findsOneWidget);
|
||||
final field = tester.widget<TextField>(find.byType(TextField));
|
||||
expect(field.enabled, isFalse);
|
||||
});
|
||||
|
||||
testWidgets('tapping Edit unlocks the field and relabels the button Save', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpScreen(tester);
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Edit'));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.widgetWithText(FilledButton, 'Save'), findsOneWidget);
|
||||
final field = tester.widget<TextField>(find.byType(TextField));
|
||||
expect(field.enabled, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('entering an invalid value keeps editing open with an error', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpScreen(tester);
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Edit'));
|
||||
await tester.pump();
|
||||
await tester.enterText(find.byType(TextField), '');
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Save'));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Enter a whole number of kcal.'), findsOneWidget);
|
||||
expect(find.widgetWithText(FilledButton, 'Save'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a zero value is rejected and editing stays open', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpScreen(tester);
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Edit'));
|
||||
await tester.pump();
|
||||
await tester.enterText(find.byType(TextField), '0');
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Save'));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Budget must be a positive number.'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('saving a valid value persists it and reverts to read-only', (
|
||||
tester,
|
||||
) async {
|
||||
// Real dart:io writes (AppSettingsService._persist, LogStorageService's
|
||||
// subsequent re-read) complete on the real event loop, not a microtask
|
||||
// pumpAndSettle alone drains -- runAsync interleaves real waits with
|
||||
// frame pumps, mirroring settings_screen_test.dart's device-flow tests.
|
||||
await tester.runAsync(() async {
|
||||
await pumpScreen(tester);
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Edit'));
|
||||
await tester.pump();
|
||||
await tester.enterText(find.byType(TextField), '1800');
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Save'));
|
||||
await pumpUntil(tester, () => find.text('Saved.').evaluate().isNotEmpty);
|
||||
|
||||
expect(find.text('Saved.'), findsOneWidget);
|
||||
expect(find.widgetWithText(FilledButton, 'Edit'), findsOneWidget);
|
||||
expect(AppSettingsService.dailyKcalGoal, 1800);
|
||||
final field = tester.widget<TextField>(find.byType(TextField));
|
||||
expect(field.enabled, isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('shows streak and year-to-date summary text', (tester) async {
|
||||
await pumpScreen(tester);
|
||||
expect(find.textContaining('Logging streak:'), findsOneWidget);
|
||||
expect(find.textContaining('This year:'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('month navigation steps the displayed month back and forward', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpScreen(tester);
|
||||
final now = DateTime.now();
|
||||
expect(find.text(_label(now)), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.chevron_left));
|
||||
await tester.pump();
|
||||
expect(
|
||||
find.text(_label(DateTime(now.year, now.month - 1))),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.chevron_right));
|
||||
await tester.pump();
|
||||
expect(find.text(_label(now)), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('tapping a day navigates to HistoryScreen filtered to that day', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpScreen(tester);
|
||||
final today = DateTime.now();
|
||||
await tester.tap(find.text('${today.day}'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(HistoryScreen), findsOneWidget);
|
||||
final screen = tester.widget<HistoryScreen>(find.byType(HistoryScreen));
|
||||
final range = screen.initialDateRange!;
|
||||
expect(range.start.year, today.year);
|
||||
expect(range.start.month, today.month);
|
||||
expect(range.start.day, today.day);
|
||||
expect(range.end, range.start);
|
||||
});
|
||||
}
|
||||
@ -129,6 +129,58 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'initialDateRange pre-filters to just that day, matching a Calendar '
|
||||
'screen day tap',
|
||||
(tester) async {
|
||||
await tester.runAsync(() async {
|
||||
await LogStorageService.instance.writeLog({
|
||||
'2026-06-01': [
|
||||
const FoodEntry(
|
||||
id: 'other-day',
|
||||
time: '2026-06-01T08:00:00+02:00',
|
||||
desc: 'other day breakfast',
|
||||
grams: 100,
|
||||
kcal: 100,
|
||||
proteinG: 5,
|
||||
carbsG: 10,
|
||||
fatG: 2,
|
||||
source: 'manual',
|
||||
),
|
||||
],
|
||||
'2026-06-22': [
|
||||
const FoodEntry(
|
||||
id: 'target-day',
|
||||
time: '2026-06-22T20:00:00+02:00',
|
||||
desc: 'target day dinner',
|
||||
grams: 100,
|
||||
kcal: 200,
|
||||
proteinG: 10,
|
||||
carbsG: 20,
|
||||
fatG: 4,
|
||||
source: 'manual',
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: HistoryScreen(
|
||||
initialDateRange: DateTimeRange(
|
||||
start: DateTime(2026, 6, 22),
|
||||
end: DateTime(2026, 6, 22),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await settle(tester);
|
||||
|
||||
expect(find.text('target day dinner'), findsOneWidget);
|
||||
expect(find.text('other day breakfast'), findsNothing);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// applyHistoryFilter — pure function tests (no widget required)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:diet_guard_app/models/food_entry.dart';
|
||||
import 'package:diet_guard_app/screens/calendar_screen.dart';
|
||||
import 'package:diet_guard_app/screens/food_bank_screen.dart';
|
||||
import 'package:diet_guard_app/screens/log_meal_screen.dart';
|
||||
import 'package:diet_guard_app/screens/history_screen.dart';
|
||||
@ -146,6 +147,20 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('the calendar icon navigates to CalendarScreen', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.runAsync(() async {
|
||||
await tester.pumpWidget(const MaterialApp(home: LogMealScreen()));
|
||||
await settle(tester);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.calendar_month));
|
||||
await settle(tester);
|
||||
|
||||
expect(find.byType(CalendarScreen), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('the settings icon navigates to SettingsScreen', (tester) async {
|
||||
await tester.runAsync(() async {
|
||||
await tester.pumpWidget(const MaterialApp(home: LogMealScreen()));
|
||||
|
||||
@ -63,6 +63,52 @@ void main() {
|
||||
final data = jsonDecode(raw) as Map;
|
||||
expect(data['daily_kcal_goal'], 1800);
|
||||
});
|
||||
|
||||
test('stamps a fresh dailyKcalGoalUpdatedAt', () async {
|
||||
AppSettingsService.resetForTesting(testDir: tempDir);
|
||||
final before = DateTime.now();
|
||||
await AppSettingsService.instance.saveDailyKcalGoal(1800);
|
||||
final after = DateTime.now();
|
||||
|
||||
final updatedAt = AppSettingsService.dailyKcalGoalUpdatedAt;
|
||||
expect(updatedAt, isNotNull);
|
||||
expect(updatedAt!.isBefore(before), isFalse);
|
||||
expect(updatedAt.isAfter(after), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('applySyncedBudget', () {
|
||||
test(
|
||||
'persists the given value and updatedAt verbatim, not "now"',
|
||||
() async {
|
||||
AppSettingsService.resetForTesting(testDir: tempDir);
|
||||
final winningEdit = DateTime.utc(2020);
|
||||
|
||||
await AppSettingsService.instance.applySyncedBudget(
|
||||
1700,
|
||||
updatedAt: winningEdit,
|
||||
);
|
||||
|
||||
expect(AppSettingsService.dailyKcalGoal, 1700);
|
||||
expect(AppSettingsService.dailyKcalGoalUpdatedAt, winningEdit);
|
||||
|
||||
final raw = await File(
|
||||
'${tempDir.path}/app_settings.json',
|
||||
).readAsString();
|
||||
final data = jsonDecode(raw) as Map;
|
||||
expect(data['daily_kcal_goal'], 1700);
|
||||
expect(
|
||||
data['daily_kcal_goal_updated_at'],
|
||||
winningEdit.toIso8601String(),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('a null updatedAt is accepted and persisted as null', () async {
|
||||
AppSettingsService.resetForTesting(testDir: tempDir);
|
||||
await AppSettingsService.instance.applySyncedBudget(1700);
|
||||
expect(AppSettingsService.dailyKcalGoalUpdatedAt, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('initForTesting (_load paths)', () {
|
||||
@ -111,5 +157,50 @@ void main() {
|
||||
|
||||
expect(AppSettingsService.dailyKcalGoal, 2200);
|
||||
});
|
||||
|
||||
test('loads daily_kcal_goal_updated_at from an existing file', () async {
|
||||
await File('${tempDir.path}/app_settings.json').writeAsString(
|
||||
jsonEncode({
|
||||
'daily_kcal_goal': 1600,
|
||||
'daily_kcal_goal_updated_at': '2026-01-01T00:00:00.000Z',
|
||||
}),
|
||||
);
|
||||
|
||||
await AppSettingsService.initForTesting(tempDir);
|
||||
|
||||
expect(
|
||||
AppSettingsService.dailyKcalGoalUpdatedAt,
|
||||
DateTime.parse('2026-01-01T00:00:00.000Z'),
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'dailyKcalGoalUpdatedAt stays null when file has no such key',
|
||||
() async {
|
||||
await File(
|
||||
'${tempDir.path}/app_settings.json',
|
||||
).writeAsString(jsonEncode({'daily_kcal_goal': 1600}));
|
||||
|
||||
await AppSettingsService.initForTesting(tempDir);
|
||||
|
||||
expect(AppSettingsService.dailyKcalGoalUpdatedAt, isNull);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'dailyKcalGoalUpdatedAt stays null when the field is not a string',
|
||||
() async {
|
||||
await File('${tempDir.path}/app_settings.json').writeAsString(
|
||||
jsonEncode({
|
||||
'daily_kcal_goal': 1600,
|
||||
'daily_kcal_goal_updated_at': 12345,
|
||||
}),
|
||||
);
|
||||
|
||||
await AppSettingsService.initForTesting(tempDir);
|
||||
|
||||
expect(AppSettingsService.dailyKcalGoalUpdatedAt, isNull);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:diet_guard_app/services/app_settings_service.dart';
|
||||
import 'package:diet_guard_app/services/background_sync_service.dart';
|
||||
import 'package:diet_guard_app/services/foodbank_service.dart';
|
||||
import 'package:diet_guard_app/services/log_storage_service.dart';
|
||||
@ -23,11 +24,16 @@ void main() {
|
||||
tempDir = await Directory.systemTemp.createTemp('diet_guard_bg_sync_');
|
||||
LogStorageService.resetForTesting(testDir: tempDir);
|
||||
FoodBankService.resetForTesting(testDir: tempDir);
|
||||
// Pre-seeds the singleton so backgroundSyncPush's own AppSettingsService
|
||||
// .init() call short-circuits instead of hitting the real (unmocked in
|
||||
// this test) path_provider channel.
|
||||
AppSettingsService.resetForTesting(testDir: tempDir);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
LogStorageService.resetForTesting();
|
||||
FoodBankService.resetForTesting();
|
||||
AppSettingsService.resetForTesting();
|
||||
await tempDir.delete(recursive: true);
|
||||
});
|
||||
|
||||
@ -68,7 +74,9 @@ void main() {
|
||||
final ok = await backgroundSyncPush(httpClient: mock);
|
||||
|
||||
expect(ok, isTrue);
|
||||
expect(puts, 1);
|
||||
// One food_log.json push, plus one budget.json push (syncLog always
|
||||
// pushes, even the empty merged budget when nothing's been set yet).
|
||||
expect(puts, 2);
|
||||
});
|
||||
|
||||
test('reports failure (retry) when the push errors', () async {
|
||||
|
||||
221
app/test/services/day_status_service_test.dart
Normal file
221
app/test/services/day_status_service_test.dart
Normal file
@ -0,0 +1,221 @@
|
||||
import 'package:diet_guard_app/models/day_status.dart';
|
||||
import 'package:diet_guard_app/models/food_entry.dart';
|
||||
import 'package:diet_guard_app/services/day_status_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,
|
||||
);
|
||||
|
||||
void main() {
|
||||
group('dayTotalKcal', () {
|
||||
test('sums entries for the day', () {
|
||||
final log = {
|
||||
'2026-01-01': [buildEntry(kcal: 100), buildEntry(kcal: 50)],
|
||||
};
|
||||
expect(dayTotalKcal(log, '2026-01-01'), 150);
|
||||
});
|
||||
|
||||
test('missing day is zero', () {
|
||||
expect(dayTotalKcal(<String, List<FoodEntry>>{}, '2026-01-01'), 0);
|
||||
});
|
||||
|
||||
test('excludes tombstoned entries', () {
|
||||
final log = {
|
||||
'2026-01-01': [
|
||||
buildEntry(kcal: 100),
|
||||
buildEntry(kcal: 50, deleted: true),
|
||||
],
|
||||
};
|
||||
expect(dayTotalKcal(log, '2026-01-01'), 100);
|
||||
});
|
||||
});
|
||||
|
||||
group('dayStatus', () {
|
||||
test('missing day is notLogged', () {
|
||||
final log = <String, List<FoodEntry>>{};
|
||||
expect(dayStatus(log, '2026-01-01', 2000), DayStatus.notLogged);
|
||||
});
|
||||
|
||||
test('a day with only tombstoned entries is notLogged, not green', () {
|
||||
final log = {
|
||||
'2026-01-01': [buildEntry(kcal: 100, deleted: true)],
|
||||
};
|
||||
expect(dayStatus(log, '2026-01-01', 2000), DayStatus.notLogged);
|
||||
});
|
||||
|
||||
test('exactly at budget is green', () {
|
||||
final log = {
|
||||
'2026-01-01': [buildEntry(kcal: 2000)],
|
||||
};
|
||||
expect(dayStatus(log, '2026-01-01', 2000), DayStatus.green);
|
||||
});
|
||||
|
||||
test('under budget is green', () {
|
||||
final log = {
|
||||
'2026-01-01': [buildEntry(kcal: 1000)],
|
||||
};
|
||||
expect(dayStatus(log, '2026-01-01', 2000), DayStatus.green);
|
||||
});
|
||||
|
||||
test('just over budget is yellow', () {
|
||||
final log = {
|
||||
'2026-01-01': [buildEntry(kcal: 2001)],
|
||||
};
|
||||
expect(dayStatus(log, '2026-01-01', 2000), DayStatus.yellow);
|
||||
});
|
||||
|
||||
test('exactly at yellow ceiling is yellow', () {
|
||||
final log = {
|
||||
'2026-01-01': [buildEntry(kcal: 2400)],
|
||||
};
|
||||
expect(dayStatus(log, '2026-01-01', 2000), DayStatus.yellow);
|
||||
});
|
||||
|
||||
test('just over yellow ceiling is red', () {
|
||||
final log = {
|
||||
'2026-01-01': [buildEntry(kcal: 2400.01)],
|
||||
};
|
||||
expect(dayStatus(log, '2026-01-01', 2000), DayStatus.red);
|
||||
});
|
||||
|
||||
test('way over budget is red', () {
|
||||
final log = {
|
||||
'2026-01-01': [buildEntry(kcal: 5000)],
|
||||
};
|
||||
expect(dayStatus(log, '2026-01-01', 2000), DayStatus.red);
|
||||
});
|
||||
});
|
||||
|
||||
group('statusMap', () {
|
||||
test('maps every present day', () {
|
||||
final log = {
|
||||
'2026-01-01': [buildEntry(kcal: 1000)],
|
||||
'2026-01-02': [buildEntry(kcal: 5000)],
|
||||
};
|
||||
expect(statusMap(log, budget: 2000), {
|
||||
'2026-01-01': DayStatus.green,
|
||||
'2026-01-02': DayStatus.red,
|
||||
});
|
||||
});
|
||||
|
||||
test('empty log is empty map', () {
|
||||
final log = <String, List<FoodEntry>>{};
|
||||
expect(statusMap(log, budget: 2000), <String, DayStatus>{});
|
||||
});
|
||||
});
|
||||
|
||||
group('loggingStreak', () {
|
||||
test('empty map is zero', () {
|
||||
expect(loggingStreak({}, today: DateTime(2026, 1, 5)), 0);
|
||||
});
|
||||
|
||||
test('counts consecutive logged days including today', () {
|
||||
final sm = {
|
||||
'2026-01-03': DayStatus.green,
|
||||
'2026-01-04': DayStatus.red,
|
||||
'2026-01-05': DayStatus.yellow,
|
||||
};
|
||||
expect(loggingStreak(sm, today: DateTime(2026, 1, 5)), 3);
|
||||
});
|
||||
|
||||
test('breaks on a gap', () {
|
||||
final sm = {
|
||||
'2026-01-01': DayStatus.green,
|
||||
'2026-01-03': DayStatus.green,
|
||||
'2026-01-04': DayStatus.green,
|
||||
};
|
||||
expect(loggingStreak(sm, today: DateTime(2026, 1, 4)), 2);
|
||||
});
|
||||
|
||||
test('today not logged is not a break', () {
|
||||
final sm = {
|
||||
'2026-01-03': DayStatus.green,
|
||||
'2026-01-04': DayStatus.yellow,
|
||||
};
|
||||
expect(loggingStreak(sm, today: DateTime(2026, 1, 5)), 2);
|
||||
});
|
||||
|
||||
test('yesterday not logged breaks streak even if today is', () {
|
||||
final sm = {
|
||||
'2026-01-03': DayStatus.green,
|
||||
'2026-01-05': DayStatus.green,
|
||||
};
|
||||
expect(loggingStreak(sm, today: DateTime(2026, 1, 5)), 1);
|
||||
});
|
||||
});
|
||||
|
||||
group('adherenceStreak', () {
|
||||
test('counts consecutive green and yellow', () {
|
||||
final sm = {
|
||||
'2026-01-03': DayStatus.green,
|
||||
'2026-01-04': DayStatus.yellow,
|
||||
'2026-01-05': DayStatus.green,
|
||||
};
|
||||
expect(adherenceStreak(sm, today: DateTime(2026, 1, 5)), 3);
|
||||
});
|
||||
|
||||
test('red today breaks the streak immediately', () {
|
||||
final sm = {
|
||||
'2026-01-04': DayStatus.green,
|
||||
'2026-01-05': DayStatus.red,
|
||||
};
|
||||
expect(adherenceStreak(sm, today: DateTime(2026, 1, 5)), 0);
|
||||
});
|
||||
|
||||
test('not-logged today is not a break', () {
|
||||
final sm = {'2026-01-04': DayStatus.green};
|
||||
expect(adherenceStreak(sm, today: DateTime(2026, 1, 5)), 1);
|
||||
});
|
||||
|
||||
test('red in history breaks the streak', () {
|
||||
final sm = {
|
||||
'2026-01-03': DayStatus.green,
|
||||
'2026-01-04': DayStatus.red,
|
||||
'2026-01-05': DayStatus.green,
|
||||
};
|
||||
expect(adherenceStreak(sm, today: DateTime(2026, 1, 5)), 1);
|
||||
});
|
||||
});
|
||||
|
||||
group('yearToDateTally', () {
|
||||
test('counts logged and adherent days this year only', () {
|
||||
final sm = {
|
||||
'2026-01-01': DayStatus.green,
|
||||
'2026-01-02': DayStatus.red,
|
||||
'2026-01-03': DayStatus.notLogged,
|
||||
'2025-12-31': DayStatus.green,
|
||||
};
|
||||
final tally = yearToDateTally(sm, today: DateTime(2026, 1, 3));
|
||||
expect(tally.loggedDays, 2);
|
||||
expect(tally.elapsedDays, 3);
|
||||
expect(tally.adherentDays, 1);
|
||||
});
|
||||
|
||||
test('future day in map is excluded', () {
|
||||
final sm = {
|
||||
'2026-01-01': DayStatus.green,
|
||||
'2026-06-01': DayStatus.green,
|
||||
};
|
||||
final tally = yearToDateTally(sm, today: DateTime(2026));
|
||||
expect(tally.loggedDays, 1);
|
||||
expect(tally.elapsedDays, 1);
|
||||
expect(tally.adherentDays, 1);
|
||||
});
|
||||
|
||||
test('empty map', () {
|
||||
final tally = yearToDateTally({}, today: DateTime(2026, 3));
|
||||
expect(tally.loggedDays, 0);
|
||||
expect(tally.elapsedDays, 60);
|
||||
expect(tally.adherentDays, 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -311,4 +311,76 @@ void main() {
|
||||
expect(() => parseRemoteLog('not json{{{'), throwsFormatException);
|
||||
});
|
||||
});
|
||||
|
||||
group('budget adapters', () {
|
||||
Map<String, dynamic> budgetRecord({
|
||||
int b = 2000,
|
||||
String t = '2026-06-22T08:00:00',
|
||||
double? w,
|
||||
}) => {'v': 2, 'b': b, 't': t, if (w != null) 'w': w};
|
||||
|
||||
group('budgetHlc', () {
|
||||
test('same record always yields the same Hlc', () {
|
||||
final record = budgetRecord();
|
||||
expect(budgetHlc(record), budgetHlc({...record}));
|
||||
});
|
||||
|
||||
test('malformed t still yields a valid Hlc', () {
|
||||
final record = budgetRecord(t: 'not-a-timestamp');
|
||||
expect(budgetHlc(record).wallTimeMs, 0);
|
||||
});
|
||||
|
||||
test('a later t yields a greater Hlc', () {
|
||||
final earlier = budgetRecord(t: '2020-01-01T00:00:00Z');
|
||||
final later = budgetRecord(t: '2030-01-01T00:00:00Z');
|
||||
expect(budgetHlc(later) > budgetHlc(earlier), isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('budgetToLog / logToBudget round trip', () {
|
||||
test('null record yields an empty log', () {
|
||||
expect(budgetToLog(null), isEmpty);
|
||||
});
|
||||
|
||||
test('round trip preserves the budget and weight', () {
|
||||
final record = budgetRecord(w: 80);
|
||||
final roundTripped = logToBudget(budgetToLog(record));
|
||||
expect(roundTripped, isNotNull);
|
||||
expect(roundTripped!['b'], 2000);
|
||||
expect(roundTripped['w'], 80);
|
||||
});
|
||||
|
||||
test('round-tripped t reflects the winning Hlc', () {
|
||||
final record = budgetRecord();
|
||||
final roundTripped = logToBudget(budgetToLog(record));
|
||||
expect(roundTripped, isNotNull);
|
||||
expect(roundTripped!['t'], isNotEmpty);
|
||||
});
|
||||
|
||||
test('empty log has no budget', () {
|
||||
expect(logToBudget({}), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('parseRemoteBudget', () {
|
||||
test('parses pushed budget wire content', () {
|
||||
final record = budgetRecord();
|
||||
final pushed = encodeBudgetForPush(budgetToLog(record));
|
||||
final log = parseRemoteBudget(pushed);
|
||||
expect(log[budgetRecordId]!.id, budgetRecordId);
|
||||
});
|
||||
|
||||
test('empty object parses as empty log', () {
|
||||
expect(parseRemoteBudget('{}'), isEmpty);
|
||||
});
|
||||
|
||||
test('non-object top level throws FormatException', () {
|
||||
expect(() => parseRemoteBudget('[1, 2, 3]'), throwsFormatException);
|
||||
});
|
||||
|
||||
test('invalid JSON throws FormatException', () {
|
||||
expect(() => parseRemoteBudget('not json{{{'), throwsFormatException);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
// Mirrors `test_sync.py`'s `TestRunSync` cases (own-id-skip, no-prior-push,
|
||||
// non-object payload, corrupt JSON, remote merge, food bank rebuild), plus
|
||||
// one Dart-specific case for the phone's `imagePath`-preserve-by-id step
|
||||
// (plan decision 10) that has no PC-side equivalent.
|
||||
// Mirrors `test_sync.py`'s `TestRunSync` and `TestSyncBudget` cases
|
||||
// (own-id-skip, no-prior-push, non-object payload, corrupt JSON, remote
|
||||
// merge, food bank rebuild, budget last-writer-wins), plus one Dart-specific
|
||||
// case for the phone's `imagePath`-preserve-by-id step (plan decision 10)
|
||||
// that has no PC-side equivalent.
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
@ -9,13 +10,24 @@ import 'dart:io';
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
import 'package:diet_guard_app/models/food_entry.dart';
|
||||
import 'package:diet_guard_app/models/nutrition.dart';
|
||||
import 'package:diet_guard_app/services/app_settings_service.dart';
|
||||
import 'package:diet_guard_app/services/foodbank_service.dart';
|
||||
import 'package:diet_guard_app/services/log_storage_service.dart';
|
||||
import 'package:diet_guard_app/services/sync_merge.dart';
|
||||
import 'package:diet_guard_app/services/sync_service.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
|
||||
/// Builds the wire text a remote device would push for a given budget edit.
|
||||
String _remoteBudgetJson({required int kcal, required String t}) {
|
||||
final record = <String, dynamic>{'v': 2, 'b': kcal, 't': t};
|
||||
final log = budgetToLog(record);
|
||||
return jsonEncode({
|
||||
for (final entry in log.entries) entry.key: entry.value.toJson(),
|
||||
});
|
||||
}
|
||||
|
||||
const _manual = Nutrition(
|
||||
kcal: 200,
|
||||
proteinG: 10,
|
||||
@ -44,6 +56,11 @@ class _FakeGitHub {
|
||||
/// Every PUT this fake received, decoded.
|
||||
final List<Map<String, dynamic>> puts = [];
|
||||
|
||||
/// Same PUTs, keyed by the repo-relative path they targeted -- lets a
|
||||
/// test pick out the food-log push from the budget push, now that a
|
||||
/// sync tick does both.
|
||||
final Map<String, Map<String, dynamic>> putsByPath = {};
|
||||
|
||||
GitHubClient buildClient() => GitHubClient(
|
||||
owner: 'o',
|
||||
repo: 'r',
|
||||
@ -60,7 +77,9 @@ class _FakeGitHub {
|
||||
}
|
||||
final path = req.url.path.replaceFirst('/repos/o/r/contents/', '');
|
||||
if (req.method == 'PUT') {
|
||||
puts.add(jsonDecode(req.body) as Map<String, dynamic>);
|
||||
final body = jsonDecode(req.body) as Map<String, dynamic>;
|
||||
puts.add(body);
|
||||
putsByPath[path] = body;
|
||||
return http.Response('{}', 200);
|
||||
}
|
||||
if (path == 'diet-guard-sync/devices') {
|
||||
@ -104,11 +123,13 @@ void main() {
|
||||
tempDir = await Directory.systemTemp.createTemp('diet_guard_sync_test_');
|
||||
LogStorageService.resetForTesting(testDir: tempDir);
|
||||
FoodBankService.resetForTesting(testDir: tempDir);
|
||||
await AppSettingsService.initForTesting(tempDir);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
LogStorageService.resetForTesting();
|
||||
FoodBankService.resetForTesting();
|
||||
AppSettingsService.resetForTesting();
|
||||
await tempDir.delete(recursive: true);
|
||||
});
|
||||
|
||||
@ -118,16 +139,26 @@ void main() {
|
||||
final merged = await runSync(fake.buildClient());
|
||||
|
||||
expect(merged.values.expand((e) => e).length, 1);
|
||||
expect(fake.puts, hasLength(1));
|
||||
// One food_log.json push, plus one budget.json push (syncLog always
|
||||
// pushes, even an empty merged budget when nothing's been set yet).
|
||||
expect(fake.puts, hasLength(2));
|
||||
});
|
||||
|
||||
test("skips its own device id ('phone') when listing", () async {
|
||||
final fake = _FakeGitHub(
|
||||
deviceDirs: const ['pc', 'phone'],
|
||||
files: const {'diet-guard-sync/devices/pc/food_log.json': '{}'},
|
||||
files: const {
|
||||
'diet-guard-sync/devices/pc/food_log.json': '{}',
|
||||
'diet-guard-sync/devices/pc/budget.json': '{}',
|
||||
},
|
||||
);
|
||||
await runSync(fake.buildClient());
|
||||
expect(fake.fileGets, ['diet-guard-sync/devices/pc/food_log.json']);
|
||||
// Both the food-log and budget pulls skip "phone" (this device) and
|
||||
// only ever read "pc"'s files.
|
||||
expect(fake.fileGets, [
|
||||
'diet-guard-sync/devices/pc/food_log.json',
|
||||
'diet-guard-sync/devices/pc/budget.json',
|
||||
]);
|
||||
});
|
||||
|
||||
test('skips a device with no pushed file yet', () async {
|
||||
@ -237,7 +268,8 @@ void main() {
|
||||
final fake = _FakeGitHub();
|
||||
await runSync(fake.buildClient());
|
||||
|
||||
final pushed = fake.puts.single;
|
||||
final pushed =
|
||||
fake.putsByPath['diet-guard-sync/devices/phone/food_log.json']!;
|
||||
final pushedText = utf8.decode(base64.decode(pushed['content'] as String));
|
||||
expect(pushedText, isNot(contains('imagePath')));
|
||||
expect(pushedText, isNot(contains('hmac')));
|
||||
@ -248,7 +280,8 @@ void main() {
|
||||
final fake = _FakeGitHub();
|
||||
await runSync(fake.buildClient());
|
||||
|
||||
final pushed = fake.puts.single;
|
||||
final pushed =
|
||||
fake.putsByPath['diet-guard-sync/devices/phone/food_log.json']!;
|
||||
final pushedText = utf8.decode(base64.decode(pushed['content'] as String));
|
||||
final decoded = jsonDecode(pushedText) as Map<String, dynamic>;
|
||||
final record = decoded.values.single as Map<String, dynamic>;
|
||||
@ -261,7 +294,10 @@ void main() {
|
||||
files: const {'diet-guard-sync/devices/phone/food_log.json': '{}'},
|
||||
);
|
||||
await runSync(fake.buildClient());
|
||||
expect(fake.puts.single['sha'], 'f-phone');
|
||||
expect(
|
||||
fake.putsByPath['diet-guard-sync/devices/phone/food_log.json']!['sha'],
|
||||
'f-phone',
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
@ -310,4 +346,91 @@ void main() {
|
||||
expect(entry.imagePath, '/local/photo.jpg');
|
||||
},
|
||||
);
|
||||
|
||||
group('budget sync', () {
|
||||
test(
|
||||
'pushes the local budget when no other devices have synced',
|
||||
() async {
|
||||
await AppSettingsService.instance.saveDailyKcalGoal(2000);
|
||||
final fake = _FakeGitHub();
|
||||
await runSync(fake.buildClient());
|
||||
|
||||
expect(
|
||||
fake.putsByPath.containsKey(
|
||||
'diet-guard-sync/devices/phone/budget.json',
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('remote-only budget is adopted locally', () async {
|
||||
final remoteJson = _remoteBudgetJson(
|
||||
kcal: 1800,
|
||||
t: '2026-01-01T09:00:00',
|
||||
);
|
||||
final fake = _FakeGitHub(
|
||||
deviceDirs: const ['pc'],
|
||||
files: {'diet-guard-sync/devices/pc/budget.json': remoteJson},
|
||||
);
|
||||
await runSync(fake.buildClient());
|
||||
expect(AppSettingsService.dailyKcalGoal, 1800);
|
||||
});
|
||||
|
||||
test('a local edit later than a remote edit wins', () async {
|
||||
await AppSettingsService.instance.saveDailyKcalGoal(1500); // now
|
||||
final remoteJson = _remoteBudgetJson(
|
||||
kcal: 1800,
|
||||
t: '2020-01-01T09:00:00',
|
||||
);
|
||||
final fake = _FakeGitHub(
|
||||
deviceDirs: const ['pc'],
|
||||
files: {'diet-guard-sync/devices/pc/budget.json': remoteJson},
|
||||
);
|
||||
await runSync(fake.buildClient());
|
||||
expect(AppSettingsService.dailyKcalGoal, 1500);
|
||||
});
|
||||
|
||||
test('a remote edit later than a local edit wins', () async {
|
||||
await AppSettingsService.instance.saveDailyKcalGoal(1500); // now
|
||||
final remoteJson = _remoteBudgetJson(
|
||||
kcal: 1800,
|
||||
t: '2999-01-01T09:00:00',
|
||||
);
|
||||
final fake = _FakeGitHub(
|
||||
deviceDirs: const ['pc'],
|
||||
files: {'diet-guard-sync/devices/pc/budget.json': remoteJson},
|
||||
);
|
||||
await runSync(fake.buildClient());
|
||||
expect(AppSettingsService.dailyKcalGoal, 1800);
|
||||
});
|
||||
|
||||
test('a malformed remote budget is skipped, not a crash', () async {
|
||||
await AppSettingsService.instance.saveDailyKcalGoal(2000);
|
||||
final fake = _FakeGitHub(
|
||||
deviceDirs: const ['pc'],
|
||||
files: const {
|
||||
'diet-guard-sync/devices/pc/budget.json': '{not valid json',
|
||||
},
|
||||
);
|
||||
await runSync(fake.buildClient());
|
||||
expect(AppSettingsService.dailyKcalGoal, 2000);
|
||||
});
|
||||
|
||||
test(
|
||||
'a fresh install with no budget ever set contributes nothing',
|
||||
() async {
|
||||
final fake = _FakeGitHub();
|
||||
await runSync(fake.buildClient());
|
||||
|
||||
final pushed =
|
||||
fake.putsByPath['diet-guard-sync/devices/phone/budget.json']!;
|
||||
final pushedText = utf8.decode(
|
||||
base64.decode(pushed['content'] as String),
|
||||
);
|
||||
expect(jsonDecode(pushedText), isEmpty);
|
||||
expect(AppSettingsService.dailyKcalGoal, 2200);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
242
app/test/widgets/day_status_calendar_test.dart
Normal file
242
app/test/widgets/day_status_calendar_test.dart
Normal file
@ -0,0 +1,242 @@
|
||||
import 'package:diet_guard_app/models/day_status.dart';
|
||||
import 'package:diet_guard_app/widgets/day_status_calendar.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
Widget _wrap(Widget child) => MaterialApp(home: Scaffold(body: child));
|
||||
|
||||
Color _colorOfDay(WidgetTester tester, String day) {
|
||||
final container = tester.widget<Container>(
|
||||
find.ancestor(of: find.text(day), matching: find.byType(Container)).first,
|
||||
);
|
||||
final decoration = container.decoration! as BoxDecoration;
|
||||
return decoration.color!;
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('DayStatusCalendar', () {
|
||||
final june2026 = DateTime(2026, 6);
|
||||
final juneToday = DateTime(2026, 6, 20);
|
||||
|
||||
testWidgets('shows month and year in header', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_wrap(
|
||||
DayStatusCalendar(
|
||||
statusByDate: const {},
|
||||
month: june2026,
|
||||
today: juneToday,
|
||||
onPrevMonth: () {},
|
||||
onNextMonth: () {},
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(find.text('June 2026'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('shows day-of-week headers', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_wrap(
|
||||
DayStatusCalendar(
|
||||
statusByDate: const {},
|
||||
month: june2026,
|
||||
today: juneToday,
|
||||
onPrevMonth: () {},
|
||||
onNextMonth: () {},
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(find.text('Mo'), findsOneWidget);
|
||||
expect(find.text('Su'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('calls onPrevMonth when left arrow tapped', (tester) async {
|
||||
var called = false;
|
||||
await tester.pumpWidget(
|
||||
_wrap(
|
||||
DayStatusCalendar(
|
||||
statusByDate: const {},
|
||||
month: june2026,
|
||||
today: juneToday,
|
||||
onPrevMonth: () => called = true,
|
||||
onNextMonth: () {},
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.tap(find.byIcon(Icons.chevron_left));
|
||||
expect(called, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('calls onNextMonth when right arrow tapped', (tester) async {
|
||||
var called = false;
|
||||
await tester.pumpWidget(
|
||||
_wrap(
|
||||
DayStatusCalendar(
|
||||
statusByDate: const {},
|
||||
month: june2026,
|
||||
today: juneToday,
|
||||
onPrevMonth: () {},
|
||||
onNextMonth: () => called = true,
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.tap(find.byIcon(Icons.chevron_right));
|
||||
expect(called, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('colors a green day', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_wrap(
|
||||
DayStatusCalendar(
|
||||
statusByDate: const {'2026-06-15': DayStatus.green},
|
||||
month: june2026,
|
||||
today: juneToday,
|
||||
onPrevMonth: () {},
|
||||
onNextMonth: () {},
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(_colorOfDay(tester, '15'), Colors.green.shade700);
|
||||
});
|
||||
|
||||
testWidgets('colors a yellow day', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_wrap(
|
||||
DayStatusCalendar(
|
||||
statusByDate: const {'2026-06-15': DayStatus.yellow},
|
||||
month: june2026,
|
||||
today: juneToday,
|
||||
onPrevMonth: () {},
|
||||
onNextMonth: () {},
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(_colorOfDay(tester, '15'), Colors.amber.shade700);
|
||||
});
|
||||
|
||||
testWidgets('colors a red day', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_wrap(
|
||||
DayStatusCalendar(
|
||||
statusByDate: const {'2026-06-15': DayStatus.red},
|
||||
month: june2026,
|
||||
today: juneToday,
|
||||
onPrevMonth: () {},
|
||||
onNextMonth: () {},
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(_colorOfDay(tester, '15'), Colors.red.shade400);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'a past day absent from statusByDate renders black (not logged)',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(
|
||||
_wrap(
|
||||
DayStatusCalendar(
|
||||
statusByDate: const {},
|
||||
month: june2026,
|
||||
today: juneToday,
|
||||
onPrevMonth: () {},
|
||||
onNextMonth: () {},
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(_colorOfDay(tester, '15'), Colors.black);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'a future day renders neutrally, never as a false not-logged cell',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(
|
||||
_wrap(
|
||||
DayStatusCalendar(
|
||||
statusByDate: const {},
|
||||
month: june2026,
|
||||
today: DateTime(2026, 6, 10),
|
||||
onPrevMonth: () {},
|
||||
onNextMonth: () {},
|
||||
),
|
||||
),
|
||||
);
|
||||
// Day 25 is after the reference "today" of June 10.
|
||||
expect(_colorOfDay(tester, '25'), Colors.grey.shade800);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets("today itself is classified, not treated as future", (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
_wrap(
|
||||
DayStatusCalendar(
|
||||
statusByDate: const {'2026-06-20': DayStatus.red},
|
||||
month: june2026,
|
||||
today: juneToday,
|
||||
onPrevMonth: () {},
|
||||
onNextMonth: () {},
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(_colorOfDay(tester, '20'), Colors.red.shade400);
|
||||
});
|
||||
|
||||
testWidgets('renders a month starting on Sunday correctly', (
|
||||
tester,
|
||||
) async {
|
||||
final sep2026 = DateTime(2026, 9);
|
||||
await tester.pumpWidget(
|
||||
_wrap(
|
||||
DayStatusCalendar(
|
||||
statusByDate: const {},
|
||||
month: sep2026,
|
||||
today: DateTime(2026, 9, 1),
|
||||
onPrevMonth: () {},
|
||||
onNextMonth: () {},
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(find.text('September 2026'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('tapping a day calls onDaySelected with that date', (
|
||||
tester,
|
||||
) async {
|
||||
DateTime? selected;
|
||||
await tester.pumpWidget(
|
||||
_wrap(
|
||||
DayStatusCalendar(
|
||||
statusByDate: const {'2026-06-15': DayStatus.green},
|
||||
month: june2026,
|
||||
today: juneToday,
|
||||
onPrevMonth: () {},
|
||||
onNextMonth: () {},
|
||||
onDaySelected: (day) => selected = day,
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.tap(find.text('15'));
|
||||
expect(selected, DateTime(2026, 6, 15));
|
||||
});
|
||||
|
||||
testWidgets('no onDaySelected means day cells are not tappable', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
_wrap(
|
||||
DayStatusCalendar(
|
||||
statusByDate: const {},
|
||||
month: june2026,
|
||||
today: juneToday,
|
||||
onPrevMonth: () {},
|
||||
onNextMonth: () {},
|
||||
),
|
||||
),
|
||||
);
|
||||
// No GestureDetector wraps the day cells when onDaySelected is null;
|
||||
// tapping must not throw.
|
||||
await tester.tap(find.text('15'));
|
||||
});
|
||||
});
|
||||
}
|
||||
64
app/test/widgets/streak_summary_row_test.dart
Normal file
64
app/test/widgets/streak_summary_row_test.dart
Normal file
@ -0,0 +1,64 @@
|
||||
import 'package:diet_guard_app/models/day_status.dart';
|
||||
import 'package:diet_guard_app/widgets/streak_summary_row.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
Widget _wrap(Widget child) => MaterialApp(home: Scaffold(body: child));
|
||||
|
||||
void main() {
|
||||
group('StreakSummaryRow', () {
|
||||
testWidgets('renders plural streak counts', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_wrap(
|
||||
const StreakSummaryRow(
|
||||
loggingStreak: 3,
|
||||
adherenceStreak: 2,
|
||||
tally: YtdTally(loggedDays: 10, elapsedDays: 14, adherentDays: 8),
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(
|
||||
find.text(
|
||||
'Logging streak: 3 days · Adherence streak: 2 days',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.text('This year: logged 10/14 days · 8 within budget'),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('renders singular streak counts', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_wrap(
|
||||
const StreakSummaryRow(
|
||||
loggingStreak: 1,
|
||||
adherenceStreak: 1,
|
||||
tally: YtdTally(loggedDays: 1, elapsedDays: 1, adherentDays: 1),
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(
|
||||
find.text('Logging streak: 1 day · Adherence streak: 1 day'),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('renders zero streaks', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_wrap(
|
||||
const StreakSummaryRow(
|
||||
loggingStreak: 0,
|
||||
adherenceStreak: 0,
|
||||
tally: YtdTally(loggedDays: 0, elapsedDays: 5, adherentDays: 0),
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(
|
||||
find.text('Logging streak: 0 days · Adherence streak: 0 days'),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -1,85 +1,74 @@
|
||||
"""Hidden, tamper-hardened daily calorie budget for diet_guard.
|
||||
"""Freely-editable daily calorie budget for diet_guard.
|
||||
|
||||
This mirrors how ``phone_focus_mode`` hides the home GPS coordinates: the real
|
||||
number never lives in committed source and is never printed. It is computed
|
||||
once from biometrics at ``init`` time, written to a runtime-only file in the
|
||||
XDG data dir (git-ignored, exactly like the phone's ``config_secrets.sh``), and
|
||||
locked with ``chattr +i`` so changing it costs a deliberate ``sudo`` step.
|
||||
The budget is computed once from biometrics at ``init`` time (via the same
|
||||
Mifflin-St Jeor formula as before) and written to a plain JSON file in the
|
||||
XDG data dir, but it is no longer sealed: it can be changed at any time, on
|
||||
this machine or the phone app, with no special ritual. It syncs like the
|
||||
food log (see :mod:`diet_guard._sync`), so the same current value is
|
||||
available on both devices, last-edit-wins.
|
||||
|
||||
Honest threat model (the same one the phone accepts):
|
||||
|
||||
* The point is **friction in a weak moment**, not cryptographic secrecy. The
|
||||
Mifflin-St Jeor formula is right here in source and you know your own weight,
|
||||
so a determined you can always recompute the number -- just as a determined
|
||||
you can root the phone and read the coordinates.
|
||||
* The shared HMAC key at ``/etc/workout-locker/hmac.key`` is world-readable, so
|
||||
the signature defeats a *naive* edit (and detects disk corruption) but not
|
||||
someone who reads the key and re-signs. The signature is tamper-*evidence*,
|
||||
not a tamper-*lock*.
|
||||
* The real lock is ``chattr +i``: removing the immutable bit needs root, which
|
||||
is the actual speed bump. The strongest layer of all is simply that the
|
||||
value is never displayed, so there is no on-screen anchor to fixate on.
|
||||
This is a deliberate design change from the file's previous ``chattr +i``
|
||||
seal, which existed specifically to make impulsive "make room" edits
|
||||
require a deliberate root step. That friction is gone by design; nothing in
|
||||
this module tries to reintroduce it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
from dataclasses import dataclass
|
||||
import hmac
|
||||
from datetime import datetime, timezone
|
||||
import json
|
||||
import logging
|
||||
|
||||
from gatelock.log_integrity import compute_entry_hmac
|
||||
|
||||
from diet_guard._constants import BUDGET_FILE
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# Schema version stored inside the sealed blob, so a future format change can be
|
||||
# detected rather than silently misread. v2 adds the optional body weight (``w``)
|
||||
# used to derive a protein target; v1 seals (budget only) still read correctly.
|
||||
_SEAL_VERSION = 2
|
||||
# Schema version stored in the file, so a future format change can be
|
||||
# detected rather than silently misread. v2 adds the optional body weight
|
||||
# (``w``) used to derive a protein target; v1 files (budget only) still
|
||||
# read correctly.
|
||||
_FILE_VERSION = 2
|
||||
|
||||
# A medically sane lower bound. Even an aggressive deficit must not seal a
|
||||
# starvation-level target, so the computed value is floored here.
|
||||
|
||||
def _now_local() -> datetime:
|
||||
"""Return the current time as a timezone-aware local datetime.
|
||||
|
||||
Duplicated from :func:`diet_guard._state.now_local` rather than
|
||||
imported: ``_state`` imports :func:`daily_budget` from this module, so
|
||||
importing back from ``_state`` here would be circular.
|
||||
"""
|
||||
return datetime.now(tz=timezone.utc).astimezone()
|
||||
|
||||
|
||||
# A medically sane lower bound. Even an aggressive deficit must not compute
|
||||
# a starvation-level target, so the value is floored here.
|
||||
_MIN_SANE_BUDGET = 1200
|
||||
|
||||
# Daily protein target for an active adult holding muscle on a deficit, in grams
|
||||
# per kg of body weight. Used only to show a target in the dashboard; it has no
|
||||
# part in the sealed calorie budget maths.
|
||||
# Daily protein target for an active adult holding muscle on a deficit, in
|
||||
# grams per kg of body weight. Used only to show a target in the dashboard;
|
||||
# it has no part in the calorie budget maths.
|
||||
PROTEIN_G_PER_KG = 1.8
|
||||
|
||||
# Probe payload used only to check whether the shared HMAC key can be loaded.
|
||||
_KEY_PROBE: dict[str, object] = {"_probe": True}
|
||||
|
||||
|
||||
class BudgetError(Exception):
|
||||
"""Base class for all budget-access failures."""
|
||||
|
||||
|
||||
class BudgetNotInitializedError(BudgetError):
|
||||
"""Raised when no sealed budget exists yet (``init`` never run)."""
|
||||
"""Raised when no budget has been set yet (``init`` never run)."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize with a fixed, side-effect-free message."""
|
||||
super().__init__("daily budget has not been initialized")
|
||||
|
||||
|
||||
class BudgetSealBrokenError(BudgetError):
|
||||
"""Raised when the sealed budget is unreadable, corrupt, or tampered."""
|
||||
class BudgetFileCorruptError(BudgetError):
|
||||
"""Raised when the budget file exists but cannot be read or parsed."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize with a fixed, side-effect-free message."""
|
||||
super().__init__("daily budget seal is broken (tampered or corrupted)")
|
||||
|
||||
|
||||
class BudgetLockedError(BudgetError):
|
||||
"""Raised when the budget file is immutable and cannot be rewritten."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize with a fixed, side-effect-free message."""
|
||||
super().__init__("daily budget file is locked (chattr +i)")
|
||||
super().__init__("daily budget file is corrupt")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@ -141,112 +130,45 @@ def compute_target_budget(
|
||||
return max(target, _MIN_SANE_BUDGET)
|
||||
|
||||
|
||||
def _hmac_key_available() -> bool:
|
||||
"""Return True if the shared HMAC key can be loaded for signing."""
|
||||
return compute_entry_hmac(_KEY_PROBE) is not None
|
||||
|
||||
|
||||
def is_initialized() -> bool:
|
||||
"""Return True if a sealed budget file exists on disk."""
|
||||
"""Return True if a budget file exists on disk."""
|
||||
return BUDGET_FILE.exists()
|
||||
|
||||
|
||||
def lock_command() -> str:
|
||||
"""Return the shell command that makes the sealed budget immutable."""
|
||||
return f"sudo chattr +i {BUDGET_FILE}"
|
||||
def write_budget(value: int, *, weight_kg: float | None = None) -> None:
|
||||
"""Write ``value`` as the daily kcal budget, plainly (no seal, no signing).
|
||||
|
||||
|
||||
def unlock_command() -> str:
|
||||
"""Return the shell command that clears the immutable bit before re-init."""
|
||||
return f"sudo chattr -i {BUDGET_FILE}"
|
||||
|
||||
|
||||
def seal_budget(value: int, *, weight_kg: float | None = None) -> None:
|
||||
"""Write ``value`` to the runtime budget file, base64-wrapped and signed.
|
||||
|
||||
The value is JSON-encoded, base64-wrapped (so a casual ``cat`` shows no
|
||||
recognizable number) and HMAC-signed (so a naive edit is detectable). The
|
||||
file is *not* made immutable here -- that needs root; the caller prints
|
||||
:func:`lock_command` for the user (or install.sh) to run.
|
||||
Stamps a ``t`` edit timestamp on every write (unlike a food-log entry,
|
||||
the budget can be edited repeatedly, so :mod:`diet_guard._sync` needs to
|
||||
know *when* this write happened to resolve a last-edit-wins merge
|
||||
against another device's write).
|
||||
|
||||
Args:
|
||||
value: The computed daily budget in kcal.
|
||||
weight_kg: Body weight in kg to store alongside the budget, so a protein
|
||||
target can later be derived. Optional; omitting it seals a
|
||||
budget-only blob that reads back with no protein target.
|
||||
|
||||
Raises:
|
||||
BudgetLockedError: If the existing file is immutable (run
|
||||
:func:`unlock_command` first).
|
||||
value: The daily budget in kcal.
|
||||
weight_kg: Body weight in kg to store alongside the budget, so a
|
||||
protein target can later be derived. Optional; omitting it
|
||||
writes a budget-only record that reads back with no protein
|
||||
target.
|
||||
"""
|
||||
inner: dict[str, object] = {"v": _SEAL_VERSION, "b": int(value)}
|
||||
record: dict[str, object] = {
|
||||
"v": _FILE_VERSION,
|
||||
"b": int(value),
|
||||
"t": _now_local().isoformat(timespec="seconds"),
|
||||
}
|
||||
if weight_kg is not None:
|
||||
inner["w"] = round(float(weight_kg), 1)
|
||||
blob = json.dumps(inner, sort_keys=True, separators=(",", ":")).encode()
|
||||
record: dict[str, object] = {"data": base64.b64encode(blob).decode("ascii")}
|
||||
signature = compute_entry_hmac(inner)
|
||||
if signature is not None:
|
||||
record["hmac"] = signature
|
||||
else:
|
||||
_logger.warning("HMAC key unavailable - sealing budget unsigned")
|
||||
|
||||
BUDGET_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
with BUDGET_FILE.open("w") as handle:
|
||||
json.dump(record, handle)
|
||||
except PermissionError as exc:
|
||||
raise BudgetLockedError from exc
|
||||
record["w"] = round(float(weight_kg), 1)
|
||||
write_raw_record(record)
|
||||
|
||||
|
||||
def _decode_inner(record: object) -> dict[str, object]:
|
||||
"""Return the inner payload dict from a parsed seal record.
|
||||
|
||||
Raises:
|
||||
BudgetSealBrokenError: If the record shape or base64 is invalid.
|
||||
"""
|
||||
if not isinstance(record, dict):
|
||||
raise BudgetSealBrokenError
|
||||
data = record.get("data")
|
||||
if not isinstance(data, str):
|
||||
raise BudgetSealBrokenError
|
||||
try:
|
||||
inner = json.loads(base64.b64decode(data, validate=True))
|
||||
except (binascii.Error, ValueError) as exc:
|
||||
raise BudgetSealBrokenError from exc
|
||||
if not isinstance(inner, dict):
|
||||
raise BudgetSealBrokenError
|
||||
return inner
|
||||
|
||||
|
||||
def _verify_signature(record: dict[str, object], inner: dict[str, object]) -> None:
|
||||
"""Check the seal's HMAC, mirroring the food log's degradation rules.
|
||||
|
||||
A present signature must verify. A missing signature is tolerated only on a
|
||||
system with no key at all; a stripped signature where a key *is* available
|
||||
means someone removed it to cheat.
|
||||
|
||||
Raises:
|
||||
BudgetSealBrokenError: If the signature is missing-but-keyed, or wrong.
|
||||
"""
|
||||
stored = record.get("hmac")
|
||||
if isinstance(stored, str):
|
||||
expected = compute_entry_hmac(inner)
|
||||
if expected is None or not hmac.compare_digest(stored, expected):
|
||||
raise BudgetSealBrokenError
|
||||
return
|
||||
if _hmac_key_available():
|
||||
raise BudgetSealBrokenError
|
||||
|
||||
|
||||
def _load_verified_inner() -> dict[str, object]:
|
||||
"""Read, decode, and integrity-check the sealed blob, returning its payload.
|
||||
def _read_record() -> dict[str, object]:
|
||||
"""Read and parse the budget file.
|
||||
|
||||
Returns:
|
||||
The inner payload dict (carrying ``b`` and, for v2 seals, ``w``).
|
||||
The parsed record dict (carrying ``b`` and, optionally, ``w``).
|
||||
|
||||
Raises:
|
||||
BudgetNotInitializedError: If no budget has been sealed yet.
|
||||
BudgetSealBrokenError: If the file is corrupt, mis-typed, or tampered.
|
||||
BudgetNotInitializedError: If no budget has been set yet.
|
||||
BudgetFileCorruptError: If the file exists but cannot be parsed.
|
||||
"""
|
||||
if not BUDGET_FILE.exists():
|
||||
raise BudgetNotInitializedError
|
||||
@ -254,30 +176,26 @@ def _load_verified_inner() -> dict[str, object]:
|
||||
with BUDGET_FILE.open() as handle:
|
||||
record = json.load(handle)
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise BudgetSealBrokenError from exc
|
||||
inner = _decode_inner(record)
|
||||
# ``record`` is a dict here: _decode_inner rejects any non-dict already.
|
||||
_verify_signature(record, inner)
|
||||
return inner
|
||||
raise BudgetFileCorruptError from exc
|
||||
if not isinstance(record, dict):
|
||||
raise BudgetFileCorruptError
|
||||
return record
|
||||
|
||||
|
||||
def daily_budget() -> int:
|
||||
"""Return the sealed daily budget, verifying integrity first.
|
||||
|
||||
This is the only way callers obtain the number, and they must use it for an
|
||||
internal decision (over/under) without printing it.
|
||||
"""Return the current daily kcal budget.
|
||||
|
||||
Returns:
|
||||
The daily kcal budget.
|
||||
|
||||
Raises:
|
||||
BudgetNotInitializedError: If no budget has been sealed yet.
|
||||
BudgetSealBrokenError: If the file is corrupt, mis-typed, or tampered.
|
||||
BudgetNotInitializedError: If no budget has been set yet.
|
||||
BudgetFileCorruptError: If the file exists but cannot be parsed.
|
||||
"""
|
||||
inner = _load_verified_inner()
|
||||
value = inner.get("b")
|
||||
record = _read_record()
|
||||
value = record.get("b")
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
raise BudgetSealBrokenError
|
||||
raise BudgetFileCorruptError
|
||||
return value
|
||||
|
||||
|
||||
@ -285,14 +203,14 @@ def budget_weight() -> float | None:
|
||||
"""Return the body weight stored with the budget, or None if unavailable.
|
||||
|
||||
Returns:
|
||||
The stored weight in kg, or None for a pre-v2 (budget-only) seal.
|
||||
The stored weight in kg, or None for a pre-v2 (budget-only) record.
|
||||
|
||||
Raises:
|
||||
BudgetNotInitializedError: If no budget has been sealed yet.
|
||||
BudgetSealBrokenError: If the file is corrupt, mis-typed, or tampered.
|
||||
BudgetNotInitializedError: If no budget has been set yet.
|
||||
BudgetFileCorruptError: If the file exists but cannot be parsed.
|
||||
"""
|
||||
inner = _load_verified_inner()
|
||||
value = inner.get("w")
|
||||
record = _read_record()
|
||||
value = record.get("w")
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
return None
|
||||
return float(value)
|
||||
@ -302,9 +220,9 @@ def protein_target_g() -> float | None:
|
||||
"""Return the daily protein target in grams, or None if it cannot be derived.
|
||||
|
||||
Derived from the stored body weight at :data:`PROTEIN_G_PER_KG`. Returns
|
||||
None -- rather than raising -- whenever the target is simply unavailable (no
|
||||
budget sealed, a pre-v2 seal without weight, or a broken seal), so the
|
||||
dashboard can show calories and quietly omit the protein line.
|
||||
None -- rather than raising -- whenever the target is simply unavailable
|
||||
(no budget set, a pre-v2 record without weight, or a corrupt file), so
|
||||
the dashboard can show calories and quietly omit the protein line.
|
||||
|
||||
Returns:
|
||||
The protein target in grams, or None when weight is unknown.
|
||||
@ -316,3 +234,35 @@ def protein_target_g() -> float | None:
|
||||
if weight is None:
|
||||
return None
|
||||
return round(weight * PROTEIN_G_PER_KG, 1)
|
||||
|
||||
|
||||
def read_raw_record() -> dict[str, object] | None:
|
||||
"""Return the on-disk budget record verbatim, or None if unset/corrupt.
|
||||
|
||||
Public, sync-only counterpart to :func:`_read_record`:
|
||||
:mod:`diet_guard._sync` must treat "not yet set" and "unreadable" alike
|
||||
as "nothing of this device's to contribute to the merge" rather than an
|
||||
error, so unlike :func:`daily_budget` this never raises.
|
||||
"""
|
||||
if not BUDGET_FILE.exists():
|
||||
return None
|
||||
try:
|
||||
with BUDGET_FILE.open() as handle:
|
||||
record = json.load(handle)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
if not isinstance(record, dict):
|
||||
return None
|
||||
return record
|
||||
|
||||
|
||||
def write_raw_record(record: dict[str, object]) -> None:
|
||||
"""Persist ``record`` verbatim, overwriting the file on disk.
|
||||
|
||||
Public counterpart to :func:`read_raw_record`, used by both
|
||||
:func:`write_budget` and :mod:`diet_guard._sync` (to write back a
|
||||
merged record, carrying the winning side's ``t`` edit timestamp).
|
||||
"""
|
||||
BUDGET_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
with BUDGET_FILE.open("w") as handle:
|
||||
json.dump(record, handle)
|
||||
|
||||
151
diet_guard/_calendar_view.py
Normal file
151
diet_guard/_calendar_view.py
Normal file
@ -0,0 +1,151 @@
|
||||
"""Pure month-grid math and status text for the diet_guard History tab.
|
||||
|
||||
Split out of :mod:`._gatelock_calendar` so the calendar math -- month-grid
|
||||
construction, per-cell styling, and the streak/YTD summary lines -- carries no
|
||||
Tk widget dependency and is unit-tested directly, with no fake Tk in the
|
||||
loop. Also keeps :mod:`._gatelock_calendar` (the Tk-facing half) under the
|
||||
repo's 500-line limit.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import calendar
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
|
||||
from diet_guard._daystatus import (
|
||||
DayStatus,
|
||||
adherence_streak,
|
||||
logging_streak,
|
||||
year_to_date_tally,
|
||||
)
|
||||
from diet_guard._gatelock_ui import BG, FG
|
||||
from diet_guard._state import now_local
|
||||
|
||||
_NOT_LOGGED_FILL = "#000000"
|
||||
_NOT_LOGGED_OUTLINE = "#e0e0e0"
|
||||
_STATUS_COLORS = {
|
||||
DayStatus.GREEN: "#33cc66",
|
||||
DayStatus.YELLOW: "#e0c33c",
|
||||
DayStatus.RED: "#ff5555",
|
||||
}
|
||||
_STATUS_TEXT_COLOR = "#003322"
|
||||
_MUTED = "#9a9a9a"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CalendarCell:
|
||||
"""One day cell in a rendered month grid.
|
||||
|
||||
``day`` is None for a blank cell outside the displayed month. ``status``
|
||||
is None for a blank cell, a future day (nothing to judge yet), or when no
|
||||
budget is set (nothing to color by) -- callers render all three the same
|
||||
neutral way, never as a false "not logged" black cell.
|
||||
"""
|
||||
|
||||
day: int | None
|
||||
status: DayStatus | None
|
||||
|
||||
|
||||
def _cell_status(
|
||||
day: date,
|
||||
status_map_: dict[str, DayStatus] | None,
|
||||
today: date,
|
||||
) -> DayStatus | None:
|
||||
"""Return the status to render for ``day``, or None for nothing to show.
|
||||
|
||||
A day after ``today`` has not happened yet, and ``status_map_`` being
|
||||
None means no budget is set to judge by -- both render neutrally rather
|
||||
than as a false "not logged" black cell.
|
||||
"""
|
||||
if day > today or status_map_ is None:
|
||||
return None
|
||||
return status_map_.get(day.isoformat(), DayStatus.NOT_LOGGED)
|
||||
|
||||
|
||||
def build_month_cells(
|
||||
year: int,
|
||||
month: int,
|
||||
status_map_: dict[str, DayStatus] | None,
|
||||
*,
|
||||
today: date | None = None,
|
||||
) -> list[list[CalendarCell]]:
|
||||
"""Return the month's weeks as a grid of :class:`CalendarCell`.
|
||||
|
||||
Mirrors :func:`calendar.monthcalendar`'s shape (a list of weeks, each
|
||||
seven day-of-month ints with 0 for days outside the month), translated
|
||||
into cells carrying the status to render.
|
||||
|
||||
Args:
|
||||
year: The displayed year.
|
||||
month: The displayed month (1-12).
|
||||
status_map_: Per-day statuses as returned by
|
||||
:func:`diet_guard._daystatus.status_map`, or None if no budget is
|
||||
set to classify days by.
|
||||
today: The reference "today"; defaults to the real current date.
|
||||
|
||||
Returns:
|
||||
A list of weeks, each a list of seven :class:`CalendarCell`.
|
||||
"""
|
||||
ref = today if today is not None else now_local().date()
|
||||
grid: list[list[CalendarCell]] = []
|
||||
for week in calendar.monthcalendar(year, month):
|
||||
row: list[CalendarCell] = []
|
||||
for day_num in week:
|
||||
if day_num == 0:
|
||||
row.append(CalendarCell(day=None, status=None))
|
||||
continue
|
||||
row.append(
|
||||
CalendarCell(
|
||||
day=day_num,
|
||||
status=_cell_status(date(year, month, day_num), status_map_, ref),
|
||||
),
|
||||
)
|
||||
grid.append(row)
|
||||
return grid
|
||||
|
||||
|
||||
def cell_style(status: DayStatus | None) -> tuple[str, str, str]:
|
||||
"""Return ``(bg, fg, highlightbackground)`` for a cell's status."""
|
||||
if status is None:
|
||||
return BG, _MUTED, BG
|
||||
if status is DayStatus.NOT_LOGGED:
|
||||
return _NOT_LOGGED_FILL, FG, _NOT_LOGGED_OUTLINE
|
||||
color = _STATUS_COLORS[status]
|
||||
return color, _STATUS_TEXT_COLOR, color
|
||||
|
||||
|
||||
def _plural(count: int) -> str:
|
||||
"""Return "day" or "days" for ``count``."""
|
||||
return "day" if count == 1 else "days"
|
||||
|
||||
|
||||
def streaks_text(
|
||||
status_map_: dict[str, DayStatus],
|
||||
*,
|
||||
today: date | None = None,
|
||||
) -> str:
|
||||
"""Render the logging and adherence streak line.
|
||||
|
||||
``today`` defaults to the real current date for live UI use; tests pass
|
||||
it explicitly for deterministic streak boundaries.
|
||||
"""
|
||||
logging = logging_streak(status_map_, today=today)
|
||||
adherence = adherence_streak(status_map_, today=today)
|
||||
return (
|
||||
f"Logging streak: {logging} {_plural(logging)} · "
|
||||
f"Adherence streak: {adherence} {_plural(adherence)}"
|
||||
)
|
||||
|
||||
|
||||
def ytd_text(status_map_: dict[str, DayStatus], *, today: date | None = None) -> str:
|
||||
"""Render the year-to-date logged/elapsed/adherent tally line.
|
||||
|
||||
``today`` defaults to the real current date for live UI use; tests pass
|
||||
it explicitly for a deterministic elapsed-day count.
|
||||
"""
|
||||
tally = year_to_date_tally(status_map_, today=today)
|
||||
return (
|
||||
f"This year: logged {tally.logged_days}/{tally.elapsed_days} days · "
|
||||
f"{tally.adherent_days} within budget"
|
||||
)
|
||||
@ -21,15 +21,12 @@ import sys
|
||||
|
||||
from diet_guard._budget import (
|
||||
Biometrics,
|
||||
BudgetLockedError,
|
||||
BudgetFileCorruptError,
|
||||
BudgetNotInitializedError,
|
||||
BudgetSealBrokenError,
|
||||
compute_target_budget,
|
||||
daily_budget,
|
||||
lock_command,
|
||||
protein_target_g,
|
||||
seal_budget,
|
||||
unlock_command,
|
||||
write_budget,
|
||||
)
|
||||
from diet_guard._cli_gate import cmd_gate
|
||||
from diet_guard._cli_sync import cmd_sync, register_sync_subparser
|
||||
@ -124,7 +121,7 @@ def _parse_args(argv: list[str]) -> argparse.Namespace:
|
||||
|
||||
sub.add_parser(
|
||||
"init",
|
||||
help="Compute your daily budget from biometrics and seal it (hidden).",
|
||||
help="Compute your starting daily budget from biometrics.",
|
||||
)
|
||||
|
||||
ate = sub.add_parser("ate", help="Log a meal you just ate.")
|
||||
@ -210,8 +207,8 @@ def _print_summary() -> None:
|
||||
f"today: {total:g} kcal (budget not set - run: python -m diet_guard init)",
|
||||
)
|
||||
return
|
||||
except BudgetSealBrokenError:
|
||||
_emit(f"today: {total:g} kcal (budget seal broken - re-run init)")
|
||||
except BudgetFileCorruptError:
|
||||
_emit(f"today: {total:g} kcal (budget file corrupt - re-run init)")
|
||||
return
|
||||
remaining = round(budget - total, 1)
|
||||
_emit(f"today: {total:g} kcal - {remaining:g} kcal left of {budget:g}")
|
||||
@ -232,7 +229,7 @@ def _read_init_inputs() -> tuple[Biometrics, float, float] | None:
|
||||
"""Prompt for biometrics on stdin; return (bio, activity, deficit) or None.
|
||||
|
||||
Returns None (after printing why) on any unparsable or out-of-range input,
|
||||
so a typo never seals a wrong budget.
|
||||
so a typo never sets a wrong budget.
|
||||
"""
|
||||
try:
|
||||
weight = float(_ask("weight in kg:"))
|
||||
@ -247,7 +244,7 @@ def _read_init_inputs() -> tuple[Biometrics, float, float] | None:
|
||||
)
|
||||
deficit = float(_ask("daily deficit in kcal (e.g. 200):"))
|
||||
except ValueError:
|
||||
_emit("that was not a number; nothing was sealed.")
|
||||
_emit("that was not a number; nothing was set.")
|
||||
return None
|
||||
|
||||
if sex_raw in _MALE_ANSWERS:
|
||||
@ -255,7 +252,7 @@ def _read_init_inputs() -> tuple[Biometrics, float, float] | None:
|
||||
elif sex_raw in _FEMALE_ANSWERS:
|
||||
is_male = False
|
||||
else:
|
||||
_emit('sex must be "m" or "f"; nothing was sealed.')
|
||||
_emit('sex must be "m" or "f"; nothing was set.')
|
||||
return None
|
||||
|
||||
bio = Biometrics(
|
||||
@ -268,7 +265,7 @@ def _read_init_inputs() -> tuple[Biometrics, float, float] | None:
|
||||
|
||||
|
||||
def _cmd_init() -> int:
|
||||
"""Compute the budget from biometrics and seal it, printing no number."""
|
||||
"""Compute the starting budget from biometrics and write it."""
|
||||
inputs = _read_init_inputs()
|
||||
if inputs is None:
|
||||
return 2
|
||||
@ -278,15 +275,9 @@ def _cmd_init() -> int:
|
||||
activity_factor=activity,
|
||||
deficit_kcal=deficit,
|
||||
)
|
||||
try:
|
||||
seal_budget(budget, weight_kg=bio.weight_kg)
|
||||
except BudgetLockedError:
|
||||
_emit("the budget is locked; unlock it first, then re-run init:")
|
||||
_emit(f" {unlock_command()}")
|
||||
return 1
|
||||
_emit("budget computed from your biometrics and sealed - the number is")
|
||||
_emit("intentionally not shown.")
|
||||
_emit(f"to lock it against casual edits, run: {lock_command()}")
|
||||
write_budget(budget, weight_kg=bio.weight_kg)
|
||||
_emit(f"budget computed from your biometrics: {budget:g} kcal/day.")
|
||||
_emit("edit it any time from the gate's calendar tab or the phone app.")
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@ -5,15 +5,15 @@ from __future__ import annotations
|
||||
from pathlib import Path
|
||||
|
||||
# --- Daily target -----------------------------------------------------------
|
||||
# There is deliberately NO budget number here. Like the home GPS coordinates in
|
||||
# phone_focus_mode (which live only in the git-ignored config_secrets.sh on the
|
||||
# device, never in committed source), the real budget is computed once from
|
||||
# biometrics at ``init`` time and sealed into BUDGET_FILE below. It is read via
|
||||
# diet_guard._budget.daily_budget() for over/under decisions only and
|
||||
# is never printed -- see _budget.py for the full threat model.
|
||||
# There is deliberately NO budget number here. It is computed once from
|
||||
# biometrics at ``init`` time, written to BUDGET_FILE below, and freely
|
||||
# editable and synced afterward from either device -- see _budget.py and
|
||||
# _sync.py. Read via diet_guard._budget.daily_budget() for over/under
|
||||
# decisions; freely shown in local CLI/GUI/app output.
|
||||
#
|
||||
# Fraction of the budget at which status flips from "on track" to "approaching
|
||||
# limit". Surfaced as a label, so the threshold leaks only by boundary-probing.
|
||||
# limit". Also mirrored above 100% as the day-status yellow/red boundary for
|
||||
# the calendar -- see _daystatus.py.
|
||||
BUDGET_WARN_FRACTION: float = 0.80
|
||||
|
||||
# --- Storage ----------------------------------------------------------------
|
||||
@ -26,10 +26,11 @@ FOOD_LOG_FILE: Path = DATA_DIR / "food_log.json"
|
||||
# searches -- Open Food Facts is used to *fill* a new food's macros, never to
|
||||
# search. Local-only, git-ignored.
|
||||
FOOD_BANK_FILE: Path = DATA_DIR / "food_bank.json"
|
||||
# The sealed budget: a dotfile alongside the log, base64-wrapped + HMAC-signed,
|
||||
# made immutable with ``chattr +i``. Git-ignored, never committed. "Hidden"
|
||||
# here means never-online (it lives outside the repo) -- the number is still
|
||||
# shown freely in local CLI/GUI output; the seal only makes *cheating* hard.
|
||||
# The budget: a plain JSON dotfile alongside the log, freely editable on this
|
||||
# device or the phone app and synced between them (see _sync.py).
|
||||
# Git-ignored, never committed. "Hidden" here means never-online (it lives
|
||||
# outside the repo) -- the number itself is shown freely in local CLI/GUI/app
|
||||
# output.
|
||||
BUDGET_FILE: Path = DATA_DIR / ".budget"
|
||||
|
||||
# --- Estimator (Open Food Facts) -------------------------------------------
|
||||
|
||||
201
diet_guard/_daystatus.py
Normal file
201
diet_guard/_daystatus.py
Normal file
@ -0,0 +1,201 @@
|
||||
"""Per-day budget-adherence status, streaks, and year-to-date tallies.
|
||||
|
||||
DAY STATUS SPEC (keep in sync with ``app/lib/services/day_status_service.dart``):
|
||||
|
||||
* green: ``total_kcal <= budget``
|
||||
* yellow: ``budget < total_kcal <= budget * (1 + (1 - BUDGET_WARN_FRACTION))``
|
||||
* red: ``total_kcal > budget * (1 + (1 - BUDGET_WARN_FRACTION))``
|
||||
* not_logged: the day is absent from a filtered log (no valid,
|
||||
non-tombstoned entries) -- see :func:`diet_guard._state.load_log`.
|
||||
* ordering, worst to best: not_logged > red > yellow > green.
|
||||
* the logging streak breaks on a not_logged day.
|
||||
* the adherence streak breaks on a red or a not_logged day; yellow and green
|
||||
both keep it alive.
|
||||
|
||||
Every function here is a pure function of an explicit ``log``/``status_map``
|
||||
argument (never reaching into on-disk state itself), so the boundary matrix
|
||||
above is trivially testable with synthetic data. :func:`current_status_map`
|
||||
is the one exception: a thin convenience wrapper for real UI callers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, timedelta
|
||||
import enum
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from diet_guard._budget import daily_budget
|
||||
from diet_guard._constants import BUDGET_WARN_FRACTION
|
||||
from diet_guard._state import entry_kcal, load_log, now_local
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from diet_guard._state import DayLog
|
||||
|
||||
|
||||
class DayStatus(enum.Enum):
|
||||
"""Qualitative per-day budget-adherence state, worst to best."""
|
||||
|
||||
NOT_LOGGED = "not_logged"
|
||||
RED = "red"
|
||||
YELLOW = "yellow"
|
||||
GREEN = "green"
|
||||
|
||||
|
||||
# The top of the "yellow" band above budget: a margin as wide as
|
||||
# BUDGET_WARN_FRACTION's margin below 100% (the existing "approaching limit"
|
||||
# band), so the two bands are symmetric around the budget line.
|
||||
_OVER_BUDGET_YELLOW_CEILING = 1.0 + (1.0 - BUDGET_WARN_FRACTION)
|
||||
|
||||
_LOGGED_STATUSES = frozenset(
|
||||
{DayStatus.GREEN, DayStatus.YELLOW, DayStatus.RED},
|
||||
)
|
||||
_ADHERENT_STATUSES = frozenset({DayStatus.GREEN, DayStatus.YELLOW})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class YtdTally:
|
||||
"""Year-to-date logging/adherence counts.
|
||||
|
||||
Attributes:
|
||||
logged_days: Days this year with at least one valid log entry.
|
||||
elapsed_days: Days from Jan 1 through the reference date, inclusive.
|
||||
adherent_days: Of ``logged_days``, how many were green or yellow.
|
||||
"""
|
||||
|
||||
logged_days: int
|
||||
elapsed_days: int
|
||||
adherent_days: int
|
||||
|
||||
|
||||
def day_total_kcal(log: DayLog, day: str) -> float:
|
||||
"""Return the summed kcal for one ``YYYY-MM-DD`` key in ``log``."""
|
||||
return sum(entry_kcal(entry) for entry in log.get(day, []))
|
||||
|
||||
|
||||
def day_status(log: DayLog, day: str, budget: int) -> DayStatus:
|
||||
"""Classify one day's budget adherence.
|
||||
|
||||
Args:
|
||||
log: A filtered log as returned by
|
||||
:func:`diet_guard._state.load_log` (only valid, non-tombstoned
|
||||
entries; a day with none is simply absent).
|
||||
day: The ``YYYY-MM-DD`` date key to classify.
|
||||
budget: The daily kcal budget to compare against.
|
||||
|
||||
Returns:
|
||||
The day's :class:`DayStatus`.
|
||||
"""
|
||||
if day not in log:
|
||||
return DayStatus.NOT_LOGGED
|
||||
total = day_total_kcal(log, day)
|
||||
if total <= budget:
|
||||
return DayStatus.GREEN
|
||||
if total <= budget * _OVER_BUDGET_YELLOW_CEILING:
|
||||
return DayStatus.YELLOW
|
||||
return DayStatus.RED
|
||||
|
||||
|
||||
def status_map(log: DayLog, *, budget: int) -> dict[str, DayStatus]:
|
||||
"""Return a :class:`DayStatus` for every date key present in ``log``.
|
||||
|
||||
Days with no log entries are simply absent from the result; a caller
|
||||
rendering a full calendar treats a missing key as
|
||||
:attr:`DayStatus.NOT_LOGGED`.
|
||||
"""
|
||||
return {day: day_status(log, day, budget) for day in log}
|
||||
|
||||
|
||||
def _streak(
|
||||
status_map_: dict[str, DayStatus],
|
||||
keeps: frozenset[DayStatus],
|
||||
*,
|
||||
today: date | None = None,
|
||||
) -> int:
|
||||
"""Count consecutive days ending at ``today`` whose status is in ``keeps``.
|
||||
|
||||
A ``today`` absent from ``status_map_`` (not yet logged) is skipped
|
||||
rather than treated as a break, so the streak does not appear broken
|
||||
every morning before the user has eaten; counting resumes from
|
||||
yesterday in that case. Any other day missing from ``keeps`` -- logged
|
||||
or not -- ends the streak normally.
|
||||
"""
|
||||
day = today if today is not None else now_local().date()
|
||||
if status_map_.get(day.isoformat()) is None:
|
||||
day -= timedelta(days=1)
|
||||
count = 0
|
||||
while status_map_.get(day.isoformat()) in keeps:
|
||||
count += 1
|
||||
day -= timedelta(days=1)
|
||||
return count
|
||||
|
||||
|
||||
def logging_streak(
|
||||
status_map_: dict[str, DayStatus],
|
||||
*,
|
||||
today: date | None = None,
|
||||
) -> int:
|
||||
"""Return the consecutive-day logging streak ending at ``today``."""
|
||||
return _streak(status_map_, _LOGGED_STATUSES, today=today)
|
||||
|
||||
|
||||
def adherence_streak(
|
||||
status_map_: dict[str, DayStatus],
|
||||
*,
|
||||
today: date | None = None,
|
||||
) -> int:
|
||||
"""Return the consecutive-day budget-adherence streak ending at ``today``.
|
||||
|
||||
Breaks on a red or not-logged day; green and yellow days both keep it
|
||||
alive ("not too aggressively crossed").
|
||||
"""
|
||||
return _streak(status_map_, _ADHERENT_STATUSES, today=today)
|
||||
|
||||
|
||||
def year_to_date_tally(
|
||||
status_map_: dict[str, DayStatus],
|
||||
*,
|
||||
today: date | None = None,
|
||||
) -> YtdTally:
|
||||
"""Return this year's logged/elapsed/adherent day counts.
|
||||
|
||||
Args:
|
||||
status_map_: Per-day statuses as returned by :func:`status_map`.
|
||||
today: The reference "today"; defaults to the real current date.
|
||||
|
||||
Returns:
|
||||
Counts of logged days this year, elapsed days so far this year
|
||||
(inclusive of ``today``), and how many logged days were adherent.
|
||||
"""
|
||||
ref = today if today is not None else now_local().date()
|
||||
elapsed_days = (ref - date(ref.year, 1, 1)).days + 1
|
||||
logged_days = 0
|
||||
adherent_days = 0
|
||||
for key, status in status_map_.items():
|
||||
if status is DayStatus.NOT_LOGGED:
|
||||
continue
|
||||
day = date.fromisoformat(key)
|
||||
if day.year != ref.year or day > ref:
|
||||
continue
|
||||
logged_days += 1
|
||||
if status in _ADHERENT_STATUSES:
|
||||
adherent_days += 1
|
||||
return YtdTally(
|
||||
logged_days=logged_days,
|
||||
elapsed_days=elapsed_days,
|
||||
adherent_days=adherent_days,
|
||||
)
|
||||
|
||||
|
||||
def current_status_map() -> dict[str, DayStatus]:
|
||||
"""Return :func:`status_map` for the real on-disk log and budget.
|
||||
|
||||
Convenience wrapper for UI callers; the pure functions above take
|
||||
explicit ``log``/``budget``/``status_map_`` arguments so they stay
|
||||
trivially testable with synthetic data.
|
||||
|
||||
Raises:
|
||||
BudgetNotInitializedError: If no budget has been set yet.
|
||||
BudgetFileCorruptError: If the budget file is corrupt.
|
||||
"""
|
||||
return status_map(load_log(), budget=daily_budget())
|
||||
@ -31,10 +31,13 @@ Building ``MealGate`` spans several sibling modules to keep each under the
|
||||
repo's 500-line limit: :mod:`._gatelock_core` provides the shared leaf
|
||||
widget/field helpers and state (``_GateCore``, ``_GateState``);
|
||||
:mod:`._gatelock_nutrition` provides the reference->total nutrition maths and
|
||||
food lookup (``_GateNutrition``); and :mod:`._gatelock_mealflow` provides the
|
||||
submit/log flow, dashboard, and callback-error handling (``_GateMealFlow``).
|
||||
``MealGate`` wires these mixins together, owns the ``gatelock.LockWindow``,
|
||||
and handles construction, layout, and event binding.
|
||||
food lookup (``_GateNutrition``); :mod:`._gatelock_mealflow` provides the
|
||||
submit/log flow, dashboard, and callback-error handling (``_GateMealFlow``);
|
||||
and :mod:`._gatelock_calendar` provides the second ``ttk.Notebook`` tab --
|
||||
the budget-adherence calendar, streaks, and YTD tally from
|
||||
:mod:`._daystatus`, plus budget editing (``_GateCalendar``). ``MealGate``
|
||||
wires these mixins together, owns the ``gatelock.LockWindow``, and handles
|
||||
construction, layout, and event binding.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@ -49,12 +52,11 @@ from gatelock import GateRoot, LockConfig, LockWindow
|
||||
|
||||
from diet_guard._constants import GATE_LOCK_FILE
|
||||
from diet_guard._gate import due_slots
|
||||
from diet_guard._gatelock_calendar import _GateCalendar
|
||||
from diet_guard._gatelock_core import _GateState
|
||||
from diet_guard._gatelock_mealflow import _GateMealFlow
|
||||
from diet_guard._gatelock_ui import (
|
||||
BG,
|
||||
GateCallbacks,
|
||||
build_layout,
|
||||
make_vars,
|
||||
)
|
||||
from diet_guard._slots import current_slot, day_slots
|
||||
@ -122,7 +124,7 @@ def _pending_slots(*, demo_mode: bool) -> list[int]:
|
||||
return []
|
||||
|
||||
|
||||
class MealGate(_GateMealFlow):
|
||||
class MealGate(_GateCalendar):
|
||||
"""A fullscreen lock that dismisses only once every missing slot is logged."""
|
||||
|
||||
def __init__(self, *, demo_mode: bool = True) -> None:
|
||||
@ -157,17 +159,13 @@ class MealGate(_GateMealFlow):
|
||||
on_add_item=self._on_add_item,
|
||||
on_fetch_sync=self._on_fetch_sync,
|
||||
)
|
||||
self._widgets = build_layout(
|
||||
self.root,
|
||||
self._vars,
|
||||
callbacks,
|
||||
demo_mode=self.demo_mode,
|
||||
)
|
||||
self._widgets = self._build_tabs(callbacks)
|
||||
self._wire_events()
|
||||
self._relabel_basis()
|
||||
self._refresh_slot_header()
|
||||
self._refresh_dashboard()
|
||||
self._refresh_projection()
|
||||
self._refresh_calendar()
|
||||
self._lock.grab_input()
|
||||
self._widgets.desc_text.focus_set()
|
||||
|
||||
|
||||
466
diet_guard/_gatelock_calendar.py
Normal file
466
diet_guard/_gatelock_calendar.py
Normal file
@ -0,0 +1,466 @@
|
||||
"""History tab: budget-adherence calendar, streaks, YTD tally, budget edit.
|
||||
|
||||
Split out of :mod:`._gatelock` (and kept out of :mod:`._gatelock_ui`) to keep
|
||||
every gate module under the repo's 500-line limit. ``_GateCalendar`` extends
|
||||
:class:`~diet_guard._gatelock_mealflow._GateMealFlow` with a second
|
||||
``ttk.Notebook`` tab: the calendar/streak/tally view built from
|
||||
:mod:`diet_guard._calendar_view`'s pure grid math, plus a budget-edit field
|
||||
writing through :func:`diet_guard._budget.write_budget` -- the one place
|
||||
either device edits the now freely-editable, synced daily budget.
|
||||
|
||||
The month-grid math itself lives in :mod:`diet_guard._calendar_view` (no Tk
|
||||
dependency), so the boundary cases (varying month length/first weekday,
|
||||
every :class:`~diet_guard._daystatus.DayStatus`, future days, no budget set)
|
||||
are unit-tested directly without a fake Tk in the loop.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import calendar
|
||||
from dataclasses import dataclass
|
||||
import tkinter as tk
|
||||
from tkinter import ttk
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from diet_guard._budget import (
|
||||
BudgetError,
|
||||
BudgetFileCorruptError,
|
||||
BudgetNotInitializedError,
|
||||
budget_weight,
|
||||
daily_budget,
|
||||
write_budget,
|
||||
)
|
||||
from diet_guard._calendar_view import (
|
||||
CalendarCell,
|
||||
build_month_cells,
|
||||
cell_style,
|
||||
streaks_text,
|
||||
ytd_text,
|
||||
)
|
||||
from diet_guard._daystatus import DayStatus, status_map
|
||||
from diet_guard._gatelock_mealflow import _GateMealFlow
|
||||
from diet_guard._gatelock_ui import (
|
||||
BG,
|
||||
ERR,
|
||||
FG,
|
||||
GateCallbacks,
|
||||
GateWidgets,
|
||||
build_layout,
|
||||
)
|
||||
from diet_guard._state import load_log, now_local
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
# Palette additions specific to the calendar (BG/FG/ERR come from
|
||||
# _gatelock_ui's public exports; the rest are private to that module, so
|
||||
# equivalents are defined locally rather than reaching across the boundary).
|
||||
_MUTED = "#9a9a9a"
|
||||
_FIELD_BG = "#2a2a2a"
|
||||
_ACCENT = "#00ff88"
|
||||
|
||||
# calendar.monthcalendar never returns more than 6 weeks for any month.
|
||||
_MONTH_ROWS = 6
|
||||
_WEEKDAY_LABELS = ("Mo", "Tu", "We", "Th", "Fr", "Sa", "Su")
|
||||
_JANUARY = 1
|
||||
_DECEMBER = 12
|
||||
_MONTH_AFTER_DECEMBER = 13
|
||||
|
||||
# Shown (and classified against) before any budget has ever been set --
|
||||
# mirrors the phone app's AppSettingsService default, so a fresh install
|
||||
# shows a meaningful calendar on both platforms with no setup ritual.
|
||||
_DEFAULT_BUDGET_KCAL = 2200
|
||||
|
||||
|
||||
@dataclass
|
||||
class CalendarVars:
|
||||
"""Tk string variables bound to the History tab's live fields."""
|
||||
|
||||
month_label: tk.StringVar
|
||||
streaks: tk.StringVar
|
||||
ytd: tk.StringVar
|
||||
budget_status: tk.StringVar
|
||||
|
||||
|
||||
@dataclass
|
||||
class CalendarWidgets:
|
||||
"""Interactive widgets of the History tab."""
|
||||
|
||||
frame: tk.Frame
|
||||
day_cells: list[tk.Label]
|
||||
budget_entry: tk.Entry
|
||||
budget_edit_button: tk.Button
|
||||
budget_status_label: tk.Label
|
||||
|
||||
|
||||
@dataclass
|
||||
class CalendarCallbacks:
|
||||
"""Construction-time commands the History tab's widgets fire."""
|
||||
|
||||
on_prev_month: Callable[[], None]
|
||||
on_next_month: Callable[[], None]
|
||||
on_edit_or_save_budget: Callable[[], None]
|
||||
|
||||
|
||||
def make_calendar_vars(root: tk.Misc) -> CalendarVars:
|
||||
"""Create the History tab's string variables, all mastered to ``root``."""
|
||||
return CalendarVars(
|
||||
month_label=tk.StringVar(master=root, value=""),
|
||||
streaks=tk.StringVar(master=root, value=""),
|
||||
ytd=tk.StringVar(master=root, value=""),
|
||||
budget_status=tk.StringVar(master=root, value=""),
|
||||
)
|
||||
|
||||
|
||||
def _build_budget_row(
|
||||
parent: tk.Frame,
|
||||
vars_: CalendarVars,
|
||||
on_edit_or_save_budget: Callable[[], None],
|
||||
) -> tuple[tk.Entry, tk.Button, tk.Label]:
|
||||
"""Build the budget row; return the entry, its edit button, and status label.
|
||||
|
||||
The entry starts read-only (``state="readonly"``): the budget is
|
||||
displayed but not directly typeable. The button on the right starts
|
||||
labelled "Edit"; clicking it makes the entry editable and relabels
|
||||
itself "Save" -- a second click validates and persists, then reverts
|
||||
both back to their read-only defaults.
|
||||
"""
|
||||
row = tk.Frame(parent, bg=BG)
|
||||
row.pack(pady=(8, 4))
|
||||
tk.Label(
|
||||
row,
|
||||
text="Daily budget (kcal):",
|
||||
font=("Arial", 12),
|
||||
bg=BG,
|
||||
fg=FG,
|
||||
).pack(side="left")
|
||||
entry = tk.Entry(
|
||||
row,
|
||||
font=("Arial", 13),
|
||||
width=8,
|
||||
bg=_FIELD_BG,
|
||||
fg=FG,
|
||||
insertbackground=FG,
|
||||
justify="center",
|
||||
state="readonly",
|
||||
readonlybackground=_FIELD_BG,
|
||||
)
|
||||
entry.pack(side="left", padx=(6, 6), ipady=2)
|
||||
edit_button = tk.Button(
|
||||
row,
|
||||
text="Edit",
|
||||
font=("Arial", 12, "bold"),
|
||||
bg=_ACCENT,
|
||||
fg="#003322",
|
||||
activebackground="#00cc66",
|
||||
cursor="hand2",
|
||||
command=on_edit_or_save_budget,
|
||||
)
|
||||
edit_button.pack(side="left")
|
||||
status_label = tk.Label(
|
||||
parent,
|
||||
textvariable=vars_.budget_status,
|
||||
font=("Arial", 11),
|
||||
bg=BG,
|
||||
fg=FG,
|
||||
)
|
||||
status_label.pack(pady=(0, 4))
|
||||
return entry, edit_button, status_label
|
||||
|
||||
|
||||
def _build_month_nav(
|
||||
parent: tk.Frame,
|
||||
vars_: CalendarVars,
|
||||
callbacks: CalendarCallbacks,
|
||||
) -> None:
|
||||
"""Build the prev/month-label/next header row."""
|
||||
row = tk.Frame(parent, bg=BG)
|
||||
row.pack(pady=(4, 2))
|
||||
tk.Button(
|
||||
row,
|
||||
text="◀",
|
||||
font=("Arial", 12, "bold"),
|
||||
bg=_FIELD_BG,
|
||||
fg=FG,
|
||||
command=callbacks.on_prev_month,
|
||||
cursor="hand2",
|
||||
).pack(side="left", padx=4)
|
||||
tk.Label(
|
||||
row,
|
||||
textvariable=vars_.month_label,
|
||||
font=("Arial", 14, "bold"),
|
||||
bg=BG,
|
||||
fg=FG,
|
||||
width=16,
|
||||
justify="center",
|
||||
).pack(side="left")
|
||||
tk.Button(
|
||||
row,
|
||||
text="▶",
|
||||
font=("Arial", 12, "bold"),
|
||||
bg=_FIELD_BG,
|
||||
fg=FG,
|
||||
command=callbacks.on_next_month,
|
||||
cursor="hand2",
|
||||
).pack(side="left", padx=4)
|
||||
|
||||
|
||||
def _build_grid(parent: tk.Frame) -> list[tk.Label]:
|
||||
"""Build the fixed 6x7 day-cell grid and return the flat cell list."""
|
||||
weekday_row = tk.Frame(parent, bg=BG)
|
||||
weekday_row.pack()
|
||||
for col, label in enumerate(_WEEKDAY_LABELS):
|
||||
tk.Label(
|
||||
weekday_row,
|
||||
text=label,
|
||||
font=("Arial", 10, "bold"),
|
||||
bg=BG,
|
||||
fg=_MUTED,
|
||||
width=4,
|
||||
).grid(row=0, column=col, padx=1)
|
||||
|
||||
grid_frame = tk.Frame(parent, bg=BG)
|
||||
grid_frame.pack(pady=(2, 4))
|
||||
day_cells: list[tk.Label] = []
|
||||
for row in range(_MONTH_ROWS):
|
||||
for col in range(7):
|
||||
cell = tk.Label(
|
||||
grid_frame,
|
||||
text="",
|
||||
font=("Arial", 11),
|
||||
width=4,
|
||||
height=2,
|
||||
bg=BG,
|
||||
fg=FG,
|
||||
highlightthickness=2,
|
||||
highlightbackground=BG,
|
||||
)
|
||||
cell.grid(row=row, column=col, padx=1, pady=1)
|
||||
day_cells.append(cell)
|
||||
return day_cells
|
||||
|
||||
|
||||
def build_calendar_frame(
|
||||
root: tk.Misc,
|
||||
vars_: CalendarVars,
|
||||
callbacks: CalendarCallbacks,
|
||||
) -> CalendarWidgets:
|
||||
"""Lay out the History tab and return the widgets the controller drives."""
|
||||
frame = tk.Frame(root, bg=BG)
|
||||
tk.Label(
|
||||
frame,
|
||||
text="📅 History",
|
||||
font=("Arial", 22, "bold"),
|
||||
bg=BG,
|
||||
fg=_ACCENT,
|
||||
).pack(pady=(10, 0))
|
||||
|
||||
budget_entry, budget_edit_button, budget_status_label = _build_budget_row(
|
||||
frame,
|
||||
vars_,
|
||||
callbacks.on_edit_or_save_budget,
|
||||
)
|
||||
_build_month_nav(frame, vars_, callbacks)
|
||||
day_cells = _build_grid(frame)
|
||||
|
||||
tk.Label(
|
||||
frame,
|
||||
textvariable=vars_.streaks,
|
||||
font=("Arial", 13, "bold"),
|
||||
bg=BG,
|
||||
fg=FG,
|
||||
).pack(pady=(6, 0))
|
||||
tk.Label(
|
||||
frame,
|
||||
textvariable=vars_.ytd,
|
||||
font=("Arial", 12),
|
||||
bg=BG,
|
||||
fg=_MUTED,
|
||||
).pack(pady=(2, 8))
|
||||
|
||||
return CalendarWidgets(
|
||||
frame=frame,
|
||||
day_cells=day_cells,
|
||||
budget_entry=budget_entry,
|
||||
budget_edit_button=budget_edit_button,
|
||||
budget_status_label=budget_status_label,
|
||||
)
|
||||
|
||||
|
||||
class _GateCalendar(_GateMealFlow):
|
||||
"""History tab: calendar, streaks, YTD tally, and budget editing."""
|
||||
|
||||
_cal_vars: CalendarVars
|
||||
_cal_widgets: CalendarWidgets
|
||||
_cal_year: int
|
||||
_cal_month: int
|
||||
_cal_editing_budget: bool
|
||||
_notebook: ttk.Notebook
|
||||
|
||||
def _build_tabs(self, callbacks: GateCallbacks) -> GateWidgets:
|
||||
"""Build the ttk.Notebook, wire both tabs, and return the meal widgets.
|
||||
|
||||
The controller stores the returned bundle as ``self._widgets`` exactly
|
||||
as it did with the bare ``build_layout`` result before this tab
|
||||
existed; the calendar's own vars/widgets are stashed on self.
|
||||
"""
|
||||
today = now_local().date()
|
||||
self._cal_year = today.year
|
||||
self._cal_month = today.month
|
||||
self._cal_editing_budget = False
|
||||
self._notebook = ttk.Notebook(self.root)
|
||||
self._notebook.place(relx=0, rely=0, relwidth=1, relheight=1)
|
||||
|
||||
widgets = build_layout(
|
||||
self._notebook, self._vars, callbacks, demo_mode=self.demo_mode
|
||||
)
|
||||
self._notebook.add(widgets.frame, text="Log Meal")
|
||||
|
||||
self._cal_vars = make_calendar_vars(self.root)
|
||||
cal_callbacks = CalendarCallbacks(
|
||||
on_prev_month=self._on_prev_month,
|
||||
on_next_month=self._on_next_month,
|
||||
on_edit_or_save_budget=self._on_edit_or_save_budget,
|
||||
)
|
||||
self._cal_widgets = build_calendar_frame(
|
||||
self._notebook, self._cal_vars, cal_callbacks
|
||||
)
|
||||
self._notebook.add(self._cal_widgets.frame, text="History")
|
||||
return widgets
|
||||
|
||||
# -- refresh --------------------------------------------------------------
|
||||
|
||||
def _refresh_calendar(self) -> None:
|
||||
"""Recompute the calendar grid, streaks, YTD tally, and budget field.
|
||||
|
||||
A budget that was never set defaults to
|
||||
:data:`_DEFAULT_BUDGET_KCAL`, matching the phone app, so the tab is
|
||||
meaningful from a fresh install with no setup ritual. A genuinely
|
||||
corrupt budget file is a real problem, not just "unset" -- that
|
||||
still degrades to a neutral grid and an error message, instead of
|
||||
raising through the tab and crashing (or failing open) the lock.
|
||||
"""
|
||||
log = load_log()
|
||||
try:
|
||||
budget: int | None = daily_budget()
|
||||
except BudgetNotInitializedError:
|
||||
budget = _DEFAULT_BUDGET_KCAL
|
||||
except BudgetFileCorruptError:
|
||||
budget = None
|
||||
status_map_ = status_map(log, budget=budget) if budget is not None else None
|
||||
self._render_month(status_map_)
|
||||
if status_map_ is None:
|
||||
self._cal_vars.streaks.set("Budget file is corrupt -- fix it below.")
|
||||
self._cal_vars.ytd.set("")
|
||||
else:
|
||||
self._cal_vars.streaks.set(streaks_text(status_map_))
|
||||
self._cal_vars.ytd.set(ytd_text(status_map_))
|
||||
if not self._cal_editing_budget:
|
||||
self._refresh_budget_field(budget)
|
||||
|
||||
def _render_month(self, status_map_: dict[str, DayStatus] | None) -> None:
|
||||
"""Redraw the day-cell grid and month label for the displayed month."""
|
||||
weeks = build_month_cells(self._cal_year, self._cal_month, status_map_)
|
||||
self._cal_vars.month_label.set(
|
||||
f"{calendar.month_name[self._cal_month]} {self._cal_year}",
|
||||
)
|
||||
cells = self._cal_widgets.day_cells
|
||||
for index, cell_widget in enumerate(cells):
|
||||
row, col = divmod(index, 7)
|
||||
spec = weeks[row][col] if row < len(weeks) else CalendarCell(None, None)
|
||||
bg, fg, outline = cell_style(spec.status)
|
||||
cell_widget.config(
|
||||
text=str(spec.day) if spec.day else "",
|
||||
bg=bg,
|
||||
fg=fg,
|
||||
highlightbackground=outline,
|
||||
)
|
||||
|
||||
def _refresh_budget_field(self, budget: int | None) -> None:
|
||||
"""Show ``budget`` in the (read-only) entry, or leave it blank if unset.
|
||||
|
||||
Temporarily switches to the "normal" Tk state to mutate the text --
|
||||
a real ``readonly`` Entry rejects ``.insert``/``.delete`` the same
|
||||
as direct typing -- then restores read-only.
|
||||
"""
|
||||
entry = self._cal_widgets.budget_entry
|
||||
entry.config(state="normal")
|
||||
entry.delete(0, tk.END)
|
||||
if budget is not None:
|
||||
entry.insert(0, str(budget))
|
||||
entry.config(state="readonly")
|
||||
|
||||
# -- month navigation -------------------------------------------------------
|
||||
|
||||
def _on_prev_month(self) -> None:
|
||||
"""Step the displayed month back one, wrapping the year at January."""
|
||||
self._cal_month -= 1
|
||||
if self._cal_month < _JANUARY:
|
||||
self._cal_month = _DECEMBER
|
||||
self._cal_year -= 1
|
||||
self._refresh_calendar()
|
||||
|
||||
def _on_next_month(self) -> None:
|
||||
"""Step the displayed month forward one, wrapping the year at December."""
|
||||
self._cal_month += 1
|
||||
if self._cal_month == _MONTH_AFTER_DECEMBER:
|
||||
self._cal_month = _JANUARY
|
||||
self._cal_year += 1
|
||||
self._refresh_calendar()
|
||||
|
||||
# -- budget editing -----------------------------------------------------
|
||||
|
||||
def _set_budget_status(self, text: str, *, error: bool) -> None:
|
||||
"""Update the budget-edit status line, red for errors."""
|
||||
self._cal_vars.budget_status.set(text)
|
||||
self._cal_widgets.budget_status_label.config(fg=ERR if error else FG)
|
||||
|
||||
def _on_edit_or_save_budget(self) -> None:
|
||||
"""Toggle the budget row between read-only display and editing.
|
||||
|
||||
First click: unlock the entry for typing and relabel the button
|
||||
"Save" -- nothing is persisted yet. Second click: validate and
|
||||
persist; on success, lock the entry back to read-only and relabel
|
||||
the button "Edit". A validation failure leaves editing open so the
|
||||
user can correct the value instead of silently discarding it.
|
||||
"""
|
||||
if not self._cal_editing_budget:
|
||||
self._cal_editing_budget = True
|
||||
self._cal_widgets.budget_entry.config(state="normal")
|
||||
self._cal_widgets.budget_edit_button.config(text="Save")
|
||||
self._set_budget_status("", error=False)
|
||||
return
|
||||
if not self._save_budget_entry():
|
||||
return
|
||||
self._cal_editing_budget = False
|
||||
self._cal_widgets.budget_edit_button.config(text="Edit")
|
||||
self._refresh_calendar()
|
||||
self._refresh_dashboard()
|
||||
self._refresh_projection()
|
||||
|
||||
def _save_budget_entry(self) -> bool:
|
||||
"""Validate and persist the entry's current text.
|
||||
|
||||
Preserves any body weight already stored alongside the budget (used
|
||||
to derive the protein target) -- a bare ``write_budget(value)`` would
|
||||
silently drop it, since the file holds one record, not a diff.
|
||||
|
||||
Returns:
|
||||
Whether the value was valid and persisted.
|
||||
"""
|
||||
raw = self._cal_widgets.budget_entry.get().strip()
|
||||
try:
|
||||
value = int(raw)
|
||||
except ValueError:
|
||||
self._set_budget_status("Enter a whole number of kcal.", error=True)
|
||||
return False
|
||||
if value <= 0:
|
||||
self._set_budget_status("Budget must be a positive number.", error=True)
|
||||
return False
|
||||
try:
|
||||
weight = budget_weight()
|
||||
except BudgetError:
|
||||
weight = None
|
||||
write_budget(value, weight_kg=weight)
|
||||
self._set_budget_status("Saved.", error=False)
|
||||
return True
|
||||
@ -253,8 +253,8 @@ class _GateMealFlow(_GateNutrition):
|
||||
def _unlock(self, logged: str) -> None:
|
||||
"""Confirm the final log and tear the window down.
|
||||
|
||||
Teardown is scheduled *before* the budget is looked up, so a broken
|
||||
budget seal (which raises) can never re-trap the user at unlock time.
|
||||
Teardown is scheduled *before* the budget is looked up, so a corrupt
|
||||
budget file (which raises) can never re-trap the user at unlock time.
|
||||
"""
|
||||
self._set_status(f"{logged} — all meals logged, unlocking…")
|
||||
self.root.after(_UNLOCK_DELAY_MS, self.close)
|
||||
|
||||
@ -87,7 +87,7 @@ class _GateNutrition(_GateCore):
|
||||
This answers, as the calories are typed, the four numbers the user asked
|
||||
to see together: how much is already eaten today, the day's goal, how
|
||||
much is left now, and how much would be left *after* logging the food
|
||||
currently in the form. With no budget sealed it degrades to the running
|
||||
currently in the form. With no budget set it degrades to the running
|
||||
total plus this item's calories, so it is always informative.
|
||||
"""
|
||||
consumed = today_total_kcal()
|
||||
|
||||
@ -70,6 +70,7 @@ class GateVars:
|
||||
class GateWidgets:
|
||||
"""Interactive widgets the controller reads back after the UI is built."""
|
||||
|
||||
frame: tk.Frame
|
||||
desc_text: tk.Text
|
||||
amount_entry: tk.Entry
|
||||
per_entry: tk.Entry
|
||||
@ -461,6 +462,7 @@ def build_layout(
|
||||
).place(x=10, y=10)
|
||||
|
||||
return GateWidgets(
|
||||
frame=frame,
|
||||
desc_text=desc_text,
|
||||
amount_entry=amount_entry,
|
||||
per_entry=per_entry,
|
||||
|
||||
@ -16,11 +16,13 @@ Safety invariants (do not break when adding tools):
|
||||
calls must never write to stdout. All logging is routed to STDERR below,
|
||||
and tools call only stdout-free leaf helpers (never the ``_cmd_*`` /
|
||||
``main`` CLI handlers, which write to stdout, read stdin, and ``sys.exit``).
|
||||
* **The daily budget number never leaves.** No tool returns the raw budget,
|
||||
the remaining-budget number, the stored body weight, the protein target, or
|
||||
the sealed ``.budget`` file: those would let a caller back out the
|
||||
deliberately-hidden budget. Read tools expose only today's *consumed* kcal /
|
||||
macros and the qualitative :func:`consumption_band` string.
|
||||
* **The daily budget number never leaves via this interface.** No tool
|
||||
returns the raw budget, the remaining-budget number, the stored body
|
||||
weight, the protein target, or the ``.budget`` file: an automated caller
|
||||
should not be handed the exact number to reason about or help game,
|
||||
even though the number itself is freely visible to the human via the
|
||||
CLI/GUI and the phone app. Read tools expose only today's *consumed*
|
||||
kcal / macros and the qualitative :func:`consumption_band` string.
|
||||
* **No secret ever leaves.** There is no tool that reads the shared HMAC key,
|
||||
the sync token, or any file under ``/etc``.
|
||||
* **Writes are gated.** The write tool defaults to a dry-run preview and
|
||||
@ -102,7 +104,7 @@ def get_status() -> dict[str, Any]:
|
||||
|
||||
Reports the calories and macros *consumed* so far, the qualitative
|
||||
:func:`consumption_band` (``"on track"`` / ``"approaching limit"`` /
|
||||
``"OVER BUDGET"``, or ``None`` when no budget is sealed yet), and the
|
||||
``"OVER BUDGET"``, or ``None`` when no budget has been set yet), and the
|
||||
meal-slot picture (which slots are due, which are already logged, and the
|
||||
current slot). The raw budget number is intentionally withheld -- only the
|
||||
band is exposed, mirroring how the CLI status shows a label to an automated
|
||||
@ -111,7 +113,7 @@ def get_status() -> dict[str, Any]:
|
||||
try:
|
||||
band: str | None = consumption_band()
|
||||
except BudgetError:
|
||||
# No budget sealed (or a broken seal): surface the absence, not a number.
|
||||
# No budget set (or a corrupt file): surface the absence, not a number.
|
||||
band = None
|
||||
return {
|
||||
"consumed_kcal": today_total_kcal(),
|
||||
|
||||
@ -245,10 +245,10 @@ def logged_slots_today() -> set[int]:
|
||||
|
||||
|
||||
def remaining_budget() -> float:
|
||||
"""Return kcal remaining against the sealed budget (may be negative).
|
||||
"""Return kcal remaining against the daily budget (may be negative).
|
||||
|
||||
Raises:
|
||||
BudgetError: If the budget is uninitialized or its seal is broken;
|
||||
BudgetError: If the budget is uninitialized or its file is corrupt;
|
||||
the caller decides whether to guide the user or fail closed.
|
||||
"""
|
||||
return round(daily_budget() - today_total_kcal(), 1)
|
||||
|
||||
@ -6,6 +6,12 @@ Pulls every other device's pushed log from GitHub-backed dumb storage
|
||||
diet_guard's entries to/from ``crdt_sync.Record``), re-signs every persisted
|
||||
entry, rebuilds the food bank, and pushes this device's own merged log back
|
||||
up in the new Record-based wire format.
|
||||
|
||||
The daily budget syncs alongside the food log in the same tick (see
|
||||
:func:`_sync_budget`, called from :func:`run_sync`): a sibling
|
||||
``budget.json`` per device, merged the same way but last-writer-wins per
|
||||
edit rather than union-of-immutable-entries, since a budget (unlike a
|
||||
food-log entry) can be edited repeatedly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@ -15,6 +21,7 @@ import logging
|
||||
|
||||
from crdt_sync import GitHubSyncClient, GitHubSyncError, Log, merge_logs
|
||||
|
||||
from diet_guard._budget import read_raw_record, write_raw_record
|
||||
from diet_guard._constants import (
|
||||
SYNC_DEVICE_ID,
|
||||
SYNC_REPO_NAME,
|
||||
@ -24,7 +31,14 @@ from diet_guard._constants import (
|
||||
)
|
||||
from diet_guard._foodbank import rebuild_food_bank
|
||||
from diet_guard._state import DayLog, read_raw_log, resign_entry, write_raw_log
|
||||
from diet_guard._sync_merge import daylog_to_log, log_to_daylog, parse_remote_log
|
||||
from diet_guard._sync_merge import (
|
||||
budget_to_log,
|
||||
daylog_to_log,
|
||||
log_to_budget,
|
||||
log_to_daylog,
|
||||
parse_remote_budget,
|
||||
parse_remote_log,
|
||||
)
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
@ -40,6 +54,11 @@ def _device_log_path(device_id: str) -> str:
|
||||
return f"{_DEVICES_DIR}/{device_id}/food_log.json"
|
||||
|
||||
|
||||
def _device_budget_path(device_id: str) -> str:
|
||||
"""Return the repo-relative path a device's budget is pushed to."""
|
||||
return f"{_DEVICES_DIR}/{device_id}/budget.json"
|
||||
|
||||
|
||||
def _read_token() -> str:
|
||||
"""Return the saved sync PAT, stripped of trailing whitespace.
|
||||
|
||||
@ -84,6 +103,48 @@ def _pull_remote_logs(client: GitHubSyncClient) -> list[Log]:
|
||||
return remote_logs
|
||||
|
||||
|
||||
def _sync_budget(client: GitHubSyncClient) -> None:
|
||||
"""Pull other devices' budgets, merge, write locally, push this device's.
|
||||
|
||||
Runs in the same tick as the food-log sync, reusing the already
|
||||
authenticated ``client``. Merging is last-writer-wins by edit time (see
|
||||
:mod:`diet_guard._sync_merge`'s budget adapters), not the food log's
|
||||
union-of-immutable-entries -- a budget can be edited repeatedly. A
|
||||
device that has never run ``init`` neither contributes a local record
|
||||
to the merge nor overwrites a real budget pulled from elsewhere, and if
|
||||
*no* device has ever set one, nothing is written or pushed.
|
||||
"""
|
||||
merged = budget_to_log(read_raw_record())
|
||||
for device_id in client.list_directory(_DEVICES_DIR):
|
||||
if device_id == SYNC_DEVICE_ID:
|
||||
continue
|
||||
text = client.get_file_text(_device_budget_path(device_id))
|
||||
if text is None:
|
||||
continue
|
||||
try:
|
||||
merged = merge_logs(merged, parse_remote_budget(text))
|
||||
except (TypeError, KeyError, ValueError, json.JSONDecodeError):
|
||||
_logger.warning(
|
||||
"Unparsable budget pushed by device %r, skipping",
|
||||
device_id,
|
||||
)
|
||||
|
||||
merged_record = log_to_budget(merged)
|
||||
if merged_record is None:
|
||||
return
|
||||
write_raw_record(merged_record)
|
||||
|
||||
push_json = json.dumps(
|
||||
{record_id: record.to_dict() for record_id, record in merged.items()},
|
||||
indent=2,
|
||||
)
|
||||
client.put_file_text(
|
||||
_device_budget_path(SYNC_DEVICE_ID),
|
||||
push_json,
|
||||
message="diet_guard sync",
|
||||
)
|
||||
|
||||
|
||||
def run_sync() -> DayLog:
|
||||
"""Run one full sync tick: pull, merge, re-sign, persist, push.
|
||||
|
||||
@ -91,7 +152,8 @@ def run_sync() -> DayLog:
|
||||
phone-origin ones): a signature computed on another device cannot be
|
||||
trusted as this device's shared key sees it, and an inbound entry with no
|
||||
signature at all would otherwise be silently dropped on the very next
|
||||
read by :func:`diet_guard._state.load_log`.
|
||||
read by :func:`diet_guard._state.load_log`. The daily budget syncs in
|
||||
the same tick (see :func:`_sync_budget`), reusing this same client.
|
||||
|
||||
Returns:
|
||||
The merged log as it now sits on disk locally, post re-sign.
|
||||
@ -118,6 +180,7 @@ def run_sync() -> DayLog:
|
||||
}
|
||||
write_raw_log(resigned)
|
||||
rebuild_food_bank(resigned)
|
||||
_sync_budget(client)
|
||||
|
||||
push_log = daylog_to_log(resigned)
|
||||
push_json = json.dumps(
|
||||
|
||||
@ -20,6 +20,16 @@ Backward compatible with devices not yet migrated (the phone app, for now):
|
||||
falls back to the old plain-DayLog format, converting old-format entries
|
||||
through the same adapters used for the local log. Push always writes the
|
||||
new format -- there is no code path left that ever *writes* the old one.
|
||||
|
||||
The budget adapters at the bottom of this module (:func:`budget_to_log`
|
||||
etc.) follow the same ``Record``/``Log`` shape, but differ in one important
|
||||
way: a budget record is edited repeatedly (not immutable-after-creation
|
||||
like a food-log entry), so its ``Hlc`` is derived from a ``t`` edit
|
||||
timestamp that :func:`diet_guard._budget.write_budget` bumps on every
|
||||
write, rather than from a birth time that never changes. There is no
|
||||
legacy plain-``DayLog``-shaped fallback for budgets -- ``budget.json`` is a
|
||||
brand-new sync payload, so every device pushing it already speaks the
|
||||
Record-based wire format.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@ -176,3 +186,80 @@ def parse_remote_log(text: str) -> Log:
|
||||
raise TypeError(msg)
|
||||
daylog[date_key] = entries
|
||||
return daylog_to_log(daylog)
|
||||
|
||||
|
||||
# Stable id: exactly one budget record per device-pushed budget.json.
|
||||
_BUDGET_RECORD_ID = "budget"
|
||||
|
||||
|
||||
def _budget_hlc(record: dict[str, object]) -> Hlc:
|
||||
"""Derive a deterministic Hlc for a raw budget record from its ``t`` field.
|
||||
|
||||
Mirrors :func:`_entry_hlc`'s determinism -- the same unedited record
|
||||
always yields the same Hlc, so re-syncing an unchanged budget is a
|
||||
no-op -- but reads ``t`` (bumped on every :func:`diet_guard._budget.
|
||||
write_budget` call) rather than a fixed birth time, since a budget can
|
||||
be edited repeatedly and the *edit* time is what last-writer-wins must
|
||||
compare.
|
||||
"""
|
||||
try:
|
||||
dt = datetime.fromisoformat(str(record.get("t", "")))
|
||||
except ValueError:
|
||||
dt = _EPOCH
|
||||
wall_time_ms = int(dt.timestamp() * 1000)
|
||||
return Hlc.new_tick(SYNC_DEVICE_ID, wall_time_ms=wall_time_ms)
|
||||
|
||||
|
||||
def budget_to_log(record: dict[str, object] | None) -> Log:
|
||||
"""Convert a raw local/remote budget record into a single-record Log.
|
||||
|
||||
Returns an empty ``Log`` when ``record`` is None (this device has never
|
||||
run ``init``), so an uninitialized device contributes nothing to the
|
||||
merge rather than clobbering another device's real value.
|
||||
"""
|
||||
if record is None:
|
||||
return {}
|
||||
hlc = _budget_hlc(record)
|
||||
value = {k: v for k, v in record.items() if k != "t"}
|
||||
rec = Record(id=_BUDGET_RECORD_ID, fields={"value": (value, hlc)})
|
||||
return {rec.id: rec}
|
||||
|
||||
|
||||
def log_to_budget(log: Log) -> dict[str, object] | None:
|
||||
"""Convert a merged budget ``Log`` back into a raw budget record.
|
||||
|
||||
Returns None when the log has no budget record at all (neither device
|
||||
has ever run ``init`` yet) -- callers treat that as "nothing to write
|
||||
locally", not an error.
|
||||
"""
|
||||
record = log.get(_BUDGET_RECORD_ID)
|
||||
if record is None:
|
||||
return None
|
||||
value, hlc = record.fields.get("value", ({}, None))
|
||||
result: dict[str, object] = dict(value) if isinstance(value, dict) else {}
|
||||
if hlc is not None:
|
||||
winning_time = datetime.fromtimestamp(hlc.wall_time_ms / 1000, tz=timezone.utc)
|
||||
result["t"] = winning_time.astimezone().isoformat(timespec="seconds")
|
||||
return result
|
||||
|
||||
|
||||
def parse_remote_budget(text: str) -> Log:
|
||||
"""Parse one device's pushed ``budget.json`` text into a ``crdt_sync.Log``.
|
||||
|
||||
Raises on malformed data; the caller
|
||||
(:func:`diet_guard._sync._pull_remote_budgets`) logs-and-skips on that,
|
||||
matching :func:`parse_remote_log`'s tolerance for a bad device file.
|
||||
|
||||
Raises:
|
||||
TypeError: If the top-level JSON isn't an object, or a record value
|
||||
isn't shaped as expected.
|
||||
KeyError: Via ``Record.from_dict``, if a record is missing an
|
||||
expected key.
|
||||
ValueError: Via ``json.loads`` on invalid JSON, or ``Hlc.from_str``
|
||||
on a malformed clock string.
|
||||
"""
|
||||
raw = json.loads(text)
|
||||
if not isinstance(raw, dict):
|
||||
msg = f"top-level budget payload is not a JSON object: {raw!r}"
|
||||
raise TypeError(msg)
|
||||
return {record_id: Record.from_dict(data) for record_id, data in raw.items()}
|
||||
|
||||
@ -29,6 +29,7 @@ import pytest
|
||||
|
||||
from diet_guard import (
|
||||
_gatelock,
|
||||
_gatelock_calendar,
|
||||
_gatelock_core,
|
||||
_gatelock_mealflow,
|
||||
_gatelock_nutrition,
|
||||
@ -135,10 +136,16 @@ class FakeVar:
|
||||
|
||||
|
||||
class FakeEntry:
|
||||
"""A functional one-line entry (delete clears, insert appends)."""
|
||||
"""A functional one-line entry (delete clears, insert appends).
|
||||
|
||||
``configure``/``config`` record their kwargs into ``configured``, so a
|
||||
test can assert on a read-only/editable state toggle the same way it
|
||||
would with :class:`FakeWidget`.
|
||||
"""
|
||||
|
||||
def __init__(self, *args: object, **kwargs: object) -> None:
|
||||
self._value = ""
|
||||
self.configured: dict[str, object] = dict(kwargs)
|
||||
|
||||
def get(self) -> str:
|
||||
return self._value
|
||||
@ -156,7 +163,7 @@ class FakeEntry:
|
||||
pass
|
||||
|
||||
def configure(self, *args: object, **kwargs: object) -> None:
|
||||
pass
|
||||
self.configured.update(kwargs)
|
||||
|
||||
config = configure
|
||||
|
||||
@ -204,10 +211,15 @@ class FakeListbox:
|
||||
|
||||
|
||||
class FakeWidget:
|
||||
"""A generic no-op widget for Frame/Label/Button/OptionMenu."""
|
||||
"""A generic no-op widget for Frame/Label/Button/OptionMenu.
|
||||
|
||||
``configure``/``config`` record their kwargs into ``configured`` (rather
|
||||
than discarding them) so a test can assert on a widget's last-set color
|
||||
or text, e.g. the calendar's per-cell status coloring.
|
||||
"""
|
||||
|
||||
def __init__(self, *args: object, **kwargs: object) -> None:
|
||||
pass
|
||||
self.configured: dict[str, object] = dict(kwargs)
|
||||
|
||||
def pack(self, *args: object, **kwargs: object) -> FakeWidget:
|
||||
return self
|
||||
@ -215,7 +227,11 @@ class FakeWidget:
|
||||
def place(self, *args: object, **kwargs: object) -> FakeWidget:
|
||||
return self
|
||||
|
||||
def grid(self, *args: object, **kwargs: object) -> FakeWidget:
|
||||
return self
|
||||
|
||||
def configure(self, *args: object, **kwargs: object) -> FakeWidget:
|
||||
self.configured.update(kwargs)
|
||||
return self
|
||||
|
||||
config = configure
|
||||
@ -223,6 +239,34 @@ class FakeWidget:
|
||||
def bind(self, *args: object, **kwargs: object) -> None:
|
||||
pass
|
||||
|
||||
def register(self, func: object) -> object:
|
||||
"""Stand in for Tk's Tcl-command registration: return the callable as-is."""
|
||||
return func
|
||||
|
||||
|
||||
class FakeNotebook(FakeWidget):
|
||||
"""A functional ``ttk.Notebook``: tracks added tabs and the selection."""
|
||||
|
||||
def __init__(self, *args: object, **kwargs: object) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self.tabs: list[tuple[object, str]] = []
|
||||
self._selected = 0
|
||||
|
||||
def add(self, child: object, *, text: str = "") -> None:
|
||||
self.tabs.append((child, text))
|
||||
|
||||
def select(self, tab_id: object = None) -> int | None:
|
||||
if tab_id is None:
|
||||
return self._selected
|
||||
if isinstance(tab_id, int):
|
||||
self._selected = tab_id
|
||||
else:
|
||||
for index, (child, _label) in enumerate(self.tabs):
|
||||
if child is tab_id:
|
||||
self._selected = index
|
||||
break
|
||||
return None
|
||||
|
||||
|
||||
_FAKE_TK = SimpleNamespace(
|
||||
END="end",
|
||||
@ -238,11 +282,14 @@ _FAKE_TK = SimpleNamespace(
|
||||
Event=object,
|
||||
)
|
||||
|
||||
_FAKE_TTK = SimpleNamespace(Notebook=FakeNotebook)
|
||||
|
||||
# Every mixin module the gate window is built from imports ``tkinter``
|
||||
# independently; all of them must see the fake so ``tk.TclError`` etc. are the
|
||||
# catchable ``_FakeTclError`` everywhere a test raises it.
|
||||
_GATE_TK_MODULES = (
|
||||
_gatelock,
|
||||
_gatelock_calendar,
|
||||
_gatelock_core,
|
||||
_gatelock_nutrition,
|
||||
_gatelock_mealflow,
|
||||
@ -256,6 +303,7 @@ def gate() -> Iterator[MealGate]:
|
||||
with ExitStack() as stack:
|
||||
for module in _GATE_TK_MODULES:
|
||||
stack.enter_context(patch.object(module, "tk", _FAKE_TK))
|
||||
stack.enter_context(patch.object(_gatelock_calendar, "ttk", _FAKE_TTK))
|
||||
yield MealGate(demo_mode=True)
|
||||
|
||||
|
||||
|
||||
@ -1,8 +1,7 @@
|
||||
"""Tests for _budget.py — the hidden, tamper-hardened daily budget."""
|
||||
"""Tests for _budget.py — the freely-editable, synced daily budget."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, cast
|
||||
@ -13,18 +12,15 @@ import pytest
|
||||
from diet_guard import _budget
|
||||
from diet_guard._budget import (
|
||||
Biometrics,
|
||||
BudgetLockedError,
|
||||
BudgetFileCorruptError,
|
||||
BudgetNotInitializedError,
|
||||
BudgetSealBrokenError,
|
||||
budget_weight,
|
||||
compute_target_budget,
|
||||
daily_budget,
|
||||
is_initialized,
|
||||
lock_command,
|
||||
mifflin_st_jeor_bmr,
|
||||
protein_target_g,
|
||||
seal_budget,
|
||||
unlock_command,
|
||||
write_budget,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@ -35,17 +31,16 @@ _BIO = Biometrics(weight_kg=80.0, height_cm=169.0, age_years=26.0, is_male=True)
|
||||
|
||||
|
||||
def _write_record(record: object) -> None:
|
||||
"""Write an arbitrary object as the seal file (for tamper tests)."""
|
||||
"""Write an arbitrary object as the budget file (for corruption tests)."""
|
||||
_budget.BUDGET_FILE.write_text(json.dumps(record), encoding="utf-8")
|
||||
|
||||
|
||||
def _budget_open_raises(exc: type[BaseException]) -> object:
|
||||
"""Patch ``Path.open`` to raise ``exc`` ONLY for the sealed-budget file.
|
||||
"""Patch ``Path.open`` to raise ``exc`` ONLY for the budget file.
|
||||
|
||||
``Path`` instances use ``__slots__`` so ``patch.object(BUDGET_FILE, "open")``
|
||||
fails; and patching ``Path.open`` wholesale would also break the unrelated
|
||||
HMAC-key read inside ``compute_entry_hmac``. Routing every other path to the
|
||||
real ``open`` keeps the failure surgically on the budget file.
|
||||
fails; routing every other path to the real ``open`` keeps the failure
|
||||
surgically on the budget file.
|
||||
|
||||
Args:
|
||||
exc: The exception type to raise when the budget file is opened.
|
||||
@ -53,8 +48,6 @@ def _budget_open_raises(exc: type[BaseException]) -> object:
|
||||
Returns:
|
||||
An unstarted ``patch`` context manager.
|
||||
"""
|
||||
# Capture the real opener as a permissive callable so forwarding the
|
||||
# patched-through args (typed ``object`` here) is not rejected on arg types.
|
||||
real_open = cast("Callable[..., Iterator[str]]", Path.open)
|
||||
|
||||
def fake_open(self: Path, *args: object, **kwargs: object) -> Iterator[str]:
|
||||
@ -89,7 +82,7 @@ class TestComputeTargetBudget:
|
||||
assert result == 2200
|
||||
|
||||
def test_floored_to_minimum(self) -> None:
|
||||
"""An absurd deficit cannot seal a starvation-level budget."""
|
||||
"""An absurd deficit cannot compute a starvation-level budget."""
|
||||
result = compute_target_budget(_BIO, activity_factor=1.0, deficit_kcal=5000)
|
||||
assert result == _budget._MIN_SANE_BUDGET
|
||||
|
||||
@ -100,42 +93,34 @@ class TestExceptions:
|
||||
def test_messages(self) -> None:
|
||||
"""Constructors set a non-empty message with no arguments."""
|
||||
assert str(BudgetNotInitializedError())
|
||||
assert str(BudgetSealBrokenError())
|
||||
assert str(BudgetLockedError())
|
||||
assert str(BudgetFileCorruptError())
|
||||
|
||||
|
||||
class TestSealAndRead:
|
||||
"""Round-tripping the sealed budget."""
|
||||
class TestWriteAndRead:
|
||||
"""Round-tripping the plainly-written budget."""
|
||||
|
||||
def test_roundtrip(self) -> None:
|
||||
"""A sealed value reads back exactly."""
|
||||
seal_budget(2000)
|
||||
"""A written value reads back exactly."""
|
||||
write_budget(2000)
|
||||
assert daily_budget() == 2000
|
||||
|
||||
def test_is_initialized(self) -> None:
|
||||
"""is_initialized reflects whether the file exists."""
|
||||
assert not is_initialized()
|
||||
seal_budget(2000)
|
||||
write_budget(2000)
|
||||
assert is_initialized()
|
||||
|
||||
def test_file_is_not_plaintext(self) -> None:
|
||||
"""The number is base64-wrapped, not stored as a bare integer."""
|
||||
seal_budget(2345)
|
||||
raw = _budget.BUDGET_FILE.read_text(encoding="utf-8")
|
||||
assert "2345" not in raw
|
||||
def test_file_is_plain_json(self) -> None:
|
||||
"""The number is stored in the open, readable format -- no wrapping."""
|
||||
write_budget(2345)
|
||||
record = json.loads(_budget.BUDGET_FILE.read_text(encoding="utf-8"))
|
||||
assert record["b"] == 2345
|
||||
|
||||
def test_unsigned_accepted_when_no_key(self) -> None:
|
||||
"""With no HMAC key, an unsigned seal is written and accepted."""
|
||||
with patch.object(_budget, "compute_entry_hmac", return_value=None):
|
||||
seal_budget(1800)
|
||||
record = json.loads(_budget.BUDGET_FILE.read_text(encoding="utf-8"))
|
||||
assert "hmac" not in record
|
||||
assert daily_budget() == 1800
|
||||
|
||||
def test_locked_file_raises(self) -> None:
|
||||
"""An unwritable (immutable) file surfaces as BudgetLockedError."""
|
||||
with _budget_open_raises(PermissionError), pytest.raises(BudgetLockedError):
|
||||
seal_budget(2000)
|
||||
def test_overwrite_replaces_the_value(self) -> None:
|
||||
"""Writing again replaces the previous value, with no extra ritual."""
|
||||
write_budget(2000)
|
||||
write_budget(1800)
|
||||
assert daily_budget() == 1800
|
||||
|
||||
|
||||
class TestReadFailures:
|
||||
@ -147,126 +132,90 @@ class TestReadFailures:
|
||||
daily_budget()
|
||||
|
||||
def test_unreadable_file(self) -> None:
|
||||
"""An OSError while reading surfaces as a broken seal."""
|
||||
seal_budget(2000)
|
||||
with _budget_open_raises(OSError), pytest.raises(BudgetSealBrokenError):
|
||||
"""An OSError while reading surfaces as a corrupt file."""
|
||||
write_budget(2000)
|
||||
with _budget_open_raises(OSError), pytest.raises(BudgetFileCorruptError):
|
||||
daily_budget()
|
||||
|
||||
def test_invalid_json(self) -> None:
|
||||
"""Garbage content -> broken seal."""
|
||||
"""Garbage content -> corrupt file."""
|
||||
_budget.BUDGET_FILE.write_text("not json", encoding="utf-8")
|
||||
with pytest.raises(BudgetSealBrokenError):
|
||||
with pytest.raises(BudgetFileCorruptError):
|
||||
daily_budget()
|
||||
|
||||
def test_record_not_dict(self) -> None:
|
||||
"""A non-object top level -> broken seal."""
|
||||
"""A non-object top level -> corrupt file."""
|
||||
_write_record([1, 2, 3])
|
||||
with pytest.raises(BudgetSealBrokenError):
|
||||
daily_budget()
|
||||
|
||||
def test_data_not_string(self) -> None:
|
||||
"""A non-string data field -> broken seal."""
|
||||
_write_record({"data": 123})
|
||||
with pytest.raises(BudgetSealBrokenError):
|
||||
daily_budget()
|
||||
|
||||
def test_bad_base64(self) -> None:
|
||||
"""Undecodable base64 -> broken seal."""
|
||||
_write_record({"data": "!!!not base64!!!"})
|
||||
with pytest.raises(BudgetSealBrokenError):
|
||||
daily_budget()
|
||||
|
||||
def test_inner_not_dict(self) -> None:
|
||||
"""base64 that decodes to a non-object -> broken seal."""
|
||||
inner = base64.b64encode(b"[1,2,3]").decode("ascii")
|
||||
_write_record({"data": inner})
|
||||
with pytest.raises(BudgetSealBrokenError):
|
||||
daily_budget()
|
||||
|
||||
def test_tampered_signature(self) -> None:
|
||||
"""A forged value with a bad signature is rejected."""
|
||||
forged = base64.b64encode(b'{"b":9999,"v":1}').decode("ascii")
|
||||
_write_record({"data": forged, "hmac": "deadbeef"})
|
||||
with pytest.raises(BudgetSealBrokenError):
|
||||
daily_budget()
|
||||
|
||||
def test_unsigned_rejected_when_key_available(self) -> None:
|
||||
"""A stripped signature on a keyed system means tampering."""
|
||||
valid = base64.b64encode(b'{"b":2000,"v":1}').decode("ascii")
|
||||
_write_record({"data": valid}) # no hmac, but a key exists
|
||||
with pytest.raises(BudgetSealBrokenError):
|
||||
daily_budget()
|
||||
|
||||
def test_signature_present_but_key_missing(self) -> None:
|
||||
"""A signed seal cannot be verified once the key is gone."""
|
||||
seal_budget(2000)
|
||||
with (
|
||||
patch.object(
|
||||
_budget,
|
||||
"compute_entry_hmac",
|
||||
return_value=None,
|
||||
),
|
||||
pytest.raises(BudgetSealBrokenError),
|
||||
):
|
||||
with pytest.raises(BudgetFileCorruptError):
|
||||
daily_budget()
|
||||
|
||||
def test_non_integer_value(self) -> None:
|
||||
"""A non-integer budget (here a bool) is rejected."""
|
||||
# Sign a record whose inner "b" is a bool, so the signature is valid but
|
||||
# the value type is wrong.
|
||||
inner = {"v": 1, "b": True}
|
||||
blob = json.dumps(inner, sort_keys=True, separators=(",", ":")).encode()
|
||||
record = {
|
||||
"data": base64.b64encode(blob).decode("ascii"),
|
||||
"hmac": _budget.compute_entry_hmac(inner),
|
||||
}
|
||||
_write_record(record)
|
||||
with pytest.raises(BudgetSealBrokenError):
|
||||
_write_record({"v": 2, "b": True})
|
||||
with pytest.raises(BudgetFileCorruptError):
|
||||
daily_budget()
|
||||
|
||||
|
||||
class TestWeightAndProtein:
|
||||
"""The v2 stored weight and the protein target derived from it."""
|
||||
"""The stored weight and the protein target derived from it."""
|
||||
|
||||
def test_seal_with_weight_roundtrips(self) -> None:
|
||||
"""A weight sealed alongside the budget reads back."""
|
||||
seal_budget(2200, weight_kg=80.0)
|
||||
def test_write_with_weight_roundtrips(self) -> None:
|
||||
"""A weight written alongside the budget reads back."""
|
||||
write_budget(2200, weight_kg=80.0)
|
||||
assert daily_budget() == 2200
|
||||
assert budget_weight() == pytest.approx(80.0)
|
||||
|
||||
def test_protein_target_from_weight(self) -> None:
|
||||
"""The protein target is weight x the per-kg constant."""
|
||||
seal_budget(2200, weight_kg=80.0)
|
||||
write_budget(2200, weight_kg=80.0)
|
||||
expected = round(80.0 * _budget.PROTEIN_G_PER_KG, 1)
|
||||
assert protein_target_g() == pytest.approx(expected)
|
||||
|
||||
def test_v1_seal_has_no_weight(self) -> None:
|
||||
"""A budget sealed without a weight exposes no weight or protein target."""
|
||||
seal_budget(2000)
|
||||
def test_no_weight_has_no_protein_target(self) -> None:
|
||||
"""A budget written without a weight exposes no weight or target."""
|
||||
write_budget(2000)
|
||||
assert budget_weight() is None
|
||||
assert protein_target_g() is None
|
||||
|
||||
def test_protein_target_none_when_uninitialized(self) -> None:
|
||||
"""With nothing sealed, the protein target is quietly None, not an error."""
|
||||
"""With no budget written, the protein target is quietly None."""
|
||||
assert protein_target_g() is None
|
||||
|
||||
def test_budget_weight_rejects_non_numeric(self) -> None:
|
||||
"""A validly-signed but non-numeric weight yields None, not a crash."""
|
||||
inner = {"v": 2, "b": 2000, "w": True}
|
||||
blob = json.dumps(inner, sort_keys=True, separators=(",", ":")).encode()
|
||||
record = {
|
||||
"data": base64.b64encode(blob).decode("ascii"),
|
||||
"hmac": _budget.compute_entry_hmac(inner),
|
||||
}
|
||||
_write_record(record)
|
||||
"""A non-numeric weight yields None, not a crash."""
|
||||
_write_record({"v": 2, "b": 2000, "w": True})
|
||||
assert budget_weight() is None
|
||||
|
||||
|
||||
class TestCommands:
|
||||
"""The chattr helper strings."""
|
||||
class TestRawRecord:
|
||||
"""The sync-only raw-record read/write pair, which never raises."""
|
||||
|
||||
def test_lock_unlock_commands(self) -> None:
|
||||
"""Both reference the budget path with the right chattr flag."""
|
||||
assert lock_command().startswith("sudo chattr +i ")
|
||||
assert unlock_command().startswith("sudo chattr -i ")
|
||||
assert str(_budget.BUDGET_FILE) in lock_command()
|
||||
def test_missing_file_returns_none(self) -> None:
|
||||
assert _budget.read_raw_record() is None
|
||||
|
||||
def test_roundtrips_a_written_record(self) -> None:
|
||||
_budget.write_raw_record({"v": 2, "b": 1900, "t": "2026-01-01T00:00:00"})
|
||||
assert _budget.read_raw_record() == {
|
||||
"v": 2,
|
||||
"b": 1900,
|
||||
"t": "2026-01-01T00:00:00",
|
||||
}
|
||||
|
||||
def test_unreadable_file_returns_none(self) -> None:
|
||||
write_budget(2000)
|
||||
with _budget_open_raises(OSError):
|
||||
assert _budget.read_raw_record() is None
|
||||
|
||||
def test_invalid_json_returns_none(self) -> None:
|
||||
_budget.BUDGET_FILE.write_text("not json", encoding="utf-8")
|
||||
assert _budget.read_raw_record() is None
|
||||
|
||||
def test_non_dict_record_returns_none(self) -> None:
|
||||
_write_record([1, 2, 3])
|
||||
assert _budget.read_raw_record() is None
|
||||
|
||||
def test_write_budget_stamps_an_edit_timestamp(self) -> None:
|
||||
write_budget(2000)
|
||||
record = _budget.read_raw_record()
|
||||
assert record is not None
|
||||
assert record["t"]
|
||||
|
||||
126
diet_guard/tests/test_calendar_view.py
Normal file
126
diet_guard/tests/test_calendar_view.py
Normal file
@ -0,0 +1,126 @@
|
||||
"""Tests for the pure (no-Tk) calendar grid math and status text."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
from diet_guard._calendar_view import (
|
||||
CalendarCell,
|
||||
build_month_cells,
|
||||
cell_style,
|
||||
streaks_text,
|
||||
ytd_text,
|
||||
)
|
||||
from diet_guard._daystatus import DayStatus
|
||||
|
||||
|
||||
class TestBuildMonthCells:
|
||||
"""Boundary matrix for :func:`build_month_cells`."""
|
||||
|
||||
def test_blank_cells_outside_the_month_have_no_day_or_status(self) -> None:
|
||||
# July 2026 starts on a Wednesday, so the first row has leading blanks.
|
||||
weeks = build_month_cells(2026, 7, {}, today=date(2026, 7, 31))
|
||||
assert weeks[0][0] == CalendarCell(day=None, status=None)
|
||||
|
||||
def test_a_day_present_in_the_status_map_carries_its_status(self) -> None:
|
||||
weeks = build_month_cells(
|
||||
2026,
|
||||
7,
|
||||
{"2026-07-15": DayStatus.GREEN},
|
||||
today=date(2026, 7, 31),
|
||||
)
|
||||
cell = next(c for week in weeks for c in week if c.day == 15)
|
||||
assert cell.status is DayStatus.GREEN
|
||||
|
||||
def test_a_past_day_absent_from_the_status_map_is_not_logged(self) -> None:
|
||||
weeks = build_month_cells(2026, 7, {}, today=date(2026, 7, 31))
|
||||
cell = next(c for week in weeks for c in week if c.day == 1)
|
||||
assert cell.status is DayStatus.NOT_LOGGED
|
||||
|
||||
def test_a_future_day_is_neutral_not_not_logged(self) -> None:
|
||||
weeks = build_month_cells(2026, 7, {}, today=date(2026, 7, 10))
|
||||
cell = next(c for week in weeks for c in week if c.day == 15)
|
||||
assert cell.status is None
|
||||
|
||||
def test_today_itself_is_classified_not_treated_as_future(self) -> None:
|
||||
weeks = build_month_cells(
|
||||
2026,
|
||||
7,
|
||||
{"2026-07-10": DayStatus.RED},
|
||||
today=date(2026, 7, 10),
|
||||
)
|
||||
cell = next(c for week in weeks for c in week if c.day == 10)
|
||||
assert cell.status is DayStatus.RED
|
||||
|
||||
def test_none_status_map_renders_every_past_day_neutral(self) -> None:
|
||||
weeks = build_month_cells(2026, 7, None, today=date(2026, 7, 31))
|
||||
cell = next(c for week in weeks for c in week if c.day == 1)
|
||||
assert cell.status is None
|
||||
|
||||
def test_defaults_today_to_the_real_current_date(self) -> None:
|
||||
# No explicit `today` -- exercises the now_local() fallback branch.
|
||||
weeks = build_month_cells(2020, 1, {})
|
||||
assert weeks[0][0].day in (None, 1)
|
||||
|
||||
def test_a_28_day_february_produces_four_or_five_weeks(self) -> None:
|
||||
weeks = build_month_cells(2026, 2, {}, today=date(2026, 2, 28))
|
||||
assert len(weeks) in (4, 5)
|
||||
|
||||
def test_a_31_day_month_starting_on_saturday_produces_six_weeks(self) -> None:
|
||||
# August 2026 starts on a Saturday and has 31 days -> 6 rows.
|
||||
weeks = build_month_cells(2026, 8, {}, today=date(2026, 8, 31))
|
||||
assert len(weeks) == 6
|
||||
|
||||
def test_every_day_of_the_month_is_present_exactly_once(self) -> None:
|
||||
weeks = build_month_cells(2026, 7, {}, today=date(2026, 7, 31))
|
||||
days = [cell.day for week in weeks for cell in week if cell.day is not None]
|
||||
assert days == list(range(1, 32))
|
||||
|
||||
|
||||
class TestCellStyle:
|
||||
"""Boundary matrix for :func:`cell_style`."""
|
||||
|
||||
def test_none_status_is_neutral(self) -> None:
|
||||
bg, _fg, outline = cell_style(None)
|
||||
assert bg == outline
|
||||
|
||||
def test_not_logged_is_black_with_a_visible_outline(self) -> None:
|
||||
bg, _fg, outline = cell_style(DayStatus.NOT_LOGGED)
|
||||
assert bg == "#000000"
|
||||
assert outline != bg
|
||||
|
||||
def test_green_yellow_red_each_style_distinctly(self) -> None:
|
||||
styles = {
|
||||
status: cell_style(status)
|
||||
for status in (DayStatus.GREEN, DayStatus.YELLOW, DayStatus.RED)
|
||||
}
|
||||
assert len({style[0] for style in styles.values()}) == 3
|
||||
|
||||
|
||||
class TestStreaksText:
|
||||
""":func:`streaks_text` boundary cases (singular/plural, zero)."""
|
||||
|
||||
def test_zero_streaks_render_as_zero_days(self) -> None:
|
||||
text = streaks_text({}, today=date(2026, 7, 13))
|
||||
assert "0 days" in text
|
||||
|
||||
def test_a_one_day_streak_uses_the_singular(self) -> None:
|
||||
text = streaks_text(
|
||||
{"2026-07-13": DayStatus.GREEN},
|
||||
today=date(2026, 7, 13),
|
||||
)
|
||||
assert "Logging streak: 1 day " in text
|
||||
assert "Adherence streak: 1 day" in text
|
||||
|
||||
|
||||
class TestYtdText:
|
||||
""":func:`ytd_text` renders the tally's three counts."""
|
||||
|
||||
def test_reports_logged_elapsed_and_adherent_counts(self) -> None:
|
||||
status_map_ = {
|
||||
"2026-01-01": DayStatus.GREEN,
|
||||
"2026-01-02": DayStatus.RED,
|
||||
}
|
||||
text = ytd_text(status_map_, today=date(2026, 1, 10))
|
||||
assert "logged 2/10 days" in text
|
||||
assert "1 within budget" in text
|
||||
@ -13,10 +13,9 @@ from unittest.mock import patch
|
||||
|
||||
from diet_guard import _cli
|
||||
from diet_guard._budget import (
|
||||
BudgetLockedError,
|
||||
BudgetFileCorruptError,
|
||||
BudgetNotInitializedError,
|
||||
BudgetSealBrokenError,
|
||||
seal_budget,
|
||||
write_budget,
|
||||
)
|
||||
from diet_guard._cli import _eaten_grams, _Portion, main
|
||||
from diet_guard._estimator import Nutrition
|
||||
@ -60,15 +59,15 @@ class TestEatenGrams:
|
||||
|
||||
|
||||
class TestInit:
|
||||
"""The budget-sealing init command."""
|
||||
"""The budget-setting init command."""
|
||||
|
||||
def test_valid_male(
|
||||
self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""Valid inputs seal a budget and print the lock hint, not the number."""
|
||||
"""Valid inputs write a budget and print the computed value."""
|
||||
_feed(monkeypatch, _VALID_INIT)
|
||||
assert main(["init"]) == 0
|
||||
assert "sealed" in capsys.readouterr().out
|
||||
assert "computed" in capsys.readouterr().out
|
||||
|
||||
def test_valid_female(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""The female sex branch is accepted."""
|
||||
@ -76,24 +75,15 @@ class TestInit:
|
||||
assert main(["init"]) == 0
|
||||
|
||||
def test_non_number_aborts(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""A non-numeric input seals nothing and returns the error code."""
|
||||
"""A non-numeric input sets nothing and returns the error code."""
|
||||
_feed(monkeypatch, "heavy\n")
|
||||
assert main(["init"]) == 2
|
||||
|
||||
def test_bad_sex_aborts(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""An unrecognised sex answer seals nothing."""
|
||||
"""An unrecognised sex answer sets nothing."""
|
||||
_feed(monkeypatch, "80\n169\n26\nx\n1.375\n180\n")
|
||||
assert main(["init"]) == 2
|
||||
|
||||
def test_locked_budget(
|
||||
self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""A locked file surfaces the unlock instructions and a failure code."""
|
||||
_feed(monkeypatch, _VALID_INIT)
|
||||
with patch.object(_cli, "seal_budget", side_effect=BudgetLockedError):
|
||||
assert main(["init"]) == 1
|
||||
assert "locked" in capsys.readouterr().out
|
||||
|
||||
|
||||
class TestSummary:
|
||||
"""The budget-remaining summary line."""
|
||||
@ -104,15 +94,15 @@ class TestSummary:
|
||||
_cli._print_summary()
|
||||
assert "budget not set" in capsys.readouterr().out
|
||||
|
||||
def test_seal_broken(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""A broken seal is reported plainly."""
|
||||
with patch.object(_cli, "daily_budget", side_effect=BudgetSealBrokenError):
|
||||
def test_file_corrupt(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""A corrupt budget file is reported plainly."""
|
||||
with patch.object(_cli, "daily_budget", side_effect=BudgetFileCorruptError):
|
||||
_cli._print_summary()
|
||||
assert "seal broken" in capsys.readouterr().out
|
||||
assert "corrupt" in capsys.readouterr().out
|
||||
|
||||
def test_remaining_shown(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""A valid budget prints how much is left."""
|
||||
seal_budget(2000)
|
||||
write_budget(2000)
|
||||
_cli._print_summary()
|
||||
assert "left" in capsys.readouterr().out
|
||||
|
||||
@ -122,7 +112,7 @@ class TestAte:
|
||||
|
||||
def test_logs_and_summarizes(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""A resolved meal is logged, banked, and summarized."""
|
||||
seal_budget(2000)
|
||||
write_budget(2000)
|
||||
with patch.object(_cli, "resolve_nutrition", return_value=_NUT):
|
||||
assert main(["ate", "big mac"]) == 0
|
||||
assert "logged:" in capsys.readouterr().out
|
||||
@ -131,7 +121,7 @@ class TestAte:
|
||||
self, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""An assumed per-item weight prints its caveat."""
|
||||
seal_budget(2000)
|
||||
write_budget(2000)
|
||||
with patch.object(_cli, "resolve_nutrition", return_value=_NUT):
|
||||
main(["ate", "mystery", "--count", "3"])
|
||||
assert "assumed" in capsys.readouterr().out
|
||||
@ -148,7 +138,7 @@ class TestStatus:
|
||||
|
||||
def test_status_with_entries(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""Logged entries, slots, summary, and macros all print."""
|
||||
seal_budget(2000)
|
||||
write_budget(2000)
|
||||
main(["ate", "lunch", "--kcal", "500"])
|
||||
capsys.readouterr()
|
||||
assert main(["status"]) == 0
|
||||
@ -158,7 +148,7 @@ class TestStatus:
|
||||
|
||||
def test_status_empty(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""With nothing logged, status still prints the slot/summary lines."""
|
||||
seal_budget(2000)
|
||||
write_budget(2000)
|
||||
assert main(["status"]) == 0
|
||||
assert "slots:" in capsys.readouterr().out
|
||||
|
||||
@ -201,7 +191,7 @@ class TestUndo:
|
||||
|
||||
def test_undo_removes_entry(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""Undo removes and reports the last entry."""
|
||||
seal_budget(2000)
|
||||
write_budget(2000)
|
||||
main(["ate", "snack", "--kcal", "100"])
|
||||
capsys.readouterr()
|
||||
assert main(["undo"]) == 0
|
||||
|
||||
185
diet_guard/tests/test_daystatus.py
Normal file
185
diet_guard/tests/test_daystatus.py
Normal file
@ -0,0 +1,185 @@
|
||||
"""Tests for diet_guard._daystatus: status classification, streaks, tallies."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
from diet_guard import _budget, _daystatus, _state
|
||||
from diet_guard._daystatus import DayStatus
|
||||
from diet_guard._estimator import Nutrition
|
||||
|
||||
|
||||
def _entry(kcal: float) -> dict[str, object]:
|
||||
return {"kcal": kcal}
|
||||
|
||||
|
||||
class TestDayTotalKcal:
|
||||
def test_sums_entries(self) -> None:
|
||||
log = {"2026-01-01": [_entry(100), _entry(50)]}
|
||||
assert _daystatus.day_total_kcal(log, "2026-01-01") == 150
|
||||
|
||||
def test_missing_day_is_zero(self) -> None:
|
||||
assert _daystatus.day_total_kcal({}, "2026-01-01") == 0
|
||||
|
||||
|
||||
class TestDayStatus:
|
||||
def test_missing_day_is_not_logged(self) -> None:
|
||||
assert _daystatus.day_status({}, "2026-01-01", 2000) == DayStatus.NOT_LOGGED
|
||||
|
||||
def test_exactly_at_budget_is_green(self) -> None:
|
||||
log = {"2026-01-01": [_entry(2000)]}
|
||||
assert _daystatus.day_status(log, "2026-01-01", 2000) == DayStatus.GREEN
|
||||
|
||||
def test_under_budget_is_green(self) -> None:
|
||||
log = {"2026-01-01": [_entry(1000)]}
|
||||
assert _daystatus.day_status(log, "2026-01-01", 2000) == DayStatus.GREEN
|
||||
|
||||
def test_just_over_budget_is_yellow(self) -> None:
|
||||
log = {"2026-01-01": [_entry(2001)]}
|
||||
assert _daystatus.day_status(log, "2026-01-01", 2000) == DayStatus.YELLOW
|
||||
|
||||
def test_exactly_at_yellow_ceiling_is_yellow(self) -> None:
|
||||
# 2400 sits right at the yellow ceiling for a 2000 budget (120%).
|
||||
log = {"2026-01-01": [_entry(2400)]}
|
||||
assert _daystatus.day_status(log, "2026-01-01", 2000) == DayStatus.YELLOW
|
||||
|
||||
def test_just_over_yellow_ceiling_is_red(self) -> None:
|
||||
log = {"2026-01-01": [_entry(2400.01)]}
|
||||
assert _daystatus.day_status(log, "2026-01-01", 2000) == DayStatus.RED
|
||||
|
||||
def test_way_over_budget_is_red(self) -> None:
|
||||
log = {"2026-01-01": [_entry(5000)]}
|
||||
assert _daystatus.day_status(log, "2026-01-01", 2000) == DayStatus.RED
|
||||
|
||||
def test_day_with_only_tombstoned_entries_is_not_logged(self) -> None:
|
||||
# A tombstoned entry is kept on disk but load_log() drops it, so the
|
||||
# day is entirely absent from the filtered log rather than present
|
||||
# with an empty list -- confirm that reads as NOT_LOGGED, not GREEN.
|
||||
_state.log_meal("snack", Nutrition(300, 5, 40, 10, 100, "manual"))
|
||||
_state.undo_last_today()
|
||||
today = _state._today()
|
||||
assert today not in _state.load_log()
|
||||
assert (
|
||||
_daystatus.day_status(_state.load_log(), today, 2000)
|
||||
== DayStatus.NOT_LOGGED
|
||||
)
|
||||
|
||||
|
||||
class TestStatusMap:
|
||||
def test_maps_every_present_day(self) -> None:
|
||||
log = {
|
||||
"2026-01-01": [_entry(1000)],
|
||||
"2026-01-02": [_entry(5000)],
|
||||
}
|
||||
result = _daystatus.status_map(log, budget=2000)
|
||||
assert result == {
|
||||
"2026-01-01": DayStatus.GREEN,
|
||||
"2026-01-02": DayStatus.RED,
|
||||
}
|
||||
|
||||
def test_empty_log_is_empty_map(self) -> None:
|
||||
assert _daystatus.status_map({}, budget=2000) == {}
|
||||
|
||||
|
||||
class TestLoggingStreak:
|
||||
def test_empty_map_is_zero(self) -> None:
|
||||
assert _daystatus.logging_streak({}, today=date(2026, 1, 5)) == 0
|
||||
|
||||
def test_counts_consecutive_logged_days_including_today(self) -> None:
|
||||
sm = {
|
||||
"2026-01-03": DayStatus.GREEN,
|
||||
"2026-01-04": DayStatus.RED,
|
||||
"2026-01-05": DayStatus.YELLOW,
|
||||
}
|
||||
assert _daystatus.logging_streak(sm, today=date(2026, 1, 5)) == 3
|
||||
|
||||
def test_breaks_on_gap(self) -> None:
|
||||
sm = {
|
||||
"2026-01-01": DayStatus.GREEN,
|
||||
"2026-01-03": DayStatus.GREEN,
|
||||
"2026-01-04": DayStatus.GREEN,
|
||||
}
|
||||
assert _daystatus.logging_streak(sm, today=date(2026, 1, 4)) == 2
|
||||
|
||||
def test_today_not_logged_is_not_a_break(self) -> None:
|
||||
sm = {
|
||||
"2026-01-03": DayStatus.GREEN,
|
||||
"2026-01-04": DayStatus.YELLOW,
|
||||
}
|
||||
# today (01-05) has no entry yet -- should not zero out the streak.
|
||||
assert _daystatus.logging_streak(sm, today=date(2026, 1, 5)) == 2
|
||||
|
||||
def test_yesterday_not_logged_breaks_streak_even_if_today_is(self) -> None:
|
||||
sm = {
|
||||
"2026-01-03": DayStatus.GREEN,
|
||||
"2026-01-05": DayStatus.GREEN,
|
||||
}
|
||||
assert _daystatus.logging_streak(sm, today=date(2026, 1, 5)) == 1
|
||||
|
||||
|
||||
class TestAdherenceStreak:
|
||||
def test_counts_consecutive_green_and_yellow(self) -> None:
|
||||
sm = {
|
||||
"2026-01-03": DayStatus.GREEN,
|
||||
"2026-01-04": DayStatus.YELLOW,
|
||||
"2026-01-05": DayStatus.GREEN,
|
||||
}
|
||||
assert _daystatus.adherence_streak(sm, today=date(2026, 1, 5)) == 3
|
||||
|
||||
def test_red_today_breaks_streak_immediately(self) -> None:
|
||||
sm = {
|
||||
"2026-01-04": DayStatus.GREEN,
|
||||
"2026-01-05": DayStatus.RED,
|
||||
}
|
||||
assert _daystatus.adherence_streak(sm, today=date(2026, 1, 5)) == 0
|
||||
|
||||
def test_not_logged_today_is_not_a_break(self) -> None:
|
||||
sm = {"2026-01-04": DayStatus.GREEN}
|
||||
assert _daystatus.adherence_streak(sm, today=date(2026, 1, 5)) == 1
|
||||
|
||||
def test_red_in_history_breaks_streak(self) -> None:
|
||||
sm = {
|
||||
"2026-01-03": DayStatus.GREEN,
|
||||
"2026-01-04": DayStatus.RED,
|
||||
"2026-01-05": DayStatus.GREEN,
|
||||
}
|
||||
assert _daystatus.adherence_streak(sm, today=date(2026, 1, 5)) == 1
|
||||
|
||||
|
||||
class TestYearToDateTally:
|
||||
def test_counts_logged_and_adherent_days_this_year_only(self) -> None:
|
||||
sm = {
|
||||
"2026-01-01": DayStatus.GREEN,
|
||||
"2026-01-02": DayStatus.RED,
|
||||
"2026-01-03": DayStatus.NOT_LOGGED,
|
||||
"2025-12-31": DayStatus.GREEN, # previous year, excluded
|
||||
}
|
||||
tally = _daystatus.year_to_date_tally(sm, today=date(2026, 1, 3))
|
||||
assert tally.logged_days == 2
|
||||
assert tally.elapsed_days == 3
|
||||
assert tally.adherent_days == 1
|
||||
|
||||
def test_future_day_in_map_excluded(self) -> None:
|
||||
sm = {
|
||||
"2026-01-01": DayStatus.GREEN,
|
||||
"2026-06-01": DayStatus.GREEN,
|
||||
}
|
||||
tally = _daystatus.year_to_date_tally(sm, today=date(2026, 1, 1))
|
||||
assert tally.logged_days == 1
|
||||
assert tally.elapsed_days == 1
|
||||
assert tally.adherent_days == 1
|
||||
|
||||
def test_empty_map(self) -> None:
|
||||
tally = _daystatus.year_to_date_tally({}, today=date(2026, 3, 1))
|
||||
assert tally.logged_days == 0
|
||||
assert tally.elapsed_days == 60
|
||||
assert tally.adherent_days == 0
|
||||
|
||||
|
||||
class TestCurrentStatusMap:
|
||||
def test_reads_real_log_and_budget(self) -> None:
|
||||
_budget.write_budget(2000)
|
||||
_state.log_meal("lunch", Nutrition(500, 30, 40, 10, 100, "manual"))
|
||||
result = _daystatus.current_status_map()
|
||||
assert len(result) == 1
|
||||
assert next(iter(result.values())) == DayStatus.GREEN
|
||||
@ -20,7 +20,7 @@ from diet_guard import (
|
||||
_gatelock_support,
|
||||
_gatelock_ui,
|
||||
)
|
||||
from diet_guard._budget import seal_budget
|
||||
from diet_guard._budget import write_budget
|
||||
from diet_guard._gatelock import (
|
||||
MealGate,
|
||||
_pending_slots,
|
||||
@ -206,7 +206,7 @@ class TestBasisAndAmount:
|
||||
|
||||
def test_projection_else_without_item(self, gate: MealGate) -> None:
|
||||
"""With a budget but no priced item, no after-this-item is shown."""
|
||||
seal_budget(2000)
|
||||
write_budget(2000)
|
||||
gate._refresh_projection()
|
||||
text = gate._vars.projection.get()
|
||||
assert "left" in text
|
||||
|
||||
208
diet_guard/tests/test_gatelock_calendar.py
Normal file
208
diet_guard/tests/test_gatelock_calendar.py
Normal file
@ -0,0 +1,208 @@
|
||||
"""Tests for the History tab (_GateCalendar): notebook wiring, no-bypass
|
||||
regression, budget-degrade, month navigation, and budget editing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import patch
|
||||
|
||||
from diet_guard import _budget
|
||||
from diet_guard._budget import budget_weight, daily_budget, write_budget
|
||||
from diet_guard._state import log_meal
|
||||
from diet_guard.tests.conftest import _nutrition
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from diet_guard._gatelock import MealGate
|
||||
|
||||
|
||||
class TestNotebookWiring:
|
||||
"""The gate builds a two-tab notebook without breaking construction."""
|
||||
|
||||
def test_builds_a_notebook_with_both_tabs(self, gate: MealGate) -> None:
|
||||
labels = [text for _child, text in gate._notebook.tabs]
|
||||
assert labels == ["Log Meal", "History"]
|
||||
|
||||
def test_calendar_state_starts_on_the_current_month(self, gate: MealGate) -> None:
|
||||
assert 1 <= gate._cal_month <= 12
|
||||
assert gate._cal_year >= 2024
|
||||
|
||||
|
||||
class TestNoBypassRegression:
|
||||
"""Switching to the History tab must never affect the lock's dismissal."""
|
||||
|
||||
def test_switching_to_the_history_tab_does_not_touch_pending_or_close(
|
||||
self,
|
||||
gate: MealGate,
|
||||
) -> None:
|
||||
pending_before = list(gate._pending)
|
||||
history_frame = gate._cal_widgets.frame
|
||||
with patch.object(gate, "close") as mock_close:
|
||||
gate._notebook.select(history_frame)
|
||||
assert gate._notebook.select() == 1
|
||||
assert gate._pending == pending_before
|
||||
mock_close.assert_not_called()
|
||||
|
||||
def test_switching_back_to_the_log_meal_tab_is_also_a_no_op(
|
||||
self,
|
||||
gate: MealGate,
|
||||
) -> None:
|
||||
pending_before = list(gate._pending)
|
||||
with patch.object(gate, "close") as mock_close:
|
||||
gate._notebook.select(gate._cal_widgets.frame)
|
||||
gate._notebook.select(gate._widgets.frame)
|
||||
assert gate._notebook.select() == 0
|
||||
assert gate._pending == pending_before
|
||||
mock_close.assert_not_called()
|
||||
|
||||
|
||||
class TestBudgetDefaultAndDegradation:
|
||||
"""No budget ever set defaults to 2200; a corrupt file still degrades."""
|
||||
|
||||
def test_no_budget_set_computes_real_streaks_against_the_default(
|
||||
self,
|
||||
gate: MealGate,
|
||||
) -> None:
|
||||
gate._refresh_calendar()
|
||||
assert "Logging streak" in gate._cal_vars.streaks.get()
|
||||
assert "This year" in gate._cal_vars.ytd.get()
|
||||
|
||||
def test_no_budget_set_shows_2200_in_the_entry(self, gate: MealGate) -> None:
|
||||
gate._refresh_calendar()
|
||||
assert gate._cal_widgets.budget_entry.get() == "2200"
|
||||
|
||||
def test_entry_starts_read_only(self, gate: MealGate) -> None:
|
||||
assert gate._cal_widgets.budget_entry.configured.get("state") == "readonly"
|
||||
|
||||
def test_edit_button_starts_labelled_edit(self, gate: MealGate) -> None:
|
||||
assert gate._cal_widgets.budget_edit_button.configured.get("text") == "Edit"
|
||||
|
||||
def test_no_budget_set_still_renders_every_grid_cell(
|
||||
self,
|
||||
gate: MealGate,
|
||||
) -> None:
|
||||
gate._refresh_calendar()
|
||||
for cell in gate._cal_widgets.day_cells:
|
||||
assert "bg" in cell.configured
|
||||
|
||||
def test_a_real_budget_set_shows_in_the_entry(self, gate: MealGate) -> None:
|
||||
write_budget(1800)
|
||||
log_meal("oatmeal", _nutrition(kcal=300), None)
|
||||
gate._refresh_calendar()
|
||||
assert gate._cal_widgets.budget_entry.get() == "1800"
|
||||
assert "Logging streak" in gate._cal_vars.streaks.get()
|
||||
|
||||
def test_a_corrupt_budget_file_degrades_with_an_error_message(
|
||||
self,
|
||||
gate: MealGate,
|
||||
) -> None:
|
||||
_budget.BUDGET_FILE.write_text("not json")
|
||||
gate._refresh_calendar()
|
||||
assert "corrupt" in gate._cal_vars.streaks.get()
|
||||
assert gate._cal_vars.ytd.get() == ""
|
||||
|
||||
|
||||
class TestMonthNavigation:
|
||||
"""Prev/next steps the displayed month, wrapping the year correctly."""
|
||||
|
||||
def test_prev_month_steps_back_within_the_year(self, gate: MealGate) -> None:
|
||||
gate._cal_year, gate._cal_month = 2026, 7
|
||||
gate._on_prev_month()
|
||||
assert (gate._cal_year, gate._cal_month) == (2026, 6)
|
||||
|
||||
def test_prev_month_wraps_the_year_at_january(self, gate: MealGate) -> None:
|
||||
gate._cal_year, gate._cal_month = 2026, 1
|
||||
gate._on_prev_month()
|
||||
assert (gate._cal_year, gate._cal_month) == (2025, 12)
|
||||
|
||||
def test_next_month_steps_forward_within_the_year(self, gate: MealGate) -> None:
|
||||
gate._cal_year, gate._cal_month = 2026, 6
|
||||
gate._on_next_month()
|
||||
assert (gate._cal_year, gate._cal_month) == (2026, 7)
|
||||
|
||||
def test_next_month_wraps_the_year_at_december(self, gate: MealGate) -> None:
|
||||
gate._cal_year, gate._cal_month = 2026, 12
|
||||
gate._on_next_month()
|
||||
assert (gate._cal_year, gate._cal_month) == (2027, 1)
|
||||
|
||||
|
||||
class TestBudgetEditToggle:
|
||||
"""Edit/Save toggle: first click unlocks, second click validates+saves."""
|
||||
|
||||
def _type(self, gate: MealGate, value: str) -> None:
|
||||
"""Replace the budget entry's text (as if the user retyped it)."""
|
||||
gate._cal_widgets.budget_entry.delete(0, "end")
|
||||
gate._cal_widgets.budget_entry.insert(0, value)
|
||||
|
||||
def test_first_click_unlocks_editing_without_persisting(
|
||||
self,
|
||||
gate: MealGate,
|
||||
) -> None:
|
||||
gate._on_edit_or_save_budget()
|
||||
assert gate._cal_editing_budget is True
|
||||
assert gate._cal_widgets.budget_edit_button.configured.get("text") == "Save"
|
||||
assert gate._cal_widgets.budget_entry.configured.get("state") == "normal"
|
||||
|
||||
def test_second_click_with_non_numeric_input_stays_in_edit_mode(
|
||||
self,
|
||||
gate: MealGate,
|
||||
) -> None:
|
||||
gate._on_edit_or_save_budget()
|
||||
self._type(gate, "not a number")
|
||||
gate._on_edit_or_save_budget()
|
||||
assert gate._cal_editing_budget is True
|
||||
assert "whole number" in gate._cal_vars.budget_status.get()
|
||||
assert gate._cal_widgets.budget_edit_button.configured.get("text") == "Save"
|
||||
|
||||
def test_second_click_with_zero_is_rejected_and_stays_in_edit_mode(
|
||||
self,
|
||||
gate: MealGate,
|
||||
) -> None:
|
||||
gate._on_edit_or_save_budget()
|
||||
self._type(gate, "0")
|
||||
gate._on_edit_or_save_budget()
|
||||
assert gate._cal_editing_budget is True
|
||||
assert "positive" in gate._cal_vars.budget_status.get()
|
||||
|
||||
def test_second_click_with_negative_is_rejected(self, gate: MealGate) -> None:
|
||||
gate._on_edit_or_save_budget()
|
||||
self._type(gate, "-500")
|
||||
gate._on_edit_or_save_budget()
|
||||
assert "positive" in gate._cal_vars.budget_status.get()
|
||||
|
||||
def test_second_click_with_a_valid_value_saves_and_exits_edit_mode(
|
||||
self,
|
||||
gate: MealGate,
|
||||
) -> None:
|
||||
gate._on_edit_or_save_budget()
|
||||
self._type(gate, "1900")
|
||||
gate._on_edit_or_save_budget()
|
||||
assert gate._cal_editing_budget is False
|
||||
assert gate._cal_vars.budget_status.get() == "Saved."
|
||||
assert gate._cal_widgets.budget_edit_button.configured.get("text") == "Edit"
|
||||
assert gate._cal_widgets.budget_entry.configured.get("state") == "readonly"
|
||||
assert daily_budget() == 1900
|
||||
|
||||
def test_saving_preserves_a_previously_stored_weight(
|
||||
self,
|
||||
gate: MealGate,
|
||||
) -> None:
|
||||
write_budget(2000, weight_kg=80)
|
||||
gate._on_edit_or_save_budget()
|
||||
self._type(gate, "2100")
|
||||
gate._on_edit_or_save_budget()
|
||||
assert budget_weight() == 80
|
||||
|
||||
def test_saving_refreshes_the_dashboard_headline(self, gate: MealGate) -> None:
|
||||
gate._on_edit_or_save_budget()
|
||||
self._type(gate, "1700")
|
||||
gate._on_edit_or_save_budget()
|
||||
assert "1700" in gate._vars.cal_headline.get()
|
||||
|
||||
def test_navigating_months_mid_edit_does_not_overwrite_the_typed_value(
|
||||
self,
|
||||
gate: MealGate,
|
||||
) -> None:
|
||||
gate._on_edit_or_save_budget()
|
||||
self._type(gate, "1234")
|
||||
gate._on_prev_month()
|
||||
assert gate._cal_widgets.budget_entry.get() == "1234"
|
||||
@ -13,7 +13,7 @@ from typing import TYPE_CHECKING
|
||||
from unittest.mock import patch
|
||||
|
||||
from diet_guard import _gatelock_mealflow
|
||||
from diet_guard._budget import seal_budget
|
||||
from diet_guard._budget import write_budget
|
||||
from diet_guard._meal import MealItem
|
||||
from diet_guard._state import log_meal
|
||||
from diet_guard.tests.conftest import _nutrition
|
||||
@ -235,7 +235,7 @@ class TestDashboard:
|
||||
|
||||
def test_headline_with_budget(self, gate: MealGate) -> None:
|
||||
"""A sealed budget shows consumed/target/remaining."""
|
||||
seal_budget(2000)
|
||||
write_budget(2000)
|
||||
gate._refresh_dashboard()
|
||||
assert "left" in gate._vars.cal_headline.get()
|
||||
|
||||
@ -246,7 +246,7 @@ class TestDashboard:
|
||||
|
||||
def test_dashboard_lists_entries(self, gate: MealGate) -> None:
|
||||
"""Logged entries appear in the detail panel."""
|
||||
seal_budget(2000, weight_kg=80)
|
||||
write_budget(2000, weight_kg=80)
|
||||
log_meal("apple", _nutrition(95, 100), 8)
|
||||
gate._refresh_dashboard()
|
||||
text = gate._vars.dashboard.get()
|
||||
@ -272,7 +272,7 @@ class TestDashboard:
|
||||
|
||||
def test_projection_with_budget(self, gate: MealGate) -> None:
|
||||
"""The projection shows the after-this-item remaining when priced."""
|
||||
seal_budget(2000)
|
||||
write_budget(2000)
|
||||
gate._widgets.macros.kcal.insert(0, "300")
|
||||
gate._refresh_projection()
|
||||
assert "after this item" in gate._vars.projection.get()
|
||||
|
||||
@ -13,7 +13,7 @@ from unittest.mock import patch
|
||||
import pytest
|
||||
|
||||
from diet_guard import _state
|
||||
from diet_guard._budget import BudgetNotInitializedError, seal_budget
|
||||
from diet_guard._budget import BudgetNotInitializedError, write_budget
|
||||
from diet_guard._estimator import Nutrition
|
||||
from diet_guard._state import (
|
||||
consumption_band,
|
||||
@ -205,25 +205,25 @@ class TestBudgetViews:
|
||||
|
||||
def test_remaining_value(self) -> None:
|
||||
"""Remaining is budget minus today's total."""
|
||||
seal_budget(2000)
|
||||
write_budget(2000)
|
||||
log_meal("lunch", _nut(500), slot=12)
|
||||
assert remaining_budget() == 1500.0
|
||||
|
||||
def test_band_on_track(self) -> None:
|
||||
"""Well under the warn fraction is 'on track'."""
|
||||
seal_budget(2000)
|
||||
write_budget(2000)
|
||||
log_meal("a", _nut(500), slot=8)
|
||||
assert consumption_band() == "on track"
|
||||
|
||||
def test_band_approaching(self) -> None:
|
||||
"""At or above the warn fraction but under budget is 'approaching limit'."""
|
||||
seal_budget(2000)
|
||||
write_budget(2000)
|
||||
log_meal("a", _nut(1700), slot=8)
|
||||
assert consumption_band() == "approaching limit"
|
||||
|
||||
def test_band_over(self) -> None:
|
||||
"""At or above budget is 'OVER BUDGET'."""
|
||||
seal_budget(2000)
|
||||
write_budget(2000)
|
||||
log_meal("a", _nut(2100), slot=8)
|
||||
assert consumption_band() == "OVER BUDGET"
|
||||
|
||||
|
||||
@ -13,9 +13,20 @@ from unittest.mock import MagicMock, patch
|
||||
import pytest
|
||||
|
||||
from diet_guard import _sync
|
||||
from diet_guard._budget import daily_budget, write_budget
|
||||
from diet_guard._estimator import Nutrition
|
||||
from diet_guard._foodbank import lookup_food
|
||||
from diet_guard._state import load_log, log_meal
|
||||
from diet_guard._sync_merge import budget_to_log
|
||||
|
||||
|
||||
def _remote_budget_json(*, kcal: int, t: str, weight_kg: float | None = None) -> str:
|
||||
"""Build the wire text a remote device would push for a given budget edit."""
|
||||
record: dict[str, object] = {"v": 2, "b": kcal, "t": t}
|
||||
if weight_kg is not None:
|
||||
record["w"] = weight_kg
|
||||
log = budget_to_log(record)
|
||||
return json.dumps({rid: rec.to_dict() for rid, rec in log.items()}, indent=2)
|
||||
|
||||
|
||||
def _nutrition(kcal: float = 200.0) -> Nutrition:
|
||||
@ -96,9 +107,13 @@ class TestRunSync:
|
||||
)
|
||||
with patch.object(_sync, "GitHubSyncClient", return_value=client):
|
||||
_sync.run_sync()
|
||||
client.get_file_text.assert_called_once_with(
|
||||
# Both the food-log and budget pulls skip "pc" (this device) and
|
||||
# only ever read "phone"'s files -- never "pc"'s.
|
||||
requested_paths = [call.args[0] for call in client.get_file_text.call_args_list]
|
||||
assert requested_paths == [
|
||||
"diet-guard-sync/devices/phone/food_log.json",
|
||||
)
|
||||
"diet-guard-sync/devices/phone/budget.json",
|
||||
]
|
||||
|
||||
def test_skips_a_device_with_no_pushed_file_yet(self) -> None:
|
||||
_write_token()
|
||||
@ -204,6 +219,83 @@ class TestRunSync:
|
||||
assert lookup_food("oatmeal") is not None
|
||||
|
||||
|
||||
class TestSyncBudget:
|
||||
"""The daily budget's last-writer-wins sync, folded into run_sync()."""
|
||||
|
||||
def test_pushes_local_budget_when_no_other_devices_have_synced(self) -> None:
|
||||
_write_token()
|
||||
write_budget(2000)
|
||||
client = _mock_client(devices=())
|
||||
with patch.object(_sync, "GitHubSyncClient", return_value=client):
|
||||
_sync.run_sync()
|
||||
pushed_paths = {call.args[0] for call in client.put_file_text.call_args_list}
|
||||
assert "diet-guard-sync/devices/pc/budget.json" in pushed_paths
|
||||
|
||||
def test_nothing_pushed_when_no_budget_ever_set(self) -> None:
|
||||
"""An uninitialized device contributes nothing -- no push, no crash."""
|
||||
_write_token()
|
||||
client = _mock_client(devices=())
|
||||
with patch.object(_sync, "GitHubSyncClient", return_value=client):
|
||||
_sync.run_sync()
|
||||
pushed_paths = {call.args[0] for call in client.put_file_text.call_args_list}
|
||||
assert "diet-guard-sync/devices/pc/budget.json" not in pushed_paths
|
||||
|
||||
def test_remote_only_budget_is_adopted_locally(self) -> None:
|
||||
"""Only the phone has ever set a budget -- the PC adopts it."""
|
||||
_write_token()
|
||||
remote_json = _remote_budget_json(kcal=1800, t="2026-01-01T09:00:00")
|
||||
client = _mock_client(
|
||||
devices=("phone",),
|
||||
files={"diet-guard-sync/devices/phone/budget.json": remote_json},
|
||||
)
|
||||
with patch.object(_sync, "GitHubSyncClient", return_value=client):
|
||||
_sync.run_sync()
|
||||
assert daily_budget() == 1800
|
||||
|
||||
def test_local_edit_later_than_remote_wins(self) -> None:
|
||||
"""A fresh local write beats a much older remote edit."""
|
||||
_write_token()
|
||||
write_budget(1500) # stamped with "now"
|
||||
remote_json = _remote_budget_json(kcal=1800, t="2020-01-01T09:00:00")
|
||||
client = _mock_client(
|
||||
devices=("phone",),
|
||||
files={"diet-guard-sync/devices/phone/budget.json": remote_json},
|
||||
)
|
||||
with patch.object(_sync, "GitHubSyncClient", return_value=client):
|
||||
_sync.run_sync()
|
||||
assert daily_budget() == 1500
|
||||
|
||||
def test_remote_edit_later_than_local_wins(self) -> None:
|
||||
"""A remote edit far in the future beats a stale local write.
|
||||
|
||||
Confirms this is genuinely edit-time (not sync-time or push-order)
|
||||
LWW: whichever side has the later ``t`` wins regardless of which
|
||||
device happens to run its sync tick first.
|
||||
"""
|
||||
_write_token()
|
||||
write_budget(1500) # stamped with "now"
|
||||
remote_json = _remote_budget_json(kcal=1800, t="2999-01-01T09:00:00")
|
||||
client = _mock_client(
|
||||
devices=("phone",),
|
||||
files={"diet-guard-sync/devices/phone/budget.json": remote_json},
|
||||
)
|
||||
with patch.object(_sync, "GitHubSyncClient", return_value=client):
|
||||
_sync.run_sync()
|
||||
assert daily_budget() == 1800
|
||||
|
||||
def test_malformed_remote_budget_is_skipped(self) -> None:
|
||||
"""A corrupt remote budget.json is skipped, not a crash."""
|
||||
_write_token()
|
||||
write_budget(2000)
|
||||
client = _mock_client(
|
||||
devices=("phone",),
|
||||
files={"diet-guard-sync/devices/phone/budget.json": "{not valid json"},
|
||||
)
|
||||
with patch.object(_sync, "GitHubSyncClient", return_value=client):
|
||||
_sync.run_sync()
|
||||
assert daily_budget() == 2000
|
||||
|
||||
|
||||
class TestPullSharedLog:
|
||||
"""The fail-closed wrapper the gate and the lock-screen button share."""
|
||||
|
||||
|
||||
@ -11,15 +11,19 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from crdt_sync import merge_logs
|
||||
from crdt_sync import Record, merge_logs
|
||||
import pytest
|
||||
|
||||
from diet_guard._sync_merge import (
|
||||
_budget_hlc,
|
||||
_entry_hlc,
|
||||
_legacy_entry_id,
|
||||
budget_to_log,
|
||||
daylog_to_log,
|
||||
entry_to_record,
|
||||
log_to_budget,
|
||||
log_to_daylog,
|
||||
parse_remote_budget,
|
||||
parse_remote_log,
|
||||
record_to_entry,
|
||||
)
|
||||
@ -210,5 +214,81 @@ class TestParseRemoteLog:
|
||||
parse_remote_log("not json{{{")
|
||||
|
||||
|
||||
def _budget_record(**overrides: object) -> dict[str, object]:
|
||||
"""Build a minimal valid raw budget record, overriding what a test needs."""
|
||||
record: dict[str, object] = {
|
||||
"v": 2,
|
||||
"b": 2000,
|
||||
"t": "2026-06-22T08:00:00+02:00",
|
||||
}
|
||||
record.update(overrides)
|
||||
return record
|
||||
|
||||
|
||||
class TestBudgetHlc:
|
||||
def test_same_record_always_yields_the_same_hlc(self) -> None:
|
||||
record = _budget_record()
|
||||
assert _budget_hlc(record) == _budget_hlc(dict(record))
|
||||
|
||||
def test_malformed_t_still_yields_a_valid_hlc(self) -> None:
|
||||
record = _budget_record(t="not-a-timestamp")
|
||||
assert _budget_hlc(record).wall_time_ms == 0
|
||||
|
||||
def test_a_later_t_yields_a_greater_hlc(self) -> None:
|
||||
earlier = _budget_record(t="2020-01-01T00:00:00+00:00")
|
||||
later = _budget_record(t="2030-01-01T00:00:00+00:00")
|
||||
assert _budget_hlc(later) > _budget_hlc(earlier)
|
||||
|
||||
|
||||
class TestBudgetLogRoundTrip:
|
||||
def test_none_record_yields_an_empty_log(self) -> None:
|
||||
assert budget_to_log(None) == {}
|
||||
|
||||
def test_round_trip_preserves_the_budget_and_weight(self) -> None:
|
||||
record = _budget_record(w=80.0)
|
||||
round_tripped = log_to_budget(budget_to_log(record))
|
||||
assert round_tripped is not None
|
||||
assert round_tripped["b"] == 2000
|
||||
assert round_tripped["w"] == 80.0
|
||||
|
||||
def test_round_tripped_t_reflects_the_winning_hlc(self) -> None:
|
||||
record = _budget_record(t="2026-06-22T08:00:00+02:00")
|
||||
round_tripped = log_to_budget(budget_to_log(record))
|
||||
assert round_tripped is not None
|
||||
assert round_tripped["t"] != ""
|
||||
|
||||
def test_empty_log_has_no_budget(self) -> None:
|
||||
assert log_to_budget({}) is None
|
||||
|
||||
def test_record_with_no_value_field_has_no_hlc_in_result(self) -> None:
|
||||
"""A record present but missing the "value" field is a defensive
|
||||
edge case (should not occur from budget_to_log itself) -- the
|
||||
result still comes back without crashing, and with no ``t``.
|
||||
"""
|
||||
log = {"budget": Record(id="budget", fields={})}
|
||||
round_tripped = log_to_budget(log)
|
||||
assert round_tripped == {}
|
||||
|
||||
|
||||
class TestParseRemoteBudget:
|
||||
def test_parses_pushed_budget_wire_content(self) -> None:
|
||||
record = _budget_record()
|
||||
pushed = budget_to_log(record)
|
||||
wire = json.dumps({rid: rec.to_dict() for rid, rec in pushed.items()})
|
||||
log = parse_remote_budget(wire)
|
||||
assert log["budget"].id == "budget"
|
||||
|
||||
def test_empty_object_parses_as_empty_log(self) -> None:
|
||||
assert parse_remote_budget("{}") == {}
|
||||
|
||||
def test_non_object_top_level_raises_type_error(self) -> None:
|
||||
with pytest.raises(TypeError):
|
||||
parse_remote_budget("[1, 2, 3]")
|
||||
|
||||
def test_invalid_json_raises(self) -> None:
|
||||
with pytest.raises(json.JSONDecodeError):
|
||||
parse_remote_budget("not json{{{")
|
||||
|
||||
|
||||
def _dumps(data: object) -> str:
|
||||
return json.dumps(data)
|
||||
|
||||
35
install.sh
35
install.sh
@ -1,6 +1,6 @@
|
||||
#!/bin/bash
|
||||
# ============================================================================
|
||||
# Diet Guard installer: hidden budget + log-to-unlock gate.
|
||||
# Diet Guard installer: log-to-unlock gate with a freely-editable budget.
|
||||
#
|
||||
# Usage: bash install.sh
|
||||
#
|
||||
@ -14,8 +14,8 @@
|
||||
# 4. Installs + enables the systemd user timer that syncs the log every ~15m
|
||||
# (the sync itself stays unconfigured -- and a no-op -- until you create
|
||||
# a sync token; see the reminder this step prints)
|
||||
# 5. Seals your daily budget from biometrics (only if not already sealed)
|
||||
# 6. Locks the budget file immutable with `chattr +i` (the real tamper gate)
|
||||
# 5. Sets your initial daily budget from biometrics (only if not already set;
|
||||
# it stays freely editable afterward from the gate or the phone app)
|
||||
# ============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
@ -36,7 +36,7 @@ readonly SYNC_TOKEN_FILE="$HOME/.config/diet_guard/sync_token"
|
||||
echo "=== Diet Guard Installer ==="
|
||||
|
||||
# 1. System dependencies ------------------------------------------------------
|
||||
echo "[1/6] Checking system dependencies..."
|
||||
echo "[1/5] Checking system dependencies..."
|
||||
if ! command -v setxkbmap &>/dev/null; then
|
||||
echo " Installing xorg-setxkbmap (gate disables VT switching while locked)..."
|
||||
sudo pacman -S --noconfirm xorg-setxkbmap
|
||||
@ -45,14 +45,14 @@ else
|
||||
fi
|
||||
|
||||
# 2. Install this package + its dependencies into system Python -------------
|
||||
echo "[2/6] Installing diet_guard + dependencies for /usr/bin/python..."
|
||||
echo "[2/5] Installing diet_guard + dependencies for /usr/bin/python..."
|
||||
/usr/bin/python3 -m pip install --user --break-system-packages -e "$REPO_DIR"
|
||||
echo " Installed. Verifying import..."
|
||||
/usr/bin/python3 -c "import diet_guard; import gatelock" \
|
||||
&& echo " diet_guard and gatelock import cleanly from the system interpreter."
|
||||
|
||||
# 3. systemd user timer + service (gate) -------------------------------------
|
||||
echo "[3/6] Installing the gate's systemd user timer + service..."
|
||||
echo "[3/5] Installing the gate's systemd user timer + service..."
|
||||
mkdir -p "$SYSTEMD_USER_DIR"
|
||||
cp "$SERVICE_SRC" "$SYSTEMD_USER_DIR/diet-guard-gate.service"
|
||||
cp "$TIMER_SRC" "$SYSTEMD_USER_DIR/diet-guard-gate.timer"
|
||||
@ -61,7 +61,7 @@ systemctl --user enable --now diet-guard-gate.timer
|
||||
echo " Timer enabled and started (fires the gate every ~30 min)."
|
||||
|
||||
# 4. systemd user timer + service (sync) -------------------------------------
|
||||
echo "[4/6] Installing the sync's systemd user timer + service..."
|
||||
echo "[4/5] Installing the sync's systemd user timer + service..."
|
||||
cp "$SYNC_SERVICE_SRC" "$SYSTEMD_USER_DIR/diet-guard-sync.service"
|
||||
cp "$SYNC_TIMER_SRC" "$SYSTEMD_USER_DIR/diet-guard-sync.timer"
|
||||
systemctl --user daemon-reload
|
||||
@ -76,25 +76,18 @@ else
|
||||
echo " mode 600: chmod 600 \"$SYNC_TOKEN_FILE\""
|
||||
fi
|
||||
|
||||
# 5. Seal the daily budget (hidden) ------------------------------------------
|
||||
echo "[5/6] Sealing your daily budget..."
|
||||
# 5. Set the initial daily budget ---------------------------------------------
|
||||
echo "[5/5] Setting your initial daily budget..."
|
||||
if [[ -e "$BUDGET_FILE" ]]; then
|
||||
echo " Budget already sealed at $BUDGET_FILE - skipping init."
|
||||
echo " Budget already set at $BUDGET_FILE - skipping init."
|
||||
echo " Edit it any time from the gate's calendar tab or the phone app."
|
||||
else
|
||||
echo " Enter your biometrics (used once then discarded; the value is hidden):"
|
||||
echo " Enter your biometrics (used once then discarded to compute a"
|
||||
echo " starting value; you can change the number itself any time from"
|
||||
echo " the gate's calendar tab or the phone app):"
|
||||
python -m diet_guard init
|
||||
fi
|
||||
|
||||
# 6. Lock the budget immutable (the real tamper friction) --------------------
|
||||
echo "[6/6] Locking the budget file (chattr +i)..."
|
||||
read -r attrs _ <<<"$(lsattr -d "$BUDGET_FILE" 2>/dev/null || true)"
|
||||
if [[ "$attrs" == *i* ]]; then
|
||||
echo " Already immutable."
|
||||
else
|
||||
sudo chattr +i "$BUDGET_FILE"
|
||||
echo " Locked. To change it later: sudo chattr -i '$BUDGET_FILE'; re-run init; re-lock."
|
||||
fi
|
||||
|
||||
echo "=== Installation complete ==="
|
||||
echo "The gate checks every ~30 min (08:00-22:00) and locks until you log a meal"
|
||||
echo "once you have gone 5h without logging."
|
||||
|
||||
Loading…
Reference in New Issue
Block a user