diff --git a/app/lib/services/sync_merge_budget.dart b/app/lib/services/sync_merge_budget.dart index 8b37860..a27e539 100644 --- a/app/lib/services/sync_merge_budget.dart +++ b/app/lib/services/sync_merge_budget.dart @@ -14,7 +14,9 @@ import 'dart:convert'; import 'package:crdt_sync/crdt_sync.dart'; import 'package:diet_guard_app/services/budget_schedule.dart'; +import 'package:diet_guard_app/services/meal_schedule_history.dart'; import 'package:diet_guard_app/services/sync_device_id.dart'; +import 'package:diet_guard_app/services/sync_merge_schedule.dart'; /// Stable id: exactly one budget record per device-pushed `budget.json`. const budgetRecordId = 'budget'; @@ -75,6 +77,7 @@ Hlc historyHlc(BudgetEntry entry) { Log budgetToLog( Map? record, [ List entries = const [], + List scheduleEntries = const [], ]) { if (record == null) return {}; final hlc = budgetHlc(record); @@ -88,6 +91,9 @@ Log budgetToLog( historyHlc(entry), ); } + // The meal-schedule history rides the same record as its own `sched:` + // fields; see `sync_merge_schedule.dart`. + fields.addAll(scheduleFields(scheduleEntries)); return {budgetRecordId: Record(id: budgetRecordId, fields: fields)}; } diff --git a/app/lib/services/sync_merge_schedule.dart b/app/lib/services/sync_merge_schedule.dart new file mode 100644 index 0000000..dcb50a4 --- /dev/null +++ b/app/lib/services/sync_merge_schedule.dart @@ -0,0 +1,98 @@ +/// Meal-schedule history as extra fields on the shared `budget` record. +/// +/// Split out of `sync_merge_budget.dart` to keep both under the repo's +/// 250-line limit; the two halves are otherwise the same idea and share one +/// record. +/// +/// The schedule rides as one `sched:` field per history entry, +/// exactly like the budget's `hist:` fields. `mergeRecord` is per-field +/// last-writer-wins over the *union* of field names, and both devices push +/// the *merged* record rather than their own, so a device that predates meal +/// schedules neither clobbers those fields nor blocks them -- it relays them +/// untouched. That is what makes this shippable without a coordinated +/// release. +/// +/// KEEP IN SYNC WITH `diet_guard/sync_merge/_schedule.py`. +library; + +import 'package:crdt_sync/crdt_sync.dart'; +import 'package:diet_guard_app/models/meal_schedule.dart'; +import 'package:diet_guard_app/services/meal_schedule_history.dart'; +import 'package:diet_guard_app/services/sync_device_id.dart'; + +/// The shared record these fields live on. Declared here rather than imported +/// from `sync_merge_budget.dart` so the two modules do not import each other; +/// `_schedule.py` declares its own copy for the same reason. +const _budgetRecordId = 'budget'; + +/// Field-name prefix for the effective-from meal-schedule history. +const scheduleFieldPrefix = 'sched:'; + +/// Derives a deterministic [Hlc] for one schedule entry from its edit time. +/// +/// Identical inputs always yield the same clock, so re-syncing an unchanged +/// history is a no-op. Derived from the *parsed* timestamp rather than the +/// raw string, so the two languages agree even though they format the epoch +/// fallback differently. +Hlc scheduleHlc(ScheduleEntry entry) { + final wallTimeMs = + DateTime.tryParse(entry.editedAt)?.toUtc().millisecondsSinceEpoch ?? 0; + return Hlc.newTick(currentSyncDeviceId, wallTimeMsOverride: wallTimeMs); +} + +/// Returns the `sched:` fields contributed by [entries]. +/// +/// Empty when this device has no history, so a device that has never edited +/// a schedule contributes nothing to the merge rather than pushing the unset +/// default over a peer's real value. +Map scheduleFields(List entries) => { + for (final entry in entries) + '$scheduleFieldPrefix${entry.effectiveFrom}': ( + { + 'f': entry.schedule.first, + 'l': entry.schedule.last, + 'n': entry.schedule.count, + }, + scheduleHlc(entry), + ), +}; + +/// Extracts the meal-schedule history from a merged budget [Log]. +/// +/// Each entry's `editedAt` is reconstructed from its field Hlc rather than +/// carried separately, so the stored timestamp and the clock the merge +/// compared can never drift apart. Malformed values are skipped, so one bad +/// field from a peer cannot take out the whole history. +List logToScheduleHistory(Log log) { + final record = log[_budgetRecordId]; + if (record == null) return const []; + final entries = []; + for (final field in record.fields.entries) { + if (!field.key.startsWith(scheduleFieldPrefix)) continue; + final value = field.value.$1; + if (value is! Map) continue; + final first = value['f']; + final last = value['l']; + final count = value['n']; + if (first is! int || last is! int || count is! int) continue; + entries.add( + ScheduleEntry( + effectiveFrom: field.key.substring(scheduleFieldPrefix.length), + // Normalised on the way in, so a peer running a future version with a + // wider range cannot hand us a schedule we would derive slots + // differently from. + schedule: MealSchedule( + first: first, + last: last, + count: count, + ).normalized(), + editedAt: DateTime.fromMillisecondsSinceEpoch( + field.value.$2.wallTimeMs, + isUtc: true, + ).toLocal().toIso8601String(), + ), + ); + } + entries.sort((a, b) => a.effectiveFrom.compareTo(b.effectiveFrom)); + return entries; +} diff --git a/app/lib/services/sync_service.dart b/app/lib/services/sync_service.dart index c4fe02f..8b195ff 100644 --- a/app/lib/services/sync_service.dart +++ b/app/lib/services/sync_service.dart @@ -18,8 +18,10 @@ import 'package:diet_guard_app/services/app_settings_service.dart'; import 'package:diet_guard_app/services/budget_history_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/meal_schedule_service.dart'; import 'package:diet_guard_app/services/sync_device_id.dart'; import 'package:diet_guard_app/services/sync_merge.dart'; +import 'package:diet_guard_app/services/sync_merge_schedule.dart'; import 'package:diet_guard_app/services/sync_state_factory.dart'; const _devicesDir = 'diet-guard-sync/devices'; @@ -113,12 +115,23 @@ Future _syncManualBank(RemoteStore client) async { /// from elsewhere. Future _syncBudget(RemoteStore client) async { final updatedAt = AppSettingsService.dailyKcalGoalUpdatedAt; - final localRecord = updatedAt == null + final scheduleEntries = MealScheduleService.history.entries; + // `budgetToLog` returns an empty Log for a null record, which would also + // drop the `sched:` fields. A device that has edited its schedule but never + // its budget still has something to contribute, so synthesise a record from + // the schedule's own edit time in that case. Its `value` carries the + // unset-default budget, but that loses every LWW race against a device that + // has actually set one, because this stamp is older than any real edit. + final scheduleStamp = MealScheduleService.updatedAt; + final recordStamp = + updatedAt ?? (scheduleEntries.isEmpty ? null : scheduleStamp); + final localRecord = recordStamp == null ? null : { 'v': 2, 'b': AppSettingsService.dailyKcalGoal, - 't': updatedAt.toIso8601String(), + 't': (updatedAt ?? DateTime.fromMillisecondsSinceEpoch(0)) + .toIso8601String(), }; final mergedBudgetLog = await syncLog( @@ -129,6 +142,7 @@ Future _syncBudget(RemoteStore client) async { localLog: budgetToLog( localRecord, BudgetHistoryService.schedule.entries, + scheduleEntries, ), encode: encodeBudgetForPush, decode: parseRemoteBudget, @@ -141,6 +155,14 @@ Future _syncBudget(RemoteStore client) async { await BudgetHistoryService.instance.applyMerged( logToHistory(mergedBudgetLog), ); + // Same reasoning for the meal schedule: field-union merge, so applied + // regardless of how the scalar budget resolved. + if (MealScheduleService.isInitialized) { + await MealScheduleService.instance.applyMerged( + logToScheduleHistory(mergedBudgetLog), + updatedAt: scheduleStamp, + ); + } final merged = logToBudget(mergedBudgetLog); final mergedKcal = merged?['b']; diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 37080fd..063f6ea 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -2,7 +2,7 @@ name: diet_guard_app description: "Companion phone app for diet_guard: log meals on the go." publish_to: 'none' -version: 1.0.33+1 +version: 1.0.34+1 environment: sdk: ^3.12.2 diff --git a/app/test/services/sync_merge_schedule_test.dart b/app/test/services/sync_merge_schedule_test.dart new file mode 100644 index 0000000..8390c9d --- /dev/null +++ b/app/test/services/sync_merge_schedule_test.dart @@ -0,0 +1,171 @@ +/// Tests for the meal-schedule half of the shared `budget` CRDT record. +/// +/// Mirrors `diet_guard/tests/test_sync_merge_schedule.py`. +library; + +import 'dart:convert'; + +import 'package:crdt_sync/crdt_sync.dart'; +import 'package:diet_guard_app/models/meal_schedule.dart'; +import 'package:diet_guard_app/services/meal_schedule_history.dart'; +import 'package:diet_guard_app/services/sync_merge_budget.dart'; +import 'package:diet_guard_app/services/sync_merge_schedule.dart'; +import 'package:flutter_test/flutter_test.dart'; + +const _record = { + 'v': 2, + 'b': 2200, + 't': '2026-08-16T10:00:00+02:00', +}; + +ScheduleEntry _entry(String day, MealSchedule schedule, String when) => + ScheduleEntry(effectiveFrom: day, schedule: schedule, editedAt: when); + +String _wire(Log log) => jsonEncode({ + for (final entry in log.entries) entry.key: entry.value.toJson(), +}); + +void main() { + group('scheduleFields', () { + test('no entries contributes nothing', () { + // A device that never edited a schedule cannot outrank a peer. + expect(scheduleFields(const []), isEmpty); + }); + + test('one field per entry', () { + final fields = scheduleFields([ + _entry( + '2026-08-16', + const MealSchedule(first: 8, last: 20, count: 5), + '2026-08-16T10:00:00+02:00', + ), + ]); + expect(fields.keys, ['${scheduleFieldPrefix}2026-08-16']); + expect(fields['${scheduleFieldPrefix}2026-08-16']!.$1, { + 'f': 8, + 'l': 20, + 'n': 5, + }); + }); + + test('the Hlc is deterministic', () { + final entry = _entry( + '2026-08-16', + const MealSchedule(first: 8, last: 20, count: 5), + '2026-08-16T10:00:00+02:00', + ); + expect( + scheduleHlc(entry).wallTimeMs, + scheduleHlc(entry).wallTimeMs, + ); + }); + + test('an unparsable timestamp falls back to the epoch', () { + final entry = _entry( + '2026-08-16', + const MealSchedule(first: 8, last: 20, count: 5), + 'not-a-timestamp', + ); + expect(scheduleHlc(entry).wallTimeMs, 0); + }); + }); + + group('logToScheduleHistory', () { + test('an absent record yields nothing', () { + expect(logToScheduleHistory(const {}), isEmpty); + }); + + test('round trips', () { + final back = logToScheduleHistory( + budgetToLog(_record, const [], [ + _entry( + '2026-08-16', + const MealSchedule(first: 8, last: 20, count: 5), + '2026-08-16T10:00:00+02:00', + ), + ]), + ); + expect(back, hasLength(1)); + expect(back.single.effectiveFrom, '2026-08-16'); + expect( + back.single.schedule, + const MealSchedule(first: 8, last: 20, count: 5), + ); + }); + + test("normalizes a peer's out-of-range schedule", () { + final log = budgetToLog(_record); + final record = log[budgetRecordId]!; + final hlc = record.fields.values.first.$2; + record.fields['${scheduleFieldPrefix}2026-08-16'] = ( + {'f': 8, 'l': 20, 'n': 99}, + hlc, + ); + expect( + logToScheduleHistory(log).single.schedule, + const MealSchedule(first: 8, last: 20, count: 6), + ); + }); + + test('skips malformed values', () { + final log = budgetToLog(_record); + final record = log[budgetRecordId]!; + final hlc = record.fields.values.first.$2; + record.fields['${scheduleFieldPrefix}2026-01-01'] = (7, hlc); + record.fields['${scheduleFieldPrefix}2026-02-01'] = ({'f': '8'}, hlc); + expect(logToScheduleHistory(log), isEmpty); + }); + }); + + group('cross-device merge', () { + test('a pre-feature peer relays the schedule untouched', () { + // `mergeRecord` is per-field LWW over the *union* of field names, and + // both devices push the merged record, so a budget-only push merges the + // fields in rather than clobbering them. This is what makes the feature + // shippable without a coordinated release. + final ours = budgetToLog(_record, const [], [ + _entry('1970-01-01', kDefaultSchedule, '1970-01-01T00:00:00.000Z'), + _entry( + '2026-08-16', + const MealSchedule(first: 8, last: 20, count: 5), + '2026-08-16T10:00:00+02:00', + ), + ]); + final peer = budgetToLog(const { + 'v': 2, + 'b': 1900, + 't': '2026-08-16T11:00:00+02:00', + }); + + final merged = mergeLogs(parseRemoteBudget(_wire(ours)), peer); + + expect(logToScheduleHistory(merged).map((e) => e.schedule), [ + kDefaultSchedule, + const MealSchedule(first: 8, last: 20, count: 5), + ]); + }); + + test('the newer edit wins per day', () { + final older = budgetToLog(_record, const [], [ + _entry( + '2026-08-16', + const MealSchedule(first: 8, last: 20, count: 5), + '2026-08-16T10:00:00+02:00', + ), + ]); + final newer = budgetToLog(_record, const [], [ + _entry( + '2026-08-16', + const MealSchedule(first: 7, last: 21, count: 3), + '2026-08-16T18:00:00+02:00', + ), + ]); + + final merged = mergeLogs(parseRemoteBudget(_wire(older)), newer); + expect( + logToScheduleHistory(merged).single.schedule, + const MealSchedule(first: 7, last: 21, count: 3), + ); + }); + }); +} diff --git a/diet_guard/_sync_banks.py b/diet_guard/_sync_banks.py index 53a864a..e627759 100644 --- a/diet_guard/_sync_banks.py +++ b/diet_guard/_sync_banks.py @@ -28,6 +28,15 @@ from diet_guard._budget_history import ( from diet_guard._device import device_identity from diet_guard._foodbank import read_food_bank, write_food_bank from diet_guard._foodbank_manual import read_manual_bank, write_manual_bank +from diet_guard._meal_schedule_store import ( + history_to_json as schedule_history_to_json, +) +from diet_guard._meal_schedule_store import ( + load_entries as load_schedule_entries, +) +from diet_guard._meal_schedule_store import ( + write_raw_history as write_raw_schedule, +) from diet_guard._sync_paths import ( _DEVICES_DIR, _device_budget_path, @@ -41,6 +50,7 @@ from diet_guard.sync_merge import ( log_to_food_bank, log_to_history, log_to_manual_bank, + log_to_schedule_history, manual_bank_to_log, parse_remote_budget, parse_remote_food_bank, @@ -161,7 +171,7 @@ def _sync_budget(client: GitHubSyncClient) -> None: *no* device has ever set one, nothing is written or pushed. """ identity = device_identity() - merged = budget_to_log(read_raw_record(), load_entries()) + merged = budget_to_log(read_raw_record(), load_entries(), load_schedule_entries()) for device_id in client.list_directory(_DEVICES_DIR): if identity.is_own(device_id): continue @@ -187,6 +197,12 @@ def _sync_budget(client: GitHubSyncClient) -> None: # seed from ever running. if merged_history: write_raw_history(history_to_json(merged_history)) + # Same guard for the meal schedule: a pre-feature peer contributes no + # `sched:` fields, and writing the empty document back would discard this + # device's own history. + merged_schedule = log_to_schedule_history(merged) + if merged_schedule: + write_raw_schedule(schedule_history_to_json(merged_schedule)) push_json = json.dumps( {record_id: record.to_dict() for record_id, record in merged.items()}, diff --git a/diet_guard/sync_merge/__init__.py b/diet_guard/sync_merge/__init__.py index 86faed2..bd0cff0 100644 --- a/diet_guard/sync_merge/__init__.py +++ b/diet_guard/sync_merge/__init__.py @@ -45,11 +45,17 @@ from diet_guard.sync_merge._daylog import ( parse_remote_log, record_to_entry, ) +from diet_guard.sync_merge._schedule import ( + SCHEDULE_FIELD_PREFIX, + log_to_schedule_history, + schedule_fields, +) __all__ = [ # Private helpers the tests reach for directly. Re-exported deliberately: # they were importable from the flat module, and dropping them here would # break those tests for a naming reason rather than a behavioural one. + "SCHEDULE_FIELD_PREFIX", "_budget_hlc", "_entry_hlc", "_legacy_entry_id", @@ -62,10 +68,12 @@ __all__ = [ "log_to_food_bank", "log_to_history", "log_to_manual_bank", + "log_to_schedule_history", "manual_bank_to_log", "parse_remote_budget", "parse_remote_food_bank", "parse_remote_log", "parse_remote_manual_bank", "record_to_entry", + "schedule_fields", ] diff --git a/diet_guard/sync_merge/_budget.py b/diet_guard/sync_merge/_budget.py index cce36ef..20c16d8 100644 --- a/diet_guard/sync_merge/_budget.py +++ b/diet_guard/sync_merge/_budget.py @@ -23,10 +23,13 @@ from crdt_sync import Hlc, Record from diet_guard._budget_history import BudgetEntry from diet_guard._device import device_id from diet_guard.sync_merge._clock import _EPOCH +from diet_guard.sync_merge._schedule import schedule_fields if TYPE_CHECKING: from crdt_sync import Log + from diet_guard._meal_schedule_store import ScheduleEntry + _BUDGET_RECORD_ID = "budget" # Field-name prefix for the effective-from budget history, one field per date. @@ -77,6 +80,7 @@ def _history_hlc(entry: BudgetEntry) -> Hlc: def budget_to_log( record: dict[str, object] | None, entries: tuple[BudgetEntry, ...] = (), + schedule_entries: tuple[ScheduleEntry, ...] = (), ) -> Log: """Convert a raw local budget record plus its history into a Log. @@ -112,6 +116,9 @@ def budget_to_log( entry.kcal, _history_hlc(entry), ) + # The meal-schedule history rides the same record as its own `sched:` + # fields; see :mod:`diet_guard.sync_merge._schedule`. + fields.update(schedule_fields(schedule_entries)) rec = Record(id=_BUDGET_RECORD_ID, fields=fields) return {rec.id: rec} diff --git a/diet_guard/sync_merge/_schedule.py b/diet_guard/sync_merge/_schedule.py new file mode 100644 index 0000000..98512bb --- /dev/null +++ b/diet_guard/sync_merge/_schedule.py @@ -0,0 +1,111 @@ +"""Meal-schedule history as extra fields on the shared ``budget`` record. + +Split out of :mod:`._budget` to keep both modules under the repo's 250-line +limit; the two halves are otherwise the same idea and share one record. + +The schedule rides as one ``sched:`` field per history entry, +exactly like the budget's ``hist:`` fields. ``crdt_sync``'s ``merge_record`` +is per-field last-writer-wins over the *union* of field names, and both +devices push the *merged* record rather than their own, so a device that +predates meal schedules neither clobbers those fields nor blocks them -- it +relays them untouched. That is what makes this shippable without a +coordinated release. + +The value is a small map (``{"f": 8, "l": 20, "n": 5}``) rather than a scalar. +``Record.to_dict``/``from_dict`` round-trip nested values unchanged, and a +malformed one is skipped exactly as a non-int ``hist:`` value is. +""" + +from __future__ import annotations + +from datetime import datetime, timezone +from typing import TYPE_CHECKING + +from crdt_sync import Hlc + +from diet_guard._device import device_id +from diet_guard._meal_schedule import MealSchedule +from diet_guard._meal_schedule_store import ScheduleEntry +from diet_guard.sync_merge._clock import _EPOCH + +if TYPE_CHECKING: + from crdt_sync import Log + +_BUDGET_RECORD_ID = "budget" +# Field-name prefix for the effective-from meal-schedule history, one field +# per date. A separate *field* rather than a separate document, for the +# reason spelled out in this module's docstring. +SCHEDULE_FIELD_PREFIX = "sched:" + + +def schedule_hlc(entry: ScheduleEntry) -> Hlc: + """Derive a deterministic Hlc for one schedule entry from its edit time. + + Same trick as the budget's ``_history_hlc``: identical inputs always yield + the same clock, so re-syncing an unchanged history is a no-op. Derived + from the parsed timestamp rather than the raw string, so the two languages + agree even though they format the epoch fallback differently. + """ + try: + moment = datetime.fromisoformat(entry.edited_at) + except ValueError: + moment = _EPOCH + return Hlc.new_tick(device_id(), wall_time_ms=int(moment.timestamp() * 1000)) + + +def schedule_fields( + entries: tuple[ScheduleEntry, ...], +) -> dict[str, tuple[object, Hlc]]: + """Return the ``sched:`` fields contributed by ``entries``. + + Empty when this device has no history, so a device that has never edited + a schedule contributes nothing to the merge rather than pushing the unset + default over a peer's real value. + """ + return { + f"{SCHEDULE_FIELD_PREFIX}{entry.effective_from}": ( + { + "f": entry.schedule.first, + "l": entry.schedule.last, + "n": entry.schedule.count, + }, + schedule_hlc(entry), + ) + for entry in entries + } + + +def log_to_schedule_history(log: Log) -> tuple[ScheduleEntry, ...]: + """Extract the meal-schedule history from a merged budget ``Log``. + + Each entry's ``edited_at`` is reconstructed from its field Hlc rather than + carried separately, so the stored timestamp and the clock the merge + compared can never drift apart. Malformed values are skipped, so one bad + field from a peer cannot take out the whole history. + """ + record = log.get(_BUDGET_RECORD_ID) + if record is None: + return () + entries = [] + for name, (value, hlc) in record.fields.items(): + if not name.startswith(SCHEDULE_FIELD_PREFIX): + continue + if not isinstance(value, dict): + continue + first, last, count = value.get("f"), value.get("l"), value.get("n") + if not ( + isinstance(first, int) and isinstance(last, int) and isinstance(count, int) + ) or any(isinstance(part, bool) for part in (first, last, count)): + continue + edited = datetime.fromtimestamp(hlc.wall_time_ms / 1000, tz=timezone.utc) + entries.append( + ScheduleEntry( + effective_from=name[len(SCHEDULE_FIELD_PREFIX) :], + # Normalised on the way in, so a peer running a future version + # with a wider range cannot hand us a schedule we would derive + # slots differently from. + schedule=MealSchedule(first, last, count).normalized(), + edited_at=edited.astimezone().isoformat(timespec="seconds"), + ), + ) + return tuple(sorted(entries, key=lambda entry: entry.effective_from)) diff --git a/diet_guard/tests/test_sync_budget.py b/diet_guard/tests/test_sync_budget.py index 6aa34d3..a9a4c3f 100644 --- a/diet_guard/tests/test_sync_budget.py +++ b/diet_guard/tests/test_sync_budget.py @@ -14,6 +14,11 @@ from diet_guard._budget import daily_budget, write_budget from diet_guard._budget_derived import budget_weight from diet_guard._budget_history import load_entries from diet_guard._device import device_id +from diet_guard._meal_schedule import MealSchedule +from diet_guard._meal_schedule_store import ( + load_entries as load_schedule_entries, +) +from diet_guard._meal_schedule_store import record_schedule_change from diet_guard.tests._sync_fakes import ( _mock_client, _remote_budget_json, @@ -113,6 +118,21 @@ class TestSyncBudget: _sync.run_sync() assert load_entries()[-1].kcal == 2000 + def test_meal_schedule_survives_a_round_trip(self) -> None: + """The meal schedule is written back locally after every merge. + + Without this the schedule would be device-local: the phone could set + five meals while the PC still derived four, and the PC's gate would + nag for a checkpoint the phone never offered. + """ + _write_token() + write_budget(2000) + record_schedule_change(MealSchedule(8, 20, 5)) + client = _mock_client(devices=()) + with patch.object(_sync_client, "GitHubSyncClient", return_value=client): + _sync.run_sync() + assert load_schedule_entries()[-1].schedule == MealSchedule(8, 20, 5) + def test_malformed_remote_budget_is_skipped(self) -> None: """A corrupt remote budget.json is skipped, not a crash.""" _write_token() diff --git a/diet_guard/tests/test_sync_merge_schedule.py b/diet_guard/tests/test_sync_merge_schedule.py new file mode 100644 index 0000000..73f2c39 --- /dev/null +++ b/diet_guard/tests/test_sync_merge_schedule.py @@ -0,0 +1,158 @@ +"""Tests for the meal-schedule half of the shared ``budget`` CRDT record.""" + +from __future__ import annotations + +import json + +from crdt_sync import Log, merge_logs + +from diet_guard._meal_schedule import DEFAULT_SCHEDULE, MealSchedule +from diet_guard._meal_schedule_store import ScheduleEntry +from diet_guard.sync_merge import ( + SCHEDULE_FIELD_PREFIX, + budget_to_log, + log_to_schedule_history, + parse_remote_budget, + schedule_fields, +) + +_RECORD = {"v": 2, "b": 2200, "t": "2026-08-16T10:00:00+02:00"} + + +def _entry(day: str, schedule: MealSchedule, when: str) -> ScheduleEntry: + """Return a schedule entry effective from ``day``.""" + return ScheduleEntry(effective_from=day, schedule=schedule, edited_at=when) + + +def _wire(log: Log) -> str: + """Return the JSON one device would push for ``log``.""" + return json.dumps( + {rid: rec.to_dict() for rid, rec in log.items()}, + indent=2, + ) + + +class TestScheduleFields: + """The per-entry fields a device contributes.""" + + def test_no_entries_contributes_nothing(self) -> None: + """A device that never edited a schedule cannot outrank a peer.""" + assert schedule_fields(()) == {} + + def test_one_field_per_entry(self) -> None: + """Each entry becomes its own ``sched:`` field.""" + fields = schedule_fields( + (_entry("2026-08-16", MealSchedule(8, 20, 5), "2026-08-16T10:00:00+02:00"),) + ) + assert set(fields) == {f"{SCHEDULE_FIELD_PREFIX}2026-08-16"} + value, _ = fields[f"{SCHEDULE_FIELD_PREFIX}2026-08-16"] + assert value == {"f": 8, "l": 20, "n": 5} + + def test_hlc_is_deterministic(self) -> None: + """Re-syncing an unchanged history is a no-op, not a fresh write.""" + entry = _entry( + "2026-08-16", MealSchedule(8, 20, 5), "2026-08-16T10:00:00+02:00" + ) + first = schedule_fields((entry,))[f"{SCHEDULE_FIELD_PREFIX}2026-08-16"][1] + second = schedule_fields((entry,))[f"{SCHEDULE_FIELD_PREFIX}2026-08-16"][1] + assert first.wall_time_ms == second.wall_time_ms + + def test_unparsable_timestamp_falls_back_to_the_epoch(self) -> None: + """A malformed stamp still yields a clock, so the entry still merges.""" + entry = _entry("2026-08-16", MealSchedule(8, 20, 5), "not-a-timestamp") + hlc = schedule_fields((entry,))[f"{SCHEDULE_FIELD_PREFIX}2026-08-16"][1] + assert hlc.wall_time_ms == 0 + + +class TestLogToScheduleHistory: + """Extracting the history back out of a merged log.""" + + def test_absent_record_yields_nothing(self) -> None: + """No budget record at all means no schedule history.""" + assert log_to_schedule_history({}) == () + + def test_round_trips(self) -> None: + """What one device pushes is what the other reads back.""" + entries = ( + _entry("2026-08-16", MealSchedule(8, 20, 5), "2026-08-16T10:00:00+02:00"), + ) + back = log_to_schedule_history(budget_to_log(_RECORD, (), entries)) + assert len(back) == 1 + assert back[0].effective_from == "2026-08-16" + assert back[0].schedule == MealSchedule(8, 20, 5) + + def test_normalizes_a_peers_out_of_range_schedule(self) -> None: + """A future peer cannot make this device derive different slots.""" + log = budget_to_log(_RECORD, ()) + record = log["budget"] + hlc = next(iter(record.fields.values()))[1] + record.fields[f"{SCHEDULE_FIELD_PREFIX}2026-08-16"] = ( + {"f": 8, "l": 20, "n": 99}, + hlc, + ) + assert log_to_schedule_history(log)[0].schedule == MealSchedule(8, 20, 6) + + def test_skips_malformed_values(self) -> None: + """One bad field from a peer cannot take out the whole history.""" + log = budget_to_log(_RECORD, ()) + record = log["budget"] + hlc = next(iter(record.fields.values()))[1] + record.fields[f"{SCHEDULE_FIELD_PREFIX}2026-01-01"] = (7, hlc) + record.fields[f"{SCHEDULE_FIELD_PREFIX}2026-02-01"] = ({"f": "8"}, hlc) + record.fields[f"{SCHEDULE_FIELD_PREFIX}2026-03-01"] = ( + {"f": True, "l": 20, "n": 4}, + hlc, + ) + assert log_to_schedule_history(log) == () + + +class TestCrossDeviceMerge: + """The property that makes this shippable without a coordinated release.""" + + def test_a_pre_feature_peer_relays_the_schedule_untouched(self) -> None: + """A device that knows nothing about schedules cannot drop them. + + ``merge_record`` is per-field LWW over the *union* of field names, and + both devices push the merged record, so the peer's budget-only push + merges the fields in rather than clobbering them. + """ + entries = ( + _entry("1970-01-01", DEFAULT_SCHEDULE, "1970-01-01T00:00:00+00:00"), + _entry("2026-08-16", MealSchedule(8, 20, 5), "2026-08-16T10:00:00+02:00"), + ) + ours = budget_to_log(_RECORD, (), entries) + peer = budget_to_log( + {"v": 2, "b": 1900, "t": "2026-08-16T11:00:00+02:00"}, (), () + ) + + merged = merge_logs(parse_remote_budget(_wire(ours)), peer) + back = log_to_schedule_history(merged) + + assert [entry.schedule for entry in back] == [ + DEFAULT_SCHEDULE, + MealSchedule(8, 20, 5), + ] + + def test_the_newer_edit_wins_per_day(self) -> None: + """Two devices editing the same day converge on the later edit.""" + older = budget_to_log( + _RECORD, + (), + ( + _entry( + "2026-08-16", MealSchedule(8, 20, 5), "2026-08-16T10:00:00+02:00" + ), + ), + ) + newer = budget_to_log( + _RECORD, + (), + ( + _entry( + "2026-08-16", MealSchedule(7, 21, 3), "2026-08-16T18:00:00+02:00" + ), + ), + ) + + merged = merge_logs(parse_remote_budget(_wire(older)), newer) + assert log_to_schedule_history(merged)[0].schedule == MealSchedule(7, 21, 3)