Sync the meal schedule as sched: fields on the budget record

Without this the schedule would be device-local, which is the failure this
whole feature has to avoid: the phone sets five meals, the PC still derives
four, and the PC's gate nags for a 12:00 checkpoint the phone never offered
and can never satisfy. So sync lands before either edit surface, not after.

The history rides the existing `budget` CRDT record as one
`sched:<YYYY-MM-DD>` field per entry, exactly like the budget's own `hist:`
fields. merge_record is per-field LWW over the *union* of field names and both
devices push the merged record, so a device that predates the feature relays
the fields untouched -- no coordinated release. There is a test for precisely
that: merging our push with a budget-only peer keeps both schedule entries.

Verified the two languages agree on the wire, not just in their own suites --
Python and Dart both emit
  {"sched:1970-01-01":{"f":8,"l":20,"n":4},"sched:2026-08-16":{"f":8,"l":20,"n":5}}
The Hlc is derived from the *parsed* timestamp on both sides, which matters
because the epoch fallback formats differently in each language
(1970-01-01T00:00:00+00:00 vs ...000Z) and a string comparison would diverge.

Two guards worth naming:

- A device with no history contributes no `sched:` fields at all, so an unset
  default can never outrank a peer's real schedule; and an empty merge result
  is never written back, so a pre-feature peer cannot erase a real history.
- Dart's `_syncBudget` synthesises a local record when the schedule has been
  edited but the budget never has -- `budgetToLog(null, ...)` returns an empty
  Log and would otherwise drop the schedule fields too. Its budget value
  carries an epoch stamp, so it loses every LWW race against a real edit.

sync_merge/_schedule.py and sync_merge_schedule.dart are separate modules
rather than additions to the budget adapters, which were at 186 and 170 lines
against the 250 cap. Each declares its own record-id constant so the Dart
pair does not import each other.

772 Python tests at 100% branch coverage, 619 Dart.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159b4E5hh512jYUeaJVNxfM
This commit is contained in:
Krzysztof Rudnicki 2026-08-16 14:49:08 +02:00
parent d2e62bf210
commit 24dbffc432
11 changed files with 621 additions and 4 deletions

View File

@ -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<String, dynamic>? record, [
List<BudgetEntry> entries = const [],
List<ScheduleEntry> 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)};
}

View File

@ -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:<YYYY-MM-DD>` 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<String, (dynamic, Hlc)> scheduleFields(List<ScheduleEntry> 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<ScheduleEntry> logToScheduleHistory(Log log) {
final record = log[_budgetRecordId];
if (record == null) return const [];
final entries = <ScheduleEntry>[];
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;
}

View File

@ -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<void> _syncManualBank(RemoteStore client) async {
/// from elsewhere.
Future<void> _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
: <String, dynamic>{
'v': 2,
'b': AppSettingsService.dailyKcalGoal,
't': updatedAt.toIso8601String(),
't': (updatedAt ?? DateTime.fromMillisecondsSinceEpoch(0))
.toIso8601String(),
};
final mergedBudgetLog = await syncLog(
@ -129,6 +142,7 @@ Future<void> _syncBudget(RemoteStore client) async {
localLog: budgetToLog(
localRecord,
BudgetHistoryService.schedule.entries,
scheduleEntries,
),
encode: encodeBudgetForPush,
decode: parseRemoteBudget,
@ -141,6 +155,14 @@ Future<void> _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'];

View File

@ -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

View File

@ -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 = <String, dynamic>{
'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 <String, dynamic>{
'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),
);
});
});
}

View File

@ -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()},

View File

@ -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",
]

View File

@ -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}

View File

@ -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:<YYYY-MM-DD>`` 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))

View File

@ -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()

View File

@ -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:<date>`` 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)