diet-guard/diet_guard/tests/test_sync_merge_schedule.py
Krzysztof kuhy Rudnicki c310861855 slots: minute-of-day consumers (phase 2 of 3: Python side)
Every Python consumer now speaks slot minutes:

- satisfaction is nearest-slot snapping (_slot_wire.satisfied_by_entries)
  at every "is this slot logged?" site: gate, CLI status, MCP, catering
  occupancy and dedup. Nothing on disk is rewritten; 07:00/10:00 entries
  keep covering 07:15/10:15 after a schedule move.
- log_meal writes slot_fields(minute) before signing (slot + slot_min).
- schedule store and sync encode via schedule_to_wire/from_wire; a
  whole-hour schedule stays byte-identical (f/l/n only).
- CLI ate --hour takes HH or HH:MM; MCP log_meal takes "HH:MM" or a
  legacy int hour, validated against today's slots; get_slots returns
  {minute, label}.
- gate History tab: first/last are HH:MM ttk.Comboboxes (quarter-hour
  list, typed times, Up/Down step without posting the popdown), locked
  as disabled; validation mirrors normalized().
- gate timer fires every 15 min; docs rewritten for minutes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015npsYVB2PdtWkG7MPAi4tn
2026-10-10 19:53:39 +02:00

189 lines
6.9 KiB
Python

"""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(480, 1200, 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(480, 1200, 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(480, 1200, 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(480, 1200, 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(480, 1200, 5)
def test_round_trips_an_off_hour_schedule(self) -> None:
"""07:15-19:00 crosses the wire with its minutes, ``f`` still an hour.
An older peer reads ``f``/``l`` and relays ``fm`` untouched; a peer on
this build reads ``fm`` and derives the same slots as the editor.
"""
entries = (
_entry(
"2026-08-16", MealSchedule(435, 1140, 5), "2026-08-16T10:00:00+02:00"
),
)
value, _ = schedule_fields(entries)[f"{SCHEDULE_FIELD_PREFIX}2026-08-16"]
assert value == {"f": 7, "l": 19, "n": 5, "fm": 435}
back = log_to_schedule_history(budget_to_log(_RECORD, (), entries))
assert back[0].schedule == MealSchedule(435, 1140, 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(480, 1200, 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(480, 1200, 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(480, 1200, 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(480, 1200, 5),
"2026-08-16T10:00:00+02:00",
),
),
)
newer = budget_to_log(
_RECORD,
(),
(
_entry(
"2026-08-16",
MealSchedule(420, 1260, 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(420, 1260, 3)