mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-10 19:27:14 +01:00
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
189 lines
6.9 KiB
Python
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)
|