Split _sync.py and its tests; extract the gate fixtures from conftest

_sync.py 498 -> 165, into siblings that each own one concern:

  _sync_client.py  picking a backend and building the remote store
  _sync_banks.py   pull/merge/push for the food bank and the budget
  _sync_paths.py   the kuhyx/syncs wire layout, shared by both
  _sync_errors.py  SyncError, so _sync and _sync_client need not import
                   each other

The patch targets moved with the code they guard, which is the whole risk of
this commit. unittest.mock.patch and monkeypatch.setattr both bind to a
*module object*, so a patch left pointing at _sync after CONFIG_FILE and
SYNC_TOKEN_FILE moved to _sync_client redirects nothing. That is not
hypothetical here: the first run of the suite took 9m18s instead of 7s and
raised 401s, because tests were reaching the real GitHub API with a fake
token. Repointed:

  conftest       SYNC_TOKEN_FILE, CONFIG_FILE      -> _sync_client
  test_sync*     GitHubSyncClient (30 patch sites) -> _sync_client
  test_backend   mirror/firebase_client_for        -> _sync_client

The repo's own _test_guard caught the same leak independently, which is the
behaviour worth keeping: two guards, both fail-closed.

test_sync.py 482 -> 205, splitting the budget and pull/revision classes into
siblings and the shared fakes into _sync_fakes.py.

conftest.py 284 -> 155, moving the Tk/gatelock fixtures to _gate_fixtures.py.
Importing them back is what registers them (pytest_plugins is an error in a
non-root conftest), and they are named in __all__ because ruff's autofix
otherwise deletes the "unused" import and silently disables Tk blocking --
it did exactly that once here. Verified afterwards with a canary asserting
_gatelock.tk is still a MagicMock, not merely that the suite passes.

Verified: 692 tests / 100% coverage unchanged; ~/.local/share/diet_guard
byte-identical before and after the run; cd /tmp && python3 -m diet_guard sync
-> 402 entries, exit 0. Gate 47 -> 44.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MK3HCZrM75sRHL1gL8usve
This commit is contained in:
Krzysztof Rudnicki 2026-08-15 18:51:18 +02:00
parent fef140d970
commit 1e201acbdc
26 changed files with 1824 additions and 1477 deletions

View File

@ -26,9 +26,9 @@ repos:
- id: debug-statements
- id: name-tests-test
args: [--pytest-test-first]
# Shared fake widgets, not a test module. Split out of conftest.py only
# because that file passed the 250-line cap.
exclude: ^diet_guard/tests/_(tk_fakes|gate_layout_probe|gate_keyboard_probe)\.py$
# Shared fakes and fixtures, not test modules. Split out of conftest.py
# and test_sync.py only because those files passed the 250-line cap.
exclude: ^diet_guard/tests/_(tk_fakes|gate_layout_probe|gate_keyboard_probe|gate_fixtures|sync_fakes)\.py$
- id: check-ast
- id: mixed-line-ending
args: [--fix=lf]

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.14+1
version: 1.0.15+1
environment:
sdk: ^3.12.2

View File

@ -16,7 +16,7 @@ Storage and sync are deliberately split:
* the entries live in their own file (``.budget_history``), leaving
:mod:`diet_guard._budget`'s ``.budget`` schema untouched at v2;
* they travel as extra ``hist:<YYYY-MM-DD>`` *fields* on the existing
``budget`` CRDT record (see :mod:`diet_guard._sync_merge`).
``budget`` CRDT record (see :mod:`diet_guard.sync_merge`).
That second point is the load-bearing one. ``crdt_sync``'s ``merge_record``
does per-field last-writer-wins over the *union* of field names, and both

View File

@ -1,7 +1,7 @@
"""This machine's persisted sync identity.
Its own module rather than living in :mod:`diet_guard._sync`, because
:mod:`diet_guard._sync_merge` needs it too for HLC stamping and importing
:mod:`diet_guard.sync_merge` needs it too for HLC stamping and importing
``_sync`` from there would be a cycle.
"""

View File

@ -2,7 +2,7 @@
Pulls every other device's pushed log from GitHub-backed dumb storage
(``crdt_sync.GitHubSyncClient``), merges with the local log via
``crdt_sync``'s shared CRDT scheme (:mod:`diet_guard._sync_merge` adapts
``crdt_sync``'s shared CRDT scheme (:mod:`diet_guard.sync_merge` adapts
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.
@ -20,176 +20,38 @@ import json
import logging
from crdt_sync import (
CONFIG_FILE,
ConfigError,
DeviceIdentity,
FileSyncStateStore,
FirebaseAuthError,
GitHubSyncClient,
GitHubSyncError,
Log,
RemoteStore,
RemoteSyncError,
SyncState,
firebase_client_for,
merge_logs,
mirror_client_for,
revision_of,
)
from diet_guard._budget import read_raw_record, write_raw_record
from diet_guard._budget_history import (
history_to_json,
load_entries,
write_raw_history,
)
from diet_guard._constants import (
SYNC_REPO_NAME,
SYNC_REPO_OWNER,
SYNC_STATE_FILE,
SYNC_TIMEOUT_SECONDS,
SYNC_TOKEN_FILE,
)
from diet_guard._device import device_identity
from diet_guard._foodbank import read_food_bank, rebuild_food_bank, write_food_bank
from diet_guard._foodbank_manual import read_manual_bank, write_manual_bank
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 (
budget_to_log,
from diet_guard._sync_banks import _sync_budget, _sync_food_bank, _sync_manual_bank
from diet_guard._sync_client import _client_for_run
from diet_guard._sync_errors import SyncError
from diet_guard._sync_paths import _DEVICES_DIR, _REVS_DIR, _device_log_path
from diet_guard.sync_merge import (
daylog_to_log,
food_bank_to_log,
log_to_budget,
log_to_daylog,
log_to_food_bank,
log_to_history,
log_to_manual_bank,
manual_bank_to_log,
parse_remote_budget,
parse_remote_food_bank,
parse_remote_log,
parse_remote_manual_bank,
)
_logger = logging.getLogger(__name__)
_DEVICES_DIR = "diet-guard-sync/devices"
# One small text node per device, each written only by its owner. Deliberately
# not one shared map: a whole-map write would erase every other device's entry,
# after which those peers would look permanently unchanged and never be
# fetched again.
_REVS_DIR = "diet-guard-sync/revs"
class SyncError(Exception):
"""Raised when a sync run cannot even start (no usable PAT)."""
def _remote_client(github: GitHubSyncClient) -> RemoteStore:
"""Return the backend to sync against.
Firebase when ``~/.config/crdt-sync/`` is set up, with GitHub kept as a
mirror so a device that has not moved yet still converges; GitHub alone
otherwise. An unconfigured machine keeps syncing exactly as before.
Every sub-sync here (log, budget, food bank, manual bank) takes the client
as a parameter, so this one swap covers all of them.
The config file is checked before constructing anything, so an
unconfigured machine never reaches the network -- otherwise a suite that
blocks real sockets fails here rather than in the code under test.
Rolling back is deleting this function and passing ``github`` straight
through: no data moves either way.
"""
if not CONFIG_FILE.is_file():
return github
try:
return mirror_client_for("diet_guard", github)
except (ConfigError, FirebaseAuthError, RemoteSyncError) as exc:
_logger.warning("Firebase unavailable, syncing via GitHub only: %s", exc)
return github
def _client_for_run() -> RemoteStore:
"""Return the backend for one sync run, requiring only *one* to be set up.
The PAT is no longer mandatory. Firebase is the primary backend now, and
requiring a GitHub token before ever constructing it meant a
Firebase-configured machine with no PAT could not sync at all -- the
commits that fixed exactly this ("Auto-sync on a Firebase-only device")
changed only the Dart side, leaving the Python half behind.
A missing PAT is therefore fatal only when Firebase is *also* unconfigured,
which is the genuine "nothing is set up" case the caller must report.
"""
try:
token = _read_token()
except SyncError:
if not CONFIG_FILE.is_file():
raise
# Firebase-only: there is no GitHub client to mirror to, so use the
# primary directly rather than failing the whole run.
#
# ConfigError is translated rather than propagated: it is *not* a
# RemoteSyncError (it subclasses Exception directly), so letting it out
# would escape every caller's catch tuple and raise a traceback out of
# the gate's fail-closed "Fetch from sync" button -- the exact failure
# the RemoteSyncError swap was made to stop. A config file that exists
# but is unusable is reached by precisely this branch.
try:
return firebase_client_for("diet_guard")
except ConfigError as exc:
message = f"Firebase config at {CONFIG_FILE} is unusable: {exc}"
raise SyncError(message) from exc
return _remote_client(
GitHubSyncClient(
SYNC_REPO_OWNER,
SYNC_REPO_NAME,
token,
timeout_seconds=SYNC_TIMEOUT_SECONDS,
)
)
def _device_log_path(device_id: str) -> str:
"""Return the repo-relative path a device's full log is pushed to."""
return f"{_DEVICES_DIR}/{device_id}/food_log.json"
def _device_food_bank_path(device_id: str) -> str:
"""Return one device's pushed derived-food-bank path."""
return f"{_DEVICES_DIR}/{device_id}/food_bank.json"
def _device_manual_bank_path(device_id: str) -> str:
"""Return one device's pushed curated-food-bank path."""
return f"{_DEVICES_DIR}/{device_id}/food_bank_manual.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.
Raises:
SyncError: If the token file is missing or empty -- the user has not
completed the one-time github.com setup step yet.
"""
if not SYNC_TOKEN_FILE.exists():
message = (
f"no sync token at {SYNC_TOKEN_FILE} -- create a fine-grained "
"GitHub PAT scoped to the syncs repo's contents and "
f"save it there (mode 600), then re-run sync"
)
raise SyncError(message)
token = SYNC_TOKEN_FILE.read_text().strip()
if not token:
msg = f"{SYNC_TOKEN_FILE} is empty"
raise SyncError(msg)
return token
def _remote_revisions(client: RemoteStore) -> dict[str, str]:
@ -254,152 +116,6 @@ def _pull_remote_logs(
return remote_logs
def _sync_food_bank(client: GitHubSyncClient) -> None:
"""Pull, merge, persist and push the log-derived food bank.
Runs *after* the local rebuild in :func:`run_sync`, so this device's own
records already reflect the merged log; the merge then unions in whatever
another device knows and max-count wins per food (see
:func:`diet_guard._sync_merge.food_bank_to_log`).
Strictly speaking the bank is derivable from the already-synced log, so
both devices converge on their own eventually. Syncing it makes them
agree *now*, and publishes the bank so a fresh device has autocomplete
before it has replayed anything.
**The log stays authoritative for which foods exist.** A CRDT union
never shrinks, so without this the max-count merge would resurrect a food
whose entries were all undone -- a peer's stale copy would out-clock the
local absence and be written back and re-pushed forever, un-deletable.
Restricting the result to the foods the freshly-rebuilt local bank still
contains fixes that, and stays identical across devices because that
rebuild comes from the *merged* log both devices share.
"""
identity = device_identity()
local = read_food_bank()
merged = food_bank_to_log(local)
for device_id in client.list_directory(_DEVICES_DIR):
if identity.is_own(device_id):
continue
text = client.get_file_text(_device_food_bank_path(device_id))
if text is None:
continue
try:
merged = merge_logs(merged, parse_remote_food_bank(text))
except (TypeError, KeyError, ValueError, json.JSONDecodeError):
_logger.warning(
"Unparsable food bank pushed by device %r, skipping",
device_id,
)
if not merged:
return
resolved = {
name: record
for name, record in log_to_food_bank(merged).items()
if name in local
}
write_food_bank(resolved)
merged = food_bank_to_log(resolved)
client.put_file_text(
_device_food_bank_path(identity.device_id),
json.dumps(
{record_id: record.to_dict() for record_id, record in merged.items()},
indent=2,
),
message="diet_guard sync",
)
def _sync_manual_bank(client: GitHubSyncClient) -> None:
"""Pull, merge, persist and push the hand-curated food bank.
Curated entries are the one part of the bank that is not derivable from
the food log (see :mod:`diet_guard._foodbank_manual`), so unlike
``food_bank.json`` they need a real merge: last-writer-wins per food name
by edit time, union across devices.
"""
identity = device_identity()
merged = manual_bank_to_log(read_manual_bank())
for device_id in client.list_directory(_DEVICES_DIR):
if identity.is_own(device_id):
continue
text = client.get_file_text(_device_manual_bank_path(device_id))
if text is None:
continue
try:
merged = merge_logs(merged, parse_remote_manual_bank(text))
except (TypeError, KeyError, ValueError, json.JSONDecodeError):
_logger.warning(
"Unparsable curated food bank pushed by device %r, skipping",
device_id,
)
if not merged:
# No device has curated anything: nothing to persist, and pushing an
# empty object every tick would be pure churn.
return
write_manual_bank(log_to_manual_bank(merged))
client.put_file_text(
_device_manual_bank_path(identity.device_id),
json.dumps(
{record_id: record.to_dict() for record_id, record in merged.items()},
indent=2,
),
message="diet_guard sync",
)
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.
"""
identity = device_identity()
merged = budget_to_log(read_raw_record(), load_entries())
for device_id in client.list_directory(_DEVICES_DIR):
if identity.is_own(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)
merged_history = log_to_history(merged)
# Only write back when the merge actually carried history. A pre-feature
# peer contributes none, and persisting an empty document would look like
# "history already exists" to any presence-based check and stop the local
# seed from ever running.
if merged_history:
write_raw_history(history_to_json(merged_history))
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(identity.device_id),
push_json,
message="diet_guard sync",
)
def run_sync() -> DayLog:
"""Run one full sync tick: pull, merge, re-sign, persist, push.

199
diet_guard/_sync_banks.py Normal file
View File

@ -0,0 +1,199 @@
"""Pull/merge/push for the food bank and the budget.
Split out of ``_sync.py`` for file size. These three run inside the same tick
as the food-log sync but are independent of it: each owns one remote document,
merges it with ``crdt_sync``'s LWW, writes the merged result back locally and
pushes it.
The derived bank merges by ``count`` (max-count-wins, idempotent -- the right
merge for a counter derived from the log), the curated bank by ``editedAt``,
and the budget by its ``t`` edit stamp. All three are per-field LWW over the
*union* of field names, so a device predating a field relays it untouched.
"""
from __future__ import annotations
import json
import logging
from typing import TYPE_CHECKING
from crdt_sync import merge_logs
from diet_guard._budget import read_raw_record, write_raw_record
from diet_guard._budget_history import (
history_to_json,
load_entries,
write_raw_history,
)
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._sync_paths import (
_DEVICES_DIR,
_device_budget_path,
_device_food_bank_path,
_device_manual_bank_path,
)
from diet_guard.sync_merge import (
budget_to_log,
food_bank_to_log,
log_to_budget,
log_to_food_bank,
log_to_history,
log_to_manual_bank,
manual_bank_to_log,
parse_remote_budget,
parse_remote_food_bank,
parse_remote_manual_bank,
)
if TYPE_CHECKING:
from crdt_sync import GitHubSyncClient
_logger = logging.getLogger(__name__)
def _sync_food_bank(client: GitHubSyncClient) -> None:
"""Pull, merge, persist and push the log-derived food bank.
Runs *after* the local rebuild in :func:`run_sync`, so this device's own
records already reflect the merged log; the merge then unions in whatever
another device knows and max-count wins per food (see
:func:`diet_guard.sync_merge.food_bank_to_log`).
Strictly speaking the bank is derivable from the already-synced log, so
both devices converge on their own eventually. Syncing it makes them
agree *now*, and publishes the bank so a fresh device has autocomplete
before it has replayed anything.
**The log stays authoritative for which foods exist.** A CRDT union
never shrinks, so without this the max-count merge would resurrect a food
whose entries were all undone -- a peer's stale copy would out-clock the
local absence and be written back and re-pushed forever, un-deletable.
Restricting the result to the foods the freshly-rebuilt local bank still
contains fixes that, and stays identical across devices because that
rebuild comes from the *merged* log both devices share.
"""
identity = device_identity()
local = read_food_bank()
merged = food_bank_to_log(local)
for device_id in client.list_directory(_DEVICES_DIR):
if identity.is_own(device_id):
continue
text = client.get_file_text(_device_food_bank_path(device_id))
if text is None:
continue
try:
merged = merge_logs(merged, parse_remote_food_bank(text))
except (TypeError, KeyError, ValueError, json.JSONDecodeError):
_logger.warning(
"Unparsable food bank pushed by device %r, skipping",
device_id,
)
if not merged:
return
resolved = {
name: record
for name, record in log_to_food_bank(merged).items()
if name in local
}
write_food_bank(resolved)
merged = food_bank_to_log(resolved)
client.put_file_text(
_device_food_bank_path(identity.device_id),
json.dumps(
{record_id: record.to_dict() for record_id, record in merged.items()},
indent=2,
),
message="diet_guard sync",
)
def _sync_manual_bank(client: GitHubSyncClient) -> None:
"""Pull, merge, persist and push the hand-curated food bank.
Curated entries are the one part of the bank that is not derivable from
the food log (see :mod:`diet_guard._foodbank_manual`), so unlike
``food_bank.json`` they need a real merge: last-writer-wins per food name
by edit time, union across devices.
"""
identity = device_identity()
merged = manual_bank_to_log(read_manual_bank())
for device_id in client.list_directory(_DEVICES_DIR):
if identity.is_own(device_id):
continue
text = client.get_file_text(_device_manual_bank_path(device_id))
if text is None:
continue
try:
merged = merge_logs(merged, parse_remote_manual_bank(text))
except (TypeError, KeyError, ValueError, json.JSONDecodeError):
_logger.warning(
"Unparsable curated food bank pushed by device %r, skipping",
device_id,
)
if not merged:
# No device has curated anything: nothing to persist, and pushing an
# empty object every tick would be pure churn.
return
write_manual_bank(log_to_manual_bank(merged))
client.put_file_text(
_device_manual_bank_path(identity.device_id),
json.dumps(
{record_id: record.to_dict() for record_id, record in merged.items()},
indent=2,
),
message="diet_guard sync",
)
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.
"""
identity = device_identity()
merged = budget_to_log(read_raw_record(), load_entries())
for device_id in client.list_directory(_DEVICES_DIR):
if identity.is_own(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)
merged_history = log_to_history(merged)
# Only write back when the merge actually carried history. A pre-feature
# peer contributes none, and persisting an empty document would look like
# "history already exists" to any presence-based check and stop the local
# seed from ever running.
if merged_history:
write_raw_history(history_to_json(merged_history))
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(identity.device_id),
push_json,
message="diet_guard sync",
)

131
diet_guard/_sync_client.py Normal file
View File

@ -0,0 +1,131 @@
"""Choosing and building the remote store one sync tick will use.
Split out of ``_sync.py`` for file size.
Two backends, and the tick needs exactly one: Firebase (primary, configured
at ``~/.config/crdt-sync/``) or a GitHub PAT mirror at
``~/.config/diet_guard/sync_token``. Neither configured is not an error --
:func:`_client_for_run` raises :class:`~diet_guard._sync.SyncError` and the
caller logs ``sync not configured`` and no-ops, which is what a machine that
has never been set up should do on a 15-minute timer.
``ConfigError`` subclasses ``Exception`` directly rather than
``RemoteSyncError``, so it is caught explicitly here and translated -- a
sibling-not-subclass relationship that has bitten this file before.
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from crdt_sync import (
CONFIG_FILE,
ConfigError,
FirebaseAuthError,
GitHubSyncClient,
RemoteSyncError,
firebase_client_for,
mirror_client_for,
)
from diet_guard._constants import (
SYNC_REPO_NAME,
SYNC_REPO_OWNER,
SYNC_TIMEOUT_SECONDS,
SYNC_TOKEN_FILE,
)
from diet_guard._sync_errors import SyncError
if TYPE_CHECKING:
from crdt_sync import RemoteStore
_logger = logging.getLogger(__name__)
def _remote_client(github: GitHubSyncClient) -> RemoteStore:
"""Return the backend to sync against.
Firebase when ``~/.config/crdt-sync/`` is set up, with GitHub kept as a
mirror so a device that has not moved yet still converges; GitHub alone
otherwise. An unconfigured machine keeps syncing exactly as before.
Every sub-sync here (log, budget, food bank, manual bank) takes the client
as a parameter, so this one swap covers all of them.
The config file is checked before constructing anything, so an
unconfigured machine never reaches the network -- otherwise a suite that
blocks real sockets fails here rather than in the code under test.
Rolling back is deleting this function and passing ``github`` straight
through: no data moves either way.
"""
if not CONFIG_FILE.is_file():
return github
try:
return mirror_client_for("diet_guard", github)
except (ConfigError, FirebaseAuthError, RemoteSyncError) as exc:
_logger.warning("Firebase unavailable, syncing via GitHub only: %s", exc)
return github
def _client_for_run() -> RemoteStore:
"""Return the backend for one sync run, requiring only *one* to be set up.
The PAT is no longer mandatory. Firebase is the primary backend now, and
requiring a GitHub token before ever constructing it meant a
Firebase-configured machine with no PAT could not sync at all -- the
commits that fixed exactly this ("Auto-sync on a Firebase-only device")
changed only the Dart side, leaving the Python half behind.
A missing PAT is therefore fatal only when Firebase is *also* unconfigured,
which is the genuine "nothing is set up" case the caller must report.
"""
try:
token = _read_token()
except SyncError:
if not CONFIG_FILE.is_file():
raise
# Firebase-only: there is no GitHub client to mirror to, so use the
# primary directly rather than failing the whole run.
#
# ConfigError is translated rather than propagated: it is *not* a
# RemoteSyncError (it subclasses Exception directly), so letting it out
# would escape every caller's catch tuple and raise a traceback out of
# the gate's fail-closed "Fetch from sync" button -- the exact failure
# the RemoteSyncError swap was made to stop. A config file that exists
# but is unusable is reached by precisely this branch.
try:
return firebase_client_for("diet_guard")
except ConfigError as exc:
message = f"Firebase config at {CONFIG_FILE} is unusable: {exc}"
raise SyncError(message) from exc
return _remote_client(
GitHubSyncClient(
SYNC_REPO_OWNER,
SYNC_REPO_NAME,
token,
timeout_seconds=SYNC_TIMEOUT_SECONDS,
)
)
def _read_token() -> str:
"""Return the saved sync PAT, stripped of trailing whitespace.
Raises:
SyncError: If the token file is missing or empty -- the user has not
completed the one-time github.com setup step yet.
"""
if not SYNC_TOKEN_FILE.exists():
message = (
f"no sync token at {SYNC_TOKEN_FILE} -- create a fine-grained "
"GitHub PAT scoped to the syncs repo's contents and "
f"save it there (mode 600), then re-run sync"
)
raise SyncError(message)
token = SYNC_TOKEN_FILE.read_text().strip()
if not token:
msg = f"{SYNC_TOKEN_FILE} is empty"
raise SyncError(msg)
return token

View File

@ -0,0 +1,17 @@
"""The one exception a sync tick raises before it can start.
Its own module so that :mod:`diet_guard._sync_client`, which raises it, and
:mod:`diet_guard._sync`, which catches it and re-exports it for callers, do
not have to import each other.
Deliberately *not* a subclass of ``crdt_sync.RemoteSyncError``: this means
"never got far enough to talk to a remote" (no PAT, no usable Firebase
config), which callers treat as a quiet no-op rather than as a sync failure
worth surfacing.
"""
from __future__ import annotations
class SyncError(Exception):
"""Raised when a sync run cannot even start (no usable PAT)."""

View File

@ -1,470 +0,0 @@
"""Entry <-> crdt_sync.Record adapters for diet_guard's cross-device sync.
diet_guard's own on-disk ``food_log.json`` format is unchanged (a
:class:`~diet_guard._state.DayLog`: date string -> list of entry dicts) --
only the GitHub-synced wire format and the cross-device merge algorithm now
go through ``crdt_sync``'s ``Record``/``Log``/``Hlc`` primitives, the same
ones every other kuhy app that syncs this way uses (see ``~/crdt-sync``).
Each diet_guard entry maps to one ``Record`` with a single opaque ``body``
field holding everything except ``id``/``deleted``: entries are immutable
after creation (only ``deleted`` ever changes post-write, see
:func:`diet_guard._state.resign_entry`), so there is no benefit to
``crdt_sync``'s per-field LWW granularity here -- the whole body shares one
derived ``Hlc``. ``hmac`` travels inside ``body`` like any other field but
is never trusted on read; :func:`diet_guard._sync.run_sync` always re-signs
after merging, exactly as before this migration.
Backward compatible with devices not yet migrated (the phone app, for now):
:func:`parse_remote_log` tries the new Record-based wire format first and
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
from datetime import datetime, timezone
import hashlib
import json
from typing import TYPE_CHECKING
from crdt_sync import Hlc, Record
from diet_guard._budget_history import BudgetEntry
from diet_guard._device import device_id
from diet_guard._foodbank_manual import record_edit_time
if TYPE_CHECKING:
from collections.abc import Mapping
from crdt_sync import Log
from diet_guard._state import DayLog
_EPOCH = datetime(1970, 1, 1, tzinfo=timezone.utc)
def _wall_time_ms(stamp: str) -> int:
"""Return ``stamp``'s epoch milliseconds, or the epoch when unparsable."""
try:
moment = datetime.fromisoformat(stamp)
except ValueError:
moment = _EPOCH
return int(moment.timestamp() * 1000)
def _entry_hlc(entry: dict[str, object]) -> Hlc:
"""Derive a deterministic Hlc for ``entry`` from its own ``time`` field.
The same entry always yields the same Hlc regardless of when this runs
-- entries are immutable after creation, so there's no real "now" to
stamp, just the birth-time already recorded on the entry itself.
Malformed/missing ``time`` still gets a valid (if early-sorting) Hlc
rather than raising -- this only affects tie-breaking between
otherwise-identical copies of the same id, never whether the entry
survives a merge.
"""
try:
dt = datetime.fromisoformat(str(entry.get("time", "")))
except ValueError:
dt = _EPOCH
wall_time_ms = int(dt.timestamp() * 1000)
return Hlc.new_tick(device_id(), wall_time_ms=wall_time_ms)
def _legacy_entry_id(entry: dict[str, object]) -> str:
"""Deterministic id for a pre-``id`` legacy entry, from ``(time, desc)``.
Two devices holding the same legacy entry independently derive the same
id without communicating, so they merge as one record instead of two --
the same guarantee the old ``(time, desc)`` dedup key gave, just
expressed as a real id going forward.
"""
key = f"{entry.get('time')}|{entry.get('desc')}"
digest = hashlib.sha256(key.encode()).hexdigest()[:32]
return f"legacy-{digest}"
def entry_to_record(entry: dict[str, object]) -> Record:
"""Convert one diet_guard log entry to a ``crdt_sync.Record``."""
entry_id = entry.get("id")
if not isinstance(entry_id, str) or not entry_id:
entry_id = _legacy_entry_id(entry)
hlc = _entry_hlc(entry)
body = {k: v for k, v in entry.items() if k not in ("id", "deleted")}
deleted = bool(entry.get("deleted", False))
return Record(
id=entry_id,
fields={"body": (body, hlc)},
deleted=deleted,
deleted_hlc=hlc if deleted else None,
)
def record_to_entry(record: Record) -> dict[str, object]:
"""Convert one ``crdt_sync.Record`` back to a diet_guard log entry."""
body_value, _hlc = record.fields.get("body", ({}, None))
entry: dict[str, object] = dict(body_value) if isinstance(body_value, dict) else {}
entry["id"] = record.id
if record.deleted:
entry["deleted"] = True
return entry
def daylog_to_log(daylog: DayLog) -> Log:
"""Convert a full local/remote DayLog into a ``crdt_sync.Log``."""
log: Log = {}
for entries in daylog.values():
for entry in entries:
record = entry_to_record(entry)
log[record.id] = record
return log
def log_to_daylog(log: Log) -> DayLog:
"""Convert a merged ``crdt_sync.Log`` back into DayLog shape.
Each entry is re-bucketed under its own ``time``'s date rather than
whatever date key it might have arrived under, and each day's entries
are sorted oldest-first -- matching the existing on-disk convention.
"""
daylog: DayLog = {}
for record in log.values():
entry = record_to_entry(record)
date_key = str(entry.get("time", ""))[:10]
daylog.setdefault(date_key, []).append(entry)
for entries in daylog.values():
entries.sort(key=lambda entry: str(entry.get("time", "")))
return daylog
def _looks_like_new_format(raw: dict[str, object]) -> bool:
"""Return True if ``raw`` is shaped like a crdt_sync Record-keyed Log.
An empty object is ambiguous but harmless either way (no entries to
convert), so it's treated as new format to skip the old-format
conversion pass for nothing.
"""
return all(
isinstance(value, dict) and "fields" in value and "id" in value
for value in raw.values()
)
def parse_remote_log(text: str) -> Log:
"""Parse one device's pushed log text into a ``crdt_sync.Log``.
Tries the new Record-based wire format first; falls back to the old
plain-DayLog format (today's on-the-wire shape) for devices not yet
migrated onto crdt_sync, converting their entries through the same
adapter the local log uses. Raises on genuinely malformed data, same as
the pre-migration behavior -- the caller
(:func:`diet_guard._sync._pull_remote_logs`) already logs-and-skips on
that.
Raises:
TypeError: If the top-level JSON isn't an object, or a "new format"
value or an old-format day's entries aren't shaped as expected.
KeyError: Via ``Record.from_dict``, if a "new format" 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 sync payload is not a JSON object: {raw!r}"
raise TypeError(msg)
if _looks_like_new_format(raw):
return {record_id: Record.from_dict(data) for record_id, data in raw.items()}
daylog: DayLog = {}
for date_key, entries in raw.items():
if not isinstance(entries, list):
msg = f"day {date_key!r} is not a JSON array: {entries!r}"
raise TypeError(msg)
for entry in entries:
if not isinstance(entry, dict):
msg = f"entry under day {date_key!r} is not a JSON object: {entry!r}"
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"
# Field-name prefix for the effective-from budget history, one field per date.
# A separate *field* rather than a separate document, because merge_record
# unions field names -- see budget_to_log.
_HISTORY_FIELD_PREFIX = "hist:"
# Body weight, as its own field rather than a key inside ``value``. It is
# shared state like everything else -- both devices must see one value -- but
# it needs its own Hlc so a device that never sets it (the phone) cannot
# delete it merely by pushing a budget edit.
_WEIGHT_FIELD = "weight"
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(device_id(), wall_time_ms=wall_time_ms)
def _history_hlc(entry: BudgetEntry) -> Hlc:
"""Derive a deterministic Hlc for one history entry from its edit time.
Same trick as :func:`_budget_hlc`: identical inputs always yield the same
clock, so re-syncing unchanged history is a no-op. Two devices that seed
the history independently derive the same ``wall_time_ms`` and the same
value and differ only in node id, so whichever side wins the field-level
LWW, the value is identical and the merge converges in one round.
"""
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 budget_to_log(
record: dict[str, object] | None,
entries: tuple[BudgetEntry, ...] = (),
) -> Log:
"""Convert a raw local budget record plus its history into a 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.
The history rides along as one ``hist:<YYYY-MM-DD>`` field per entry on
the *same* record. ``crdt_sync``'s ``merge_record`` is per-field LWW over
the union of field names, so a device that predates the history neither
clobbers those fields nor blocks them: it merges them in from the remote
and pushes them straight back out (both sides push the *merged* log).
That is what makes this safe to roll out without a coordinated release.
``w`` (body weight) travels the same way, as its own :data:`_WEIGHT_FIELD`
rather than as a key inside ``value``. It is shared state -- both devices
must agree on it -- but inside ``value`` it was collateral damage of the
whole-map LWW: the phone rebuilds that map without ``w``, so any phone
budget edit silently deleted the stored weight and with it the protein
target. As its own field it is protected by per-field LWW, and a device
that never sets it relays it untouched instead of dropping it.
"""
if record is None:
return {}
hlc = _budget_hlc(record)
value = {k: v for k, v in record.items() if k not in {"t", "w"}}
fields: dict[str, tuple[object, Hlc]] = {"value": (value, hlc)}
weight = record.get("w")
if isinstance(weight, (int, float)) and not isinstance(weight, bool):
fields[_WEIGHT_FIELD] = (float(weight), hlc)
for entry in entries:
fields[f"{_HISTORY_FIELD_PREFIX}{entry.effective_from}"] = (
entry.kcal,
_history_hlc(entry),
)
rec = Record(id=_BUDGET_RECORD_ID, fields=fields)
return {rec.id: rec}
def log_to_history(log: Log) -> tuple[BudgetEntry, ...]:
"""Extract the effective-from 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.
"""
record = log.get(_BUDGET_RECORD_ID)
if record is None:
return ()
entries = []
for name, (value, hlc) in record.fields.items():
if not name.startswith(_HISTORY_FIELD_PREFIX):
continue
if isinstance(value, bool) or not isinstance(value, int):
continue
edited = datetime.fromtimestamp(hlc.wall_time_ms / 1000, tz=timezone.utc)
entries.append(
BudgetEntry(
effective_from=name[len(_HISTORY_FIELD_PREFIX) :],
kcal=value,
edited_at=edited.astimezone().isoformat(timespec="seconds"),
),
)
return tuple(sorted(entries, key=lambda e: e.effective_from))
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")
weight, _ = record.fields.get(_WEIGHT_FIELD, (None, None))
if isinstance(weight, (int, float)) and not isinstance(weight, bool):
result["w"] = float(weight)
return result
def food_bank_to_log(bank: Mapping[str, object]) -> Log:
"""Convert the log-derived food bank into a ``crdt_sync.Log``.
The bank is *derived* -- both devices replay the same synced log and
compute the same records -- so this exists to make them agree
**immediately** rather than only after each has replayed, and to let a
device that is missing part of the log still autocomplete the other's
foods.
The clock is the record's own ``count``, not a wall time. That makes
last-writer-wins mean *max-count-wins*, which is the right merge for a
derived counter: a device that has seen more of the log has the higher
count, and re-merging is idempotent because the count does not move
unless the log did. It also avoids inventing an edit timestamp for a
record nobody edits.
"""
log: Log = {}
for name, record in bank.items():
if not isinstance(record, dict):
continue
count = record.get("count")
ticks = int(count) if isinstance(count, (int, float)) else 0
hlc = Hlc.new_tick(device_id(), wall_time_ms=ticks)
log[name] = Record(id=name, fields={"body": (dict(record), hlc)})
return log
def log_to_food_bank(log: Log) -> dict[str, dict[str, object]]:
"""Convert a merged food-bank ``Log`` back into on-disk bank shape."""
bank: dict[str, dict[str, object]] = {}
for name, record in log.items():
if record.deleted:
continue
body, _hlc = record.fields.get("body", ({}, None))
if isinstance(body, dict):
bank[name] = dict(body)
return bank
def parse_remote_food_bank(text: str) -> Log:
"""Parse one device's pushed ``food_bank.json`` into a ``crdt_sync.Log``.
Raises:
TypeError: If the top-level JSON isn't an object.
KeyError: Via ``Record.from_dict``, on a record missing a key.
ValueError: Via ``json.loads``/``Hlc.from_str`` on malformed input.
"""
raw = json.loads(text)
if not isinstance(raw, dict):
msg = f"top-level food-bank 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()}
def manual_bank_to_log(bank: Mapping[str, object]) -> Log:
"""Convert the hand-curated food bank into a ``crdt_sync.Log``.
One record per normalized food name, carrying the whole bank record as an
opaque ``body`` -- the same shape food-log entries use. Unlike an entry
a curated food is editable, so its Hlc comes from the record's own ``t``
edit stamp (like the budget's) rather than a fixed birth time.
"""
log: Log = {}
for name, record in bank.items():
if not isinstance(record, dict):
continue
hlc = Hlc.new_tick(
device_id(),
wall_time_ms=_wall_time_ms(record_edit_time(record)),
)
body = {k: v for k, v in record.items() if k != "t"}
log[name] = Record(id=name, fields={"body": (body, hlc)})
return log
def log_to_manual_bank(log: Log) -> dict[str, dict[str, object]]:
"""Convert a merged curated-bank ``Log`` back into on-disk bank shape."""
bank: dict[str, dict[str, object]] = {}
for name, record in log.items():
if record.deleted:
continue
body, hlc = record.fields.get("body", ({}, None))
if not isinstance(body, dict):
continue
stored = dict(body)
if hlc is not None:
winning = datetime.fromtimestamp(hlc.wall_time_ms / 1000, tz=timezone.utc)
stored["t"] = winning.astimezone().isoformat(timespec="seconds")
bank[name] = stored
return bank
def parse_remote_manual_bank(text: str) -> Log:
"""Parse one device's pushed curated-bank text into a ``crdt_sync.Log``.
Raises on malformed data; the caller logs-and-skips, matching every other
remote parser here.
Raises:
TypeError: If the top-level JSON isn't an object.
KeyError: Via ``Record.from_dict``, on a record missing a key.
ValueError: Via ``json.loads``/``Hlc.from_str`` on malformed input.
"""
raw = json.loads(text)
if not isinstance(raw, dict):
msg = f"top-level curated-bank 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()}
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()}

39
diet_guard/_sync_paths.py Normal file
View File

@ -0,0 +1,39 @@
"""Repo-relative paths every device pushes its state to.
Split out of ``_sync.py`` for file size. These are the wire layout of the
`kuhyx/syncs` repo, so they are shared by the tick that pushes the food log
(:mod:`diet_guard._sync`) and the one that pushes the banks and budget
(:mod:`diet_guard._sync_banks`) -- a second copy of the layout in either
module is how one of them starts writing where the other does not look.
Every device pushes under its own persisted per-install uuid, never a role
constant; see :mod:`diet_guard._device`.
"""
from __future__ import annotations
_DEVICES_DIR = "diet-guard-sync/devices"
#: Where per-device revision markers live, used to skip re-reading an
#: unchanged remote log.
_REVS_DIR = "diet-guard-sync/revs"
def _device_log_path(device_id: str) -> str:
"""Return the repo-relative path a device's full log is pushed to."""
return f"{_DEVICES_DIR}/{device_id}/food_log.json"
def _device_food_bank_path(device_id: str) -> str:
"""Return one device's pushed derived-food-bank path."""
return f"{_DEVICES_DIR}/{device_id}/food_bank.json"
def _device_manual_bank_path(device_id: str) -> str:
"""Return one device's pushed curated-food-bank path."""
return f"{_DEVICES_DIR}/{device_id}/food_bank_manual.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"

View File

@ -0,0 +1,71 @@
"""Entry <-> ``crdt_sync.Record`` adapters for diet_guard's cross-device sync.
diet_guard's own on-disk ``food_log.json`` format is unchanged (a
:class:`~diet_guard._state.DayLog`: date string -> list of entry dicts) --
only the GitHub-synced wire format and the cross-device merge algorithm go
through ``crdt_sync``'s ``Record``/``Log``/``Hlc`` primitives, the same ones
every other kuhy app that syncs this way uses (see ``~/crdt-sync``).
Split into three submodules for file size, along the same seams the tests
already use:
* :mod:`._daylog` -- the food log itself, including the legacy-format fallback
* :mod:`._budget` -- the budget record and its ``hist:`` history fields
* :mod:`._banks` -- the derived and curated halves of the food bank
Everything public is re-exported here, so ``from diet_guard.sync_merge import
parse_remote_log`` reaches the same object the flat module exposed. (The
``merge_logs`` these adapters feed is ``crdt_sync``'s, imported directly by
:mod:`diet_guard._sync` -- it was never defined here.)
"""
from __future__ import annotations
from diet_guard.sync_merge._banks import (
food_bank_to_log,
log_to_food_bank,
log_to_manual_bank,
manual_bank_to_log,
parse_remote_food_bank,
parse_remote_manual_bank,
)
from diet_guard.sync_merge._budget import (
_budget_hlc,
budget_to_log,
log_to_budget,
log_to_history,
parse_remote_budget,
)
from diet_guard.sync_merge._daylog import (
_entry_hlc,
_legacy_entry_id,
daylog_to_log,
entry_to_record,
log_to_daylog,
parse_remote_log,
record_to_entry,
)
__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.
"_budget_hlc",
"_entry_hlc",
"_legacy_entry_id",
"budget_to_log",
"daylog_to_log",
"entry_to_record",
"food_bank_to_log",
"log_to_budget",
"log_to_daylog",
"log_to_food_bank",
"log_to_history",
"log_to_manual_bank",
"manual_bank_to_log",
"parse_remote_budget",
"parse_remote_food_bank",
"parse_remote_log",
"parse_remote_manual_bank",
"record_to_entry",
]

View File

@ -0,0 +1,137 @@
"""Food-bank <-> ``crdt_sync.Record`` adapters, for both halves of the bank.
The derived bank's merge clock is the record's own ``count``, not a wall time:
last-writer-wins therefore means *max-count-wins*, which is the correct merge
for a derived counter (the device that has seen more of the log has the higher
count) and is idempotent, since the count only moves when the log does.
The curated bank is one record per normalized name, LWW by an ``editedAt``
stamp -- those foods were never eaten, so they are not derivable from any log.
"""
from __future__ import annotations
from datetime import datetime, timezone
import json
from typing import TYPE_CHECKING
from crdt_sync import Hlc, Record
from diet_guard._device import device_id
from diet_guard._foodbank_manual import record_edit_time
from diet_guard.sync_merge._clock import _wall_time_ms
if TYPE_CHECKING:
from collections.abc import Mapping
from crdt_sync import Log
def food_bank_to_log(bank: Mapping[str, object]) -> Log:
"""Convert the log-derived food bank into a ``crdt_sync.Log``.
The bank is *derived* -- both devices replay the same synced log and
compute the same records -- so this exists to make them agree
**immediately** rather than only after each has replayed, and to let a
device that is missing part of the log still autocomplete the other's
foods.
The clock is the record's own ``count``, not a wall time. That makes
last-writer-wins mean *max-count-wins*, which is the right merge for a
derived counter: a device that has seen more of the log has the higher
count, and re-merging is idempotent because the count does not move
unless the log did. It also avoids inventing an edit timestamp for a
record nobody edits.
"""
log: Log = {}
for name, record in bank.items():
if not isinstance(record, dict):
continue
count = record.get("count")
ticks = int(count) if isinstance(count, (int, float)) else 0
hlc = Hlc.new_tick(device_id(), wall_time_ms=ticks)
log[name] = Record(id=name, fields={"body": (dict(record), hlc)})
return log
def log_to_food_bank(log: Log) -> dict[str, dict[str, object]]:
"""Convert a merged food-bank ``Log`` back into on-disk bank shape."""
bank: dict[str, dict[str, object]] = {}
for name, record in log.items():
if record.deleted:
continue
body, _hlc = record.fields.get("body", ({}, None))
if isinstance(body, dict):
bank[name] = dict(body)
return bank
def parse_remote_food_bank(text: str) -> Log:
"""Parse one device's pushed ``food_bank.json`` into a ``crdt_sync.Log``.
Raises:
TypeError: If the top-level JSON isn't an object.
KeyError: Via ``Record.from_dict``, on a record missing a key.
ValueError: Via ``json.loads``/``Hlc.from_str`` on malformed input.
"""
raw = json.loads(text)
if not isinstance(raw, dict):
msg = f"top-level food-bank 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()}
def manual_bank_to_log(bank: Mapping[str, object]) -> Log:
"""Convert the hand-curated food bank into a ``crdt_sync.Log``.
One record per normalized food name, carrying the whole bank record as an
opaque ``body`` -- the same shape food-log entries use. Unlike an entry
a curated food is editable, so its Hlc comes from the record's own ``t``
edit stamp (like the budget's) rather than a fixed birth time.
"""
log: Log = {}
for name, record in bank.items():
if not isinstance(record, dict):
continue
hlc = Hlc.new_tick(
device_id(),
wall_time_ms=_wall_time_ms(record_edit_time(record)),
)
body = {k: v for k, v in record.items() if k != "t"}
log[name] = Record(id=name, fields={"body": (body, hlc)})
return log
def log_to_manual_bank(log: Log) -> dict[str, dict[str, object]]:
"""Convert a merged curated-bank ``Log`` back into on-disk bank shape."""
bank: dict[str, dict[str, object]] = {}
for name, record in log.items():
if record.deleted:
continue
body, hlc = record.fields.get("body", ({}, None))
if not isinstance(body, dict):
continue
stored = dict(body)
if hlc is not None:
winning = datetime.fromtimestamp(hlc.wall_time_ms / 1000, tz=timezone.utc)
stored["t"] = winning.astimezone().isoformat(timespec="seconds")
bank[name] = stored
return bank
def parse_remote_manual_bank(text: str) -> Log:
"""Parse one device's pushed curated-bank text into a ``crdt_sync.Log``.
Raises on malformed data; the caller logs-and-skips, matching every other
remote parser here.
Raises:
TypeError: If the top-level JSON isn't an object.
KeyError: Via ``Record.from_dict``, on a record missing a key.
ValueError: Via ``json.loads``/``Hlc.from_str`` on malformed input.
"""
raw = json.loads(text)
if not isinstance(raw, dict):
msg = f"top-level curated-bank 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()}

View File

@ -0,0 +1,186 @@
"""Budget and budget-history <-> ``crdt_sync.Record`` adapters.
Same ``Record``/``Log`` shape as the food-log adapters, with one important
difference: a budget record is edited repeatedly rather than being immutable
after creation, so its ``Hlc`` derives from a ``t`` edit timestamp that
:func:`diet_guard._budget.write_budget` bumps on every write, not from a birth
time that never changes.
History rides along as ``hist:<YYYY-MM-DD>`` *fields* on the same record
rather than as a second document, so a device predating the feature relays
those fields untouched. There is no legacy fallback -- ``budget.json`` is a
brand-new sync payload, so every device pushing it already speaks this format.
"""
from __future__ import annotations
from datetime import datetime, timezone
import json
from typing import TYPE_CHECKING
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
if TYPE_CHECKING:
from crdt_sync import Log
_BUDGET_RECORD_ID = "budget"
# Field-name prefix for the effective-from budget history, one field per date.
# A separate *field* rather than a separate document, because merge_record
# unions field names -- see budget_to_log.
_HISTORY_FIELD_PREFIX = "hist:"
# Body weight, as its own field rather than a key inside ``value``. It is
# shared state like everything else -- both devices must see one value -- but
# it needs its own Hlc so a device that never sets it (the phone) cannot
# delete it merely by pushing a budget edit.
_WEIGHT_FIELD = "weight"
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(device_id(), wall_time_ms=wall_time_ms)
def _history_hlc(entry: BudgetEntry) -> Hlc:
"""Derive a deterministic Hlc for one history entry from its edit time.
Same trick as :func:`_budget_hlc`: identical inputs always yield the same
clock, so re-syncing unchanged history is a no-op. Two devices that seed
the history independently derive the same ``wall_time_ms`` and the same
value and differ only in node id, so whichever side wins the field-level
LWW, the value is identical and the merge converges in one round.
"""
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 budget_to_log(
record: dict[str, object] | None,
entries: tuple[BudgetEntry, ...] = (),
) -> Log:
"""Convert a raw local budget record plus its history into a 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.
The history rides along as one ``hist:<YYYY-MM-DD>`` field per entry on
the *same* record. ``crdt_sync``'s ``merge_record`` is per-field LWW over
the union of field names, so a device that predates the history neither
clobbers those fields nor blocks them: it merges them in from the remote
and pushes them straight back out (both sides push the *merged* log).
That is what makes this safe to roll out without a coordinated release.
``w`` (body weight) travels the same way, as its own :data:`_WEIGHT_FIELD`
rather than as a key inside ``value``. It is shared state -- both devices
must agree on it -- but inside ``value`` it was collateral damage of the
whole-map LWW: the phone rebuilds that map without ``w``, so any phone
budget edit silently deleted the stored weight and with it the protein
target. As its own field it is protected by per-field LWW, and a device
that never sets it relays it untouched instead of dropping it.
"""
if record is None:
return {}
hlc = _budget_hlc(record)
value = {k: v for k, v in record.items() if k not in {"t", "w"}}
fields: dict[str, tuple[object, Hlc]] = {"value": (value, hlc)}
weight = record.get("w")
if isinstance(weight, (int, float)) and not isinstance(weight, bool):
fields[_WEIGHT_FIELD] = (float(weight), hlc)
for entry in entries:
fields[f"{_HISTORY_FIELD_PREFIX}{entry.effective_from}"] = (
entry.kcal,
_history_hlc(entry),
)
rec = Record(id=_BUDGET_RECORD_ID, fields=fields)
return {rec.id: rec}
def log_to_history(log: Log) -> tuple[BudgetEntry, ...]:
"""Extract the effective-from 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.
"""
record = log.get(_BUDGET_RECORD_ID)
if record is None:
return ()
entries = []
for name, (value, hlc) in record.fields.items():
if not name.startswith(_HISTORY_FIELD_PREFIX):
continue
if isinstance(value, bool) or not isinstance(value, int):
continue
edited = datetime.fromtimestamp(hlc.wall_time_ms / 1000, tz=timezone.utc)
entries.append(
BudgetEntry(
effective_from=name[len(_HISTORY_FIELD_PREFIX) :],
kcal=value,
edited_at=edited.astimezone().isoformat(timespec="seconds"),
),
)
return tuple(sorted(entries, key=lambda e: e.effective_from))
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")
weight, _ = record.fields.get(_WEIGHT_FIELD, (None, None))
if isinstance(weight, (int, float)) and not isinstance(weight, bool):
result["w"] = float(weight)
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()}

View File

@ -0,0 +1,30 @@
"""Wall-clock helpers shared by every adapter in this package.
All three adapter modules derive an ``Hlc`` from some ISO timestamp -- an
entry's birth ``time``, a budget's ``t`` edit stamp, a curated food's
``editedAt``. They therefore share one parsing rule, and it lives here rather
than in whichever module happened to be written first: a second copy is how
two adapters start disagreeing about what an unparsable stamp means, which
would silently change merge outcomes rather than raising.
"""
from __future__ import annotations
from datetime import datetime, timezone
_EPOCH = datetime(1970, 1, 1, tzinfo=timezone.utc)
def _wall_time_ms(stamp: str) -> int:
"""Return ``stamp``'s epoch milliseconds, or the epoch when unparsable.
Falling back to the epoch rather than raising keeps a malformed stamp from
dropping the record entirely: it sorts early in a last-writer-wins merge,
so a well-formed copy from any other device wins, which is the outcome the
user wants.
"""
try:
moment = datetime.fromisoformat(stamp)
except ValueError:
moment = _EPOCH
return int(moment.timestamp() * 1000)

View File

@ -0,0 +1,170 @@
"""Food-log entry <-> ``crdt_sync.Record`` adapters.
Each entry maps to one ``Record`` with a single opaque ``body`` field holding
everything except ``id``/``deleted``: entries are immutable after creation
(only ``deleted`` ever changes post-write, see
:func:`diet_guard._state.resign_entry`), so the whole body shares one derived
``Hlc``. ``hmac`` travels inside ``body`` but is never trusted on read --
:func:`diet_guard._sync.run_sync` always re-signs after merging.
Backward compatible with devices not yet migrated:
:func:`parse_remote_log` tries the Record-based wire format first and falls
back to the old plain-``DayLog`` format. Push always writes the new format.
"""
from __future__ import annotations
from datetime import datetime
import hashlib
import json
from typing import TYPE_CHECKING
from crdt_sync import Hlc, Record
from diet_guard._device import device_id
from diet_guard.sync_merge._clock import _EPOCH
if TYPE_CHECKING:
from crdt_sync import Log
from diet_guard._state import DayLog
def _entry_hlc(entry: dict[str, object]) -> Hlc:
"""Derive a deterministic Hlc for ``entry`` from its own ``time`` field.
The same entry always yields the same Hlc regardless of when this runs
-- entries are immutable after creation, so there's no real "now" to
stamp, just the birth-time already recorded on the entry itself.
Malformed/missing ``time`` still gets a valid (if early-sorting) Hlc
rather than raising -- this only affects tie-breaking between
otherwise-identical copies of the same id, never whether the entry
survives a merge.
"""
try:
dt = datetime.fromisoformat(str(entry.get("time", "")))
except ValueError:
dt = _EPOCH
wall_time_ms = int(dt.timestamp() * 1000)
return Hlc.new_tick(device_id(), wall_time_ms=wall_time_ms)
def _legacy_entry_id(entry: dict[str, object]) -> str:
"""Deterministic id for a pre-``id`` legacy entry, from ``(time, desc)``.
Two devices holding the same legacy entry independently derive the same
id without communicating, so they merge as one record instead of two --
the same guarantee the old ``(time, desc)`` dedup key gave, just
expressed as a real id going forward.
"""
key = f"{entry.get('time')}|{entry.get('desc')}"
digest = hashlib.sha256(key.encode()).hexdigest()[:32]
return f"legacy-{digest}"
def entry_to_record(entry: dict[str, object]) -> Record:
"""Convert one diet_guard log entry to a ``crdt_sync.Record``."""
entry_id = entry.get("id")
if not isinstance(entry_id, str) or not entry_id:
entry_id = _legacy_entry_id(entry)
hlc = _entry_hlc(entry)
body = {k: v for k, v in entry.items() if k not in ("id", "deleted")}
deleted = bool(entry.get("deleted", False))
return Record(
id=entry_id,
fields={"body": (body, hlc)},
deleted=deleted,
deleted_hlc=hlc if deleted else None,
)
def record_to_entry(record: Record) -> dict[str, object]:
"""Convert one ``crdt_sync.Record`` back to a diet_guard log entry."""
body_value, _hlc = record.fields.get("body", ({}, None))
entry: dict[str, object] = dict(body_value) if isinstance(body_value, dict) else {}
entry["id"] = record.id
if record.deleted:
entry["deleted"] = True
return entry
def daylog_to_log(daylog: DayLog) -> Log:
"""Convert a full local/remote DayLog into a ``crdt_sync.Log``."""
log: Log = {}
for entries in daylog.values():
for entry in entries:
record = entry_to_record(entry)
log[record.id] = record
return log
def log_to_daylog(log: Log) -> DayLog:
"""Convert a merged ``crdt_sync.Log`` back into DayLog shape.
Each entry is re-bucketed under its own ``time``'s date rather than
whatever date key it might have arrived under, and each day's entries
are sorted oldest-first -- matching the existing on-disk convention.
"""
daylog: DayLog = {}
for record in log.values():
entry = record_to_entry(record)
date_key = str(entry.get("time", ""))[:10]
daylog.setdefault(date_key, []).append(entry)
for entries in daylog.values():
entries.sort(key=lambda entry: str(entry.get("time", "")))
return daylog
def _looks_like_new_format(raw: dict[str, object]) -> bool:
"""Return True if ``raw`` is shaped like a crdt_sync Record-keyed Log.
An empty object is ambiguous but harmless either way (no entries to
convert), so it's treated as new format to skip the old-format
conversion pass for nothing.
"""
return all(
isinstance(value, dict) and "fields" in value and "id" in value
for value in raw.values()
)
def parse_remote_log(text: str) -> Log:
"""Parse one device's pushed log text into a ``crdt_sync.Log``.
Tries the new Record-based wire format first; falls back to the old
plain-DayLog format (today's on-the-wire shape) for devices not yet
migrated onto crdt_sync, converting their entries through the same
adapter the local log uses. Raises on genuinely malformed data, same as
the pre-migration behavior -- the caller
(:func:`diet_guard._sync._pull_remote_logs`) already logs-and-skips on
that.
Raises:
TypeError: If the top-level JSON isn't an object, or a "new format"
value or an old-format day's entries aren't shaped as expected.
KeyError: Via ``Record.from_dict``, if a "new format" 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 sync payload is not a JSON object: {raw!r}"
raise TypeError(msg)
if _looks_like_new_format(raw):
return {record_id: Record.from_dict(data) for record_id, data in raw.items()}
daylog: DayLog = {}
for date_key, entries in raw.items():
if not isinstance(entries, list):
msg = f"day {date_key!r} is not a JSON array: {entries!r}"
raise TypeError(msg)
for entry in entries:
if not isinstance(entry, dict):
msg = f"entry under day {date_key!r} is not a JSON object: {entry!r}"
raise TypeError(msg)
daylog[date_key] = entries
return daylog_to_log(daylog)
# Stable id: exactly one budget record per device-pushed budget.json.

View File

@ -0,0 +1,182 @@
"""Fixtures that keep the gate's Tk widgets fake and its outputs hermetic.
Split out of ``conftest.py`` for file size, and imported back into it by name
-- pytest only discovers fixtures defined *or imported* in a conftest, and
``pytest_plugins`` is an error in a non-root conftest, so the import in
``conftest.py`` is what registers these. Do not "tidy" it away.
Every module that builds gate widgets is patched as one set: hand-picking a
subset is how a test ends up mixing a real ``tk.Frame`` into a fake notebook,
which fails far from the cause.
"""
from __future__ import annotations
from contextlib import ExitStack, contextmanager
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
from gatelock import Output, OutputRect
from gatelock import _scrollable as _gatelock_scrollable
from gatelock import widgets as _gatelock_widgets
import pytest
from diet_guard import (
_gatelock,
_gatelock_calendar,
_gatelock_calendar_ui,
_gatelock_core,
_gatelock_mealflow,
_gatelock_nutrition,
_gatelock_ui,
)
from diet_guard._gatelock import MealGate
from diet_guard.tests._tk_fakes import (
_FAKE_TK,
_FAKE_TTK,
)
if TYPE_CHECKING:
from collections.abc import Iterator
from pathlib import Path
@pytest.fixture(autouse=True)
def _block_real_tk() -> Iterator[None]:
"""Replace tk + the window class in _gatelock so no real window can open."""
with (
patch("diet_guard._gatelock.tk", MagicMock()),
patch("diet_guard._gatelock.GateRoot", MagicMock()),
):
yield
FAKE_OUTPUTS = (
Output("DP-0", connected=True, rect=OutputRect(0, 0, 1920, 1080), primary=True),
)
"""One live output by default, so the Tk-screen fallback (which would call
int() on a MagicMock) is never reached."""
TWO_OUTPUTS = (
Output("DP-0", connected=True, rect=OutputRect(0, 0, 3840, 2160), primary=True),
Output("HDMI-0", connected=True, rect=OutputRect(3840, 0, 2560, 1440)),
)
"""The real desk. Opt in with ``dual_output`` for anything asserting that the
gate is really built on every monitor."""
def _make_toplevel(_parent: object = None, **_kwargs: object) -> MagicMock:
"""A Toplevel stand-in for gatelock's per-output surfaces."""
window = MagicMock()
window.winfo_children.return_value = []
return window
@pytest.fixture(autouse=True)
def _hermetic_gatelock(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> Iterator[None]:
"""Keep gatelock v0.2.0's per-output machinery off the real machine.
``_block_real_tk`` patches ``tk`` inside *diet_guard*, which is no longer
enough: ``LockWindow.setup()`` builds one real ``tk.Toplevel`` per live
output through *gatelock's* own ``tk``. Over a MagicMock root that sends
real tkinter into unbounded mock recursion, so the suite **hangs** rather
than failing. The runtime dir is redirected too, so no test can stand a
production locker down through the arbiter.
"""
monkeypatch.setenv("GATELOCK_RUNTIME_DIR", str(tmp_path / "gatelock-runtime"))
with (
patch("gatelock._surfaces.tk.Toplevel", side_effect=_make_toplevel),
patch("gatelock._outputs.RandrBackend.create", return_value=None),
patch("gatelock._outputs.scan_xrandr", return_value=FAKE_OUTPUTS),
patch("gatelock._detect._RandrEventSource.start", return_value=False),
):
yield
@pytest.fixture
def dual_output(_hermetic_gatelock: None) -> Iterator[None]:
"""Re-scan as a two-monitor desk, layered over the single-output default."""
with patch("gatelock._outputs.scan_xrandr", return_value=TWO_OUTPUTS):
yield
@pytest.fixture(autouse=True)
def _block_real_vt() -> Iterator[None]:
"""Make gatelock's VT-switch disable a no-op for every test.
Belt-and-suspenders alongside ``_block_real_tk``: VT-disable now lives in
``gatelock``, independent of the (mocked) root, so a test that builds a
real prod-mode (``demo_mode=False``) gate would otherwise run a genuine
``setxkbmap`` against whatever X session the test happens to run under.
"""
with patch("gatelock._vt.shutil.which", return_value=None):
yield
@pytest.fixture(autouse=True)
def _hmac_key(tmp_path: Path) -> Iterator[None]:
"""Point the shared HMAC key at a deterministic temp file.
Makes signing/verification work the same in any environment (including CI,
which has no ``/etc/workout-locker/hmac.key``). Tests that need the
no-key path patch ``compute_entry_hmac`` to return None locally.
"""
key = tmp_path / "hmac.key"
key.write_bytes(b"diet-guard-test-key-0123456789ab")
with patch("gatelock.log_integrity.DEFAULT_HMAC_KEY_FILE", key):
yield
# --------------------------------------------------------------------------
# Gate fixture and its functional tk fakes
# --------------------------------------------------------------------------
#
# A functional fake ``tk`` (stateful Entry/Text/Listbox/StringVar widgets and a
# real, catchable ``TclError``) replaces the blanket MagicMock above for the
# duration of each gate test, so the window's *logic* runs for real against
# in-memory widgets without ever opening a window or grabbing the keyboard.
_GATE_TK_MODULES = (
_gatelock,
# The gate's buttons are built by gatelock now, so its widget module
# needs the fake tk too -- otherwise the gate tests open real windows.
_gatelock_widgets,
_gatelock_calendar,
_gatelock_calendar_ui,
_gatelock_core,
_gatelock_nutrition,
_gatelock_mealflow,
_gatelock_ui,
# The gate's scroll viewport lives in gatelock, not here, and imports
# tkinter independently -- so it needs the fake too, or a real tk.Frame
# ends up parented to a FakeNotebook.
_gatelock_scrollable,
)
@contextmanager
def fake_tk() -> Iterator[None]:
"""Patch every module that builds gate widgets to use the fakes.
Exposed so tests that construct a gate outside the :func:`gate` fixture
patch the *same* set. Hand-picking a subset is how a test ends up mixing a
real ``tk.Label`` with a fake parent: the widget tree spans several modules
plus ``gatelock``, and any module left real will try to talk to a fake
master.
"""
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))
stack.enter_context(patch.object(_gatelock_calendar_ui, "ttk", _FAKE_TTK))
yield
@pytest.fixture
def gate() -> Iterator[MealGate]:
"""Build a demo gate whose widgets are functional fakes."""
with fake_tk():
yield MealGate(demo_mode=True)

View File

@ -0,0 +1,59 @@
"""Shared fakes for the sync tests.
Not a ``test_`` module: these are fixtures-by-hand imported by
``test_sync.py``, ``test_sync_banks.py`` and ``test_sync_pull.py``, and
pytest must not collect them as tests. The repo already excludes this
naming pattern from the ``name-tests-test`` hook.
"""
from __future__ import annotations
import json
from unittest.mock import MagicMock
from diet_guard import _sync_client
from diet_guard._estimator import Nutrition
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:
return Nutrition(
kcal=kcal,
protein_g=10.0,
carbs_g=20.0,
fat_g=5.0,
grams=100.0,
source="manual",
)
def _write_token(token: str = "fake-token") -> None:
_sync_client.SYNC_TOKEN_FILE.parent.mkdir(parents=True, exist_ok=True)
_sync_client.SYNC_TOKEN_FILE.write_text(token)
def _mock_client(
*,
devices: tuple[str, ...] = (),
files: dict[str, str] | None = None,
) -> MagicMock:
"""Build a mock ``GitHubSyncClient`` covering the methods sync calls."""
client = MagicMock()
client.list_directory.return_value = list(devices)
resolved_files = files or {}
client.get_file_text.side_effect = resolved_files.get
# The real GitHubSyncClient has no bulk-map read, and a MagicMock would
# otherwise auto-create one returning a Mock -- which then fails to
# serialise. Deleting it also exercises the documented degrade path:
# no revision map means every peer is fetched, exactly as before.
del client.get_string_map
return client

View File

@ -20,26 +20,27 @@ are functional in-memory stand-ins, shared by ``test_gatelock.py`` and
from __future__ import annotations
from contextlib import ExitStack, contextmanager
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
from unittest.mock import patch
from gatelock import Output, OutputRect
from gatelock import _scrollable as _gatelock_scrollable
from gatelock import widgets as _gatelock_widgets
import pytest
from diet_guard import (
_gatelock,
_gatelock_calendar,
_gatelock_calendar_ui,
_gatelock_core,
_gatelock_mealflow,
_gatelock_nutrition,
_gatelock_ui,
)
from diet_guard._estimator import Nutrition
from diet_guard._gatelock import MealGate
# Importing these fixtures IS their registration: pytest discovers fixtures
# defined *or imported* in a conftest, and ``pytest_plugins`` is an error in a
# non-root conftest. They look unused and are not -- do not "tidy" them away.
from diet_guard.tests._gate_fixtures import (
_GATE_TK_MODULES,
FAKE_OUTPUTS,
TWO_OUTPUTS,
_block_real_tk,
_block_real_vt,
_hermetic_gatelock,
dual_output,
fake_tk,
gate,
)
from diet_guard.tests._tk_fakes import (
_FAKE_TK,
_FAKE_TTK,
@ -57,12 +58,15 @@ from diet_guard.tests._tk_fakes import (
)
# Re-exported: the fake widgets moved into the package so this file stays
# under the 500-line cap, but tests import them from conftest by name.
# under the 250-line cap, but tests import them from conftest by name.
__all__ = [
"FAKE_OUTPUTS",
"TWO_OUTPUTS",
"_FAKE_TK",
"_FAKE_TTK",
# The autouse fixtures: importing them here is what registers them with
# pytest, and naming them here is what stops a linter deleting the import.
"_GATE_TK_MODULES",
"FakeCanvas",
"FakeEntry",
"FakeListbox",
@ -74,8 +78,14 @@ __all__ = [
"FakeVar",
"FakeWidget",
"_FakeTclError",
"_block_real_tk",
"_block_real_vt",
"_hermetic_gatelock",
"dual_output",
"dual_output",
"fake_tk",
"fake_tk",
"gate",
"gate",
]
@ -113,7 +123,7 @@ def _isolate_state(tmp_path: Path) -> Iterator[None]:
tmp_path / ".gate.lock",
),
patch(
"diet_guard._sync.SYNC_TOKEN_FILE",
"diet_guard._sync_client.SYNC_TOKEN_FILE",
tmp_path / "sync_token",
),
patch(
@ -131,154 +141,13 @@ def _isolate_state(tmp_path: Path) -> Iterator[None]:
# mirror. On a developer machine the real file exists, so without this
# every sync test would sign in and push to the live database.
patch(
"diet_guard._sync.CONFIG_FILE",
"diet_guard._sync_client.CONFIG_FILE",
tmp_path / "nonexistent-firebase.json",
),
):
yield
@pytest.fixture(autouse=True)
def _block_real_tk() -> Iterator[None]:
"""Replace tk + the window class in _gatelock so no real window can open."""
with (
patch("diet_guard._gatelock.tk", MagicMock()),
patch("diet_guard._gatelock.GateRoot", MagicMock()),
):
yield
FAKE_OUTPUTS = (
Output("DP-0", connected=True, rect=OutputRect(0, 0, 1920, 1080), primary=True),
)
"""One live output by default, so the Tk-screen fallback (which would call
int() on a MagicMock) is never reached."""
TWO_OUTPUTS = (
Output("DP-0", connected=True, rect=OutputRect(0, 0, 3840, 2160), primary=True),
Output("HDMI-0", connected=True, rect=OutputRect(3840, 0, 2560, 1440)),
)
"""The real desk. Opt in with ``dual_output`` for anything asserting that the
gate is really built on every monitor."""
def _make_toplevel(_parent: object = None, **_kwargs: object) -> MagicMock:
"""A Toplevel stand-in for gatelock's per-output surfaces."""
window = MagicMock()
window.winfo_children.return_value = []
return window
@pytest.fixture(autouse=True)
def _hermetic_gatelock(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> Iterator[None]:
"""Keep gatelock v0.2.0's per-output machinery off the real machine.
``_block_real_tk`` patches ``tk`` inside *diet_guard*, which is no longer
enough: ``LockWindow.setup()`` builds one real ``tk.Toplevel`` per live
output through *gatelock's* own ``tk``. Over a MagicMock root that sends
real tkinter into unbounded mock recursion, so the suite **hangs** rather
than failing. The runtime dir is redirected too, so no test can stand a
production locker down through the arbiter.
"""
monkeypatch.setenv("GATELOCK_RUNTIME_DIR", str(tmp_path / "gatelock-runtime"))
with (
patch("gatelock._surfaces.tk.Toplevel", side_effect=_make_toplevel),
patch("gatelock._outputs.RandrBackend.create", return_value=None),
patch("gatelock._outputs.scan_xrandr", return_value=FAKE_OUTPUTS),
patch("gatelock._detect._RandrEventSource.start", return_value=False),
):
yield
@pytest.fixture
def dual_output(_hermetic_gatelock: None) -> Iterator[None]:
"""Re-scan as a two-monitor desk, layered over the single-output default."""
with patch("gatelock._outputs.scan_xrandr", return_value=TWO_OUTPUTS):
yield
@pytest.fixture(autouse=True)
def _block_real_vt() -> Iterator[None]:
"""Make gatelock's VT-switch disable a no-op for every test.
Belt-and-suspenders alongside ``_block_real_tk``: VT-disable now lives in
``gatelock``, independent of the (mocked) root, so a test that builds a
real prod-mode (``demo_mode=False``) gate would otherwise run a genuine
``setxkbmap`` against whatever X session the test happens to run under.
"""
with patch("gatelock._vt.shutil.which", return_value=None):
yield
@pytest.fixture(autouse=True)
def _hmac_key(tmp_path: Path) -> Iterator[None]:
"""Point the shared HMAC key at a deterministic temp file.
Makes signing/verification work the same in any environment (including CI,
which has no ``/etc/workout-locker/hmac.key``). Tests that need the
no-key path patch ``compute_entry_hmac`` to return None locally.
"""
key = tmp_path / "hmac.key"
key.write_bytes(b"diet-guard-test-key-0123456789ab")
with patch("gatelock.log_integrity.DEFAULT_HMAC_KEY_FILE", key):
yield
# --------------------------------------------------------------------------
# Gate fixture and its functional tk fakes
# --------------------------------------------------------------------------
#
# A functional fake ``tk`` (stateful Entry/Text/Listbox/StringVar widgets and a
# real, catchable ``TclError``) replaces the blanket MagicMock above for the
# duration of each gate test, so the window's *logic* runs for real against
# in-memory widgets without ever opening a window or grabbing the keyboard.
_GATE_TK_MODULES = (
_gatelock,
# The gate's buttons are built by gatelock now, so its widget module
# needs the fake tk too -- otherwise the gate tests open real windows.
_gatelock_widgets,
_gatelock_calendar,
_gatelock_calendar_ui,
_gatelock_core,
_gatelock_nutrition,
_gatelock_mealflow,
_gatelock_ui,
# The gate's scroll viewport lives in gatelock, not here, and imports
# tkinter independently -- so it needs the fake too, or a real tk.Frame
# ends up parented to a FakeNotebook.
_gatelock_scrollable,
)
@contextmanager
def fake_tk() -> Iterator[None]:
"""Patch every module that builds gate widgets to use the fakes.
Exposed so tests that construct a gate outside the :func:`gate` fixture
patch the *same* set. Hand-picking a subset is how a test ends up mixing a
real ``tk.Label`` with a fake parent: the widget tree spans several modules
plus ``gatelock``, and any module left real will try to talk to a fake
master.
"""
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))
stack.enter_context(patch.object(_gatelock_calendar_ui, "ttk", _FAKE_TTK))
yield
@pytest.fixture
def gate() -> Iterator[MealGate]:
"""Build a demo gate whose widgets are functional fakes."""
with fake_tk():
yield MealGate(demo_mode=True)
def _nutrition(kcal: float = 100, grams: float = 100) -> Nutrition:
"""A simple reference nutrition for driving the gate form."""
return Nutrition(kcal, 10, 20, 5, grams, "food bank")

View File

@ -8,90 +8,36 @@ The GitHub layer is mocked (no network access); conftest.py's
from __future__ import annotations
import json
from unittest.mock import MagicMock, patch
from unittest.mock import patch
from crdt_sync import (
FirebaseAuthError,
FirebaseSyncError,
GitHubSyncError,
SyncState,
)
import pytest
from diet_guard import _sync
from diet_guard._budget import budget_weight, daily_budget, write_budget
from diet_guard._budget_history import load_entries
from diet_guard._device import device_id, device_identity
from diet_guard._estimator import Nutrition
from diet_guard import _sync, _sync_client
from diet_guard._device import device_id
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:
return Nutrition(
kcal=kcal,
protein_g=10.0,
carbs_g=20.0,
fat_g=5.0,
grams=100.0,
source="manual",
)
def _write_token(token: str = "fake-token") -> None:
_sync.SYNC_TOKEN_FILE.parent.mkdir(parents=True, exist_ok=True)
_sync.SYNC_TOKEN_FILE.write_text(token)
def _mock_client(
*,
devices: tuple[str, ...] = (),
files: dict[str, str] | None = None,
) -> MagicMock:
"""Build a mock ``GitHubSyncClient`` covering the methods sync calls."""
client = MagicMock()
client.list_directory.return_value = list(devices)
resolved_files = files or {}
client.get_file_text.side_effect = resolved_files.get
# The real GitHubSyncClient has no bulk-map read, and a MagicMock would
# otherwise auto-create one returning a Mock -- which then fails to
# serialise. Deleting it also exercises the documented degrade path:
# no revision map means every peer is fetched, exactly as before.
del client.get_string_map
return client
from diet_guard.tests._sync_fakes import _mock_client, _nutrition, _write_token
class TestReadToken:
def test_missing_token_file_raises_sync_error(self) -> None:
with pytest.raises(_sync.SyncError):
_sync._read_token()
_sync_client._read_token()
def test_empty_token_file_raises_sync_error(self) -> None:
_write_token(" ")
with pytest.raises(_sync.SyncError):
_sync._read_token()
_sync_client._read_token()
def test_present_token_is_read_and_stripped(self) -> None:
_write_token(" abc123 \n")
assert _sync._read_token() == "abc123"
assert _sync_client._read_token() == "abc123"
class TestRunSync:
def test_raises_before_touching_github_when_no_token(self) -> None:
with (
patch.object(_sync, "GitHubSyncClient") as client_cls,
patch.object(_sync_client, "GitHubSyncClient") as client_cls,
pytest.raises(_sync.SyncError),
):
_sync.run_sync()
@ -101,7 +47,7 @@ class TestRunSync:
_write_token()
log_meal("oatmeal", _nutrition(), slot=8)
client = _mock_client(devices=())
with patch.object(_sync, "GitHubSyncClient", return_value=client):
with patch.object(_sync_client, "GitHubSyncClient", return_value=client):
merged = _sync.run_sync()
assert sum(len(entries) for entries in merged.values()) == 1
@ -131,7 +77,7 @@ class TestRunSync:
devices=("pc", "phone"),
files={"diet-guard-sync/devices/phone/food_log.json": "{}"},
)
with patch.object(_sync, "GitHubSyncClient", return_value=client):
with patch.object(_sync_client, "GitHubSyncClient", return_value=client):
_sync.run_sync()
# Every pull -- food log, budget, curated bank -- skips "pc" (this
# device) and only ever reads "phone"'s files.
@ -146,7 +92,7 @@ class TestRunSync:
def test_skips_a_device_with_no_pushed_file_yet(self) -> None:
_write_token()
client = _mock_client(devices=("phone",), files={})
with patch.object(_sync, "GitHubSyncClient", return_value=client):
with patch.object(_sync_client, "GitHubSyncClient", return_value=client):
merged = _sync.run_sync()
assert merged == {}
@ -156,7 +102,7 @@ class TestRunSync:
devices=("phone",),
files={"diet-guard-sync/devices/phone/food_log.json": "[]"},
)
with patch.object(_sync, "GitHubSyncClient", return_value=client):
with patch.object(_sync_client, "GitHubSyncClient", return_value=client):
merged = _sync.run_sync()
assert merged == {}
@ -169,7 +115,7 @@ class TestRunSync:
devices=("phone",),
files={"diet-guard-sync/devices/phone/food_log.json": "{not valid json"},
)
with patch.object(_sync, "GitHubSyncClient", return_value=client):
with patch.object(_sync_client, "GitHubSyncClient", return_value=client):
merged = _sync.run_sync()
assert merged == {}
@ -196,7 +142,7 @@ class TestRunSync:
devices=("phone",),
files={"diet-guard-sync/devices/phone/food_log.json": remote_log_json},
)
with patch.object(_sync, "GitHubSyncClient", return_value=client):
with patch.object(_sync_client, "GitHubSyncClient", return_value=client):
merged = _sync.run_sync()
descs = {entry["desc"] for entries in merged.values() for entry in entries}
assert "phone meal" in descs
@ -231,7 +177,7 @@ class TestRunSync:
devices=("phone",),
files={"diet-guard-sync/devices/phone/food_log.json": remote_log_json},
)
with patch.object(_sync, "GitHubSyncClient", return_value=client):
with patch.object(_sync_client, "GitHubSyncClient", return_value=client):
_sync.run_sync()
reloaded = load_log()
@ -242,241 +188,6 @@ class TestRunSync:
_write_token()
log_meal("oatmeal", _nutrition(), slot=8)
client = _mock_client(devices=())
with patch.object(_sync, "GitHubSyncClient", return_value=client):
with patch.object(_sync_client, "GitHubSyncClient", return_value=client):
_sync.run_sync()
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 f"diet-guard-sync/devices/{device_id()}/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 f"diet-guard-sync/devices/{device_id()}/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_a_remote_edit_does_not_delete_the_local_body_weight(self) -> None:
"""The w-loss bug: ``w`` is PC-local and must survive a phone edit.
``w`` never travels (budget_to_log strips it), so before this fix a
winning remote record replaced the whole stored map and silently took
the weight -- and the protein target -- with it.
"""
_write_token()
write_budget(1500, weight_kg=78.5)
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
assert budget_weight() == 78.5
def test_budget_history_survives_a_round_trip(self) -> None:
"""The history is written back locally after every merge."""
_write_token()
write_budget(2000)
client = _mock_client(devices=())
with patch.object(_sync, "GitHubSyncClient", return_value=client):
_sync.run_sync()
assert load_entries()[-1].kcal == 2000
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."""
def test_returns_none_on_success(self) -> None:
"""A clean pull returns None (no reason to report)."""
with patch.object(_sync, "run_sync") as run_sync:
assert _sync.pull_shared_log() is None
run_sync.assert_called_once_with()
def test_returns_reason_on_expected_failure(self) -> None:
"""A real sync failure (here a network error) becomes a reason string."""
with patch.object(_sync, "run_sync", side_effect=_sync.GitHubSyncError("boom")):
reason = _sync.pull_shared_log()
assert reason is not None
assert "boom" in reason
@pytest.mark.parametrize(
"error",
[
FirebaseAuthError("token refresh rejected"),
FirebaseSyncError("RTDB unavailable"),
],
)
def test_firebase_failure_fails_closed(self, error: Exception) -> None:
"""A Firebase failure must return a reason, never raise.
Regression guard: ``FirebaseAuthError``/``FirebaseSyncError`` are
*siblings* of ``GitHubSyncError`` under ``RemoteSyncError``, not
subclasses. Catching only the GitHub type let every per-request
failure of the *primary* backend escape, so the gate's "Fetch from
sync" button raised a traceback instead of reporting a reason and
leaving the lock up -- on the exact path this helper exists to
fail closed.
"""
with patch.object(_sync, "run_sync", side_effect=error):
reason = _sync.pull_shared_log()
assert reason is not None
assert str(error) in reason
def test_unexpected_error_is_not_swallowed(self) -> None:
"""A bug outside the known failure surface surfaces, not hidden."""
with (
patch.object(_sync, "run_sync", side_effect=KeyError("bug")),
pytest.raises(KeyError),
):
_sync.pull_shared_log()
class TestRemoteRevisions:
"""Reading the peers' revision map, which gates the big downloads."""
def test_is_empty_without_a_bulk_map_read(self) -> None:
"""GitHub has none; correctness must not depend on the optimisation."""
client = MagicMock()
del client.get_string_map
assert _sync._remote_revisions(client) == {}
def test_returns_the_published_revisions(self) -> None:
client = MagicMock()
client.get_string_map.return_value = {"phone": "abc"}
assert _sync._remote_revisions(client) == {"phone": "abc"}
def test_an_unreadable_map_degrades_to_fetching_everything(self) -> None:
"""Not worth failing a sync over; every peer is simply fetched."""
client = MagicMock()
client.get_string_map.side_effect = GitHubSyncError("boom")
assert _sync._remote_revisions(client) == {}
class TestPullSkipsUnchangedPeers:
"""The single largest traffic saving in the fleet."""
def test_skips_a_peer_whose_revision_is_unchanged(self) -> None:
client = _mock_client(devices=("phone",))
state = SyncState(pushed_rev=None, peer_revs={"phone": "rev-1"})
seen: dict[str, str] = {}
logs = _sync._pull_remote_logs(
client, {"phone": "rev-1"}, state, seen, device_identity()
)
assert logs == []
assert seen == {"phone": "rev-1"}
client.get_file_text.assert_not_called()
def test_downloads_a_peer_whose_revision_moved(self) -> None:
text = json.dumps({})
client = _mock_client(
devices=("phone",),
files={"diet-guard-sync/devices/phone/food_log.json": text},
)
state = SyncState(pushed_rev=None, peer_revs={"phone": "rev-1"})
seen: dict[str, str] = {}
_sync._pull_remote_logs(
client, {"phone": "rev-2"}, state, seen, device_identity()
)
client.get_file_text.assert_called_once()
assert seen == {"phone": "rev-2"}
class TestNoOpPushSuppression:
"""88% of the old GitHub history was byte-identical no-op pushes."""
def test_a_second_unchanged_sync_pushes_no_log(self) -> None:
"""The saving the free-tier budget depends on, at 96 ticks a day."""
_write_token()
client = _mock_client()
with patch.object(_sync, "GitHubSyncClient", return_value=client):
_sync.run_sync()
pushed_first = [
call.args[0] for call in client.put_file_text.call_args_list
]
client.put_file_text.reset_mock()
_sync.run_sync()
pushed_second = [call.args[0] for call in client.put_file_text.call_args_list]
assert f"diet-guard-sync/devices/{device_id()}/food_log.json" in pushed_first
assert (
f"diet-guard-sync/devices/{device_id()}/food_log.json" not in pushed_second
)
assert f"diet-guard-sync/revs/{device_id()}" not in pushed_second

View File

@ -12,7 +12,7 @@ from pathlib import Path
from crdt_sync import ConfigError
import pytest
from diet_guard import _sync
from diet_guard import _sync, _sync_client
class TestRemoteClient:
@ -22,10 +22,12 @@ class TestRemoteClient:
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""An unconfigured machine must not reach the network at all."""
monkeypatch.setattr(_sync, "CONFIG_FILE", Path("/nonexistent/firebase.json"))
monkeypatch.setattr(
_sync_client, "CONFIG_FILE", Path("/nonexistent/firebase.json")
)
github = object()
assert _sync._remote_client(github) is github
assert _sync_client._remote_client(github) is github
def test_mirrors_to_github_when_configured(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
@ -33,13 +35,13 @@ class TestRemoteClient:
"""Configured: Firebase is primary, GitHub keeps receiving writes."""
config = tmp_path / "firebase.json"
config.write_text("{}", encoding="utf-8")
monkeypatch.setattr(_sync, "CONFIG_FILE", config)
monkeypatch.setattr(_sync_client, "CONFIG_FILE", config)
monkeypatch.setattr(
_sync, "mirror_client_for", lambda _app, client: ("mirror", client)
_sync_client, "mirror_client_for", lambda _app, client: ("mirror", client)
)
github = object()
assert _sync._remote_client(github) == ("mirror", github)
assert _sync_client._remote_client(github) == ("mirror", github)
def test_falls_back_when_firebase_is_unusable(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
@ -47,16 +49,16 @@ class TestRemoteClient:
"""A broken Firebase must degrade to GitHub, never fail the tick."""
config = tmp_path / "firebase.json"
config.write_text("{}", encoding="utf-8")
monkeypatch.setattr(_sync, "CONFIG_FILE", config)
monkeypatch.setattr(_sync_client, "CONFIG_FILE", config)
def _boom(*_args: object, **_kwargs: object) -> None:
message = "no password"
raise ConfigError(message)
monkeypatch.setattr(_sync, "mirror_client_for", _boom)
monkeypatch.setattr(_sync_client, "mirror_client_for", _boom)
github = object()
assert _sync._remote_client(github) is github
assert _sync_client._remote_client(github) is github
class TestClientForRun:
@ -73,11 +75,15 @@ class TestClientForRun:
"""
config = tmp_path / "firebase.json"
config.write_text("{}", encoding="utf-8")
monkeypatch.setattr(_sync, "CONFIG_FILE", config)
monkeypatch.setattr(_sync, "SYNC_TOKEN_FILE", tmp_path / "absent_sync_token")
monkeypatch.setattr(_sync, "firebase_client_for", lambda app: ("firebase", app))
monkeypatch.setattr(_sync_client, "CONFIG_FILE", config)
monkeypatch.setattr(
_sync_client, "SYNC_TOKEN_FILE", tmp_path / "absent_sync_token"
)
monkeypatch.setattr(
_sync_client, "firebase_client_for", lambda app: ("firebase", app)
)
assert _sync._client_for_run() == ("firebase", "diet_guard")
assert _sync_client._client_for_run() == ("firebase", "diet_guard")
def test_unusable_firebase_config_becomes_a_sync_error(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
@ -94,24 +100,28 @@ class TestClientForRun:
"""
config = tmp_path / "firebase.json"
config.write_text("{}", encoding="utf-8")
monkeypatch.setattr(_sync, "CONFIG_FILE", config)
monkeypatch.setattr(_sync, "SYNC_TOKEN_FILE", tmp_path / "absent_token")
monkeypatch.setattr(_sync_client, "CONFIG_FILE", config)
monkeypatch.setattr(_sync_client, "SYNC_TOKEN_FILE", tmp_path / "absent_token")
def _boom(*_args: object, **_kwargs: object) -> None:
message = "no password"
raise ConfigError(message)
monkeypatch.setattr(_sync, "firebase_client_for", _boom)
monkeypatch.setattr(_sync_client, "firebase_client_for", _boom)
with pytest.raises(_sync.SyncError):
_sync._client_for_run()
_sync_client._client_for_run()
def test_still_fails_when_neither_backend_is_configured(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""No PAT *and* no Firebase is the genuine "nothing is set up" case."""
monkeypatch.setattr(_sync, "CONFIG_FILE", tmp_path / "absent_firebase.json")
monkeypatch.setattr(_sync, "SYNC_TOKEN_FILE", tmp_path / "absent_sync_token")
monkeypatch.setattr(
_sync_client, "CONFIG_FILE", tmp_path / "absent_firebase.json"
)
monkeypatch.setattr(
_sync_client, "SYNC_TOKEN_FILE", tmp_path / "absent_sync_token"
)
with pytest.raises(_sync.SyncError):
_sync._client_for_run()
_sync_client._client_for_run()

View File

@ -11,14 +11,14 @@ from __future__ import annotations
import json
from unittest.mock import patch
from diet_guard import _sync
from diet_guard import _sync, _sync_client
from diet_guard._device import device_id
from diet_guard._estimator import Nutrition
from diet_guard._foodbank import lookup_food, read_food_bank
from diet_guard._foodbank_manual import add_manual_entry, read_manual_bank
from diet_guard._state import log_meal
from diet_guard._sync_merge import food_bank_to_log, manual_bank_to_log
from diet_guard.tests.test_sync import _mock_client, _write_token
from diet_guard.sync_merge import food_bank_to_log, manual_bank_to_log
from diet_guard.tests._sync_fakes import _mock_client, _write_token
class TestSyncFoodBank:
@ -31,7 +31,7 @@ class TestSyncFoodBank:
# behind it is (correctly) discarded before the push.
log_meal("apple", Nutrition(95, 0.5, 25, 0.3, 180, "manual"), 8)
client = _mock_client(devices=())
with patch.object(_sync, "GitHubSyncClient", return_value=client):
with patch.object(_sync_client, "GitHubSyncClient", return_value=client):
_sync.run_sync()
pushed = [call.args[0] for call in client.put_file_text.call_args_list]
assert f"diet-guard-sync/devices/{device_id()}/food_bank.json" in pushed
@ -65,7 +65,7 @@ class TestSyncFoodBank:
devices=("phone",),
files={"diet-guard-sync/devices/phone/food_log.json": remote_log_json},
)
with patch.object(_sync, "GitHubSyncClient", return_value=client):
with patch.object(_sync_client, "GitHubSyncClient", return_value=client):
_sync.run_sync()
found = lookup_food("Skyr")
assert found is not None
@ -93,7 +93,7 @@ class TestSyncFoodBank:
devices=("phone",),
files={"diet-guard-sync/devices/phone/food_bank.json": remote},
)
with patch.object(_sync, "GitHubSyncClient", return_value=client):
with patch.object(_sync_client, "GitHubSyncClient", return_value=client):
_sync.run_sync()
assert lookup_food("Skyr") is None
@ -121,7 +121,7 @@ class TestSyncFoodBank:
devices=("phone",),
files={"diet-guard-sync/devices/phone/food_bank.json": remote},
)
with patch.object(_sync, "GitHubSyncClient", return_value=client):
with patch.object(_sync_client, "GitHubSyncClient", return_value=client):
_sync.run_sync()
assert read_food_bank()["apple"]["count"] == 99
@ -132,14 +132,14 @@ class TestSyncFoodBank:
devices=("phone",),
files={"diet-guard-sync/devices/phone/food_bank.json": "{not json"},
)
with patch.object(_sync, "GitHubSyncClient", return_value=client):
with patch.object(_sync_client, "GitHubSyncClient", return_value=client):
_sync.run_sync()
assert lookup_food("apple") is not None
def test_nothing_pushed_when_no_device_has_a_bank(self) -> None:
_write_token()
client = _mock_client(devices=())
with patch.object(_sync, "GitHubSyncClient", return_value=client):
with patch.object(_sync_client, "GitHubSyncClient", return_value=client):
_sync.run_sync()
pushed = [call.args[0] for call in client.put_file_text.call_args_list]
assert f"diet-guard-sync/devices/{device_id()}/food_bank.json" not in pushed
@ -151,7 +151,7 @@ class TestSyncManualBank:
def test_nothing_pushed_when_no_device_has_curated_anything(self) -> None:
_write_token()
client = _mock_client(devices=())
with patch.object(_sync, "GitHubSyncClient", return_value=client):
with patch.object(_sync_client, "GitHubSyncClient", return_value=client):
_sync.run_sync()
pushed = [call.args[0] for call in client.put_file_text.call_args_list]
assert not any("food_bank_manual" in path for path in pushed)
@ -160,7 +160,7 @@ class TestSyncManualBank:
_write_token()
add_manual_entry("Skyr", {"desc": "Skyr", "kcal": 120.0, "count": 0})
client = _mock_client(devices=())
with patch.object(_sync, "GitHubSyncClient", return_value=client):
with patch.object(_sync_client, "GitHubSyncClient", return_value=client):
_sync.run_sync()
pushed = [call.args[0] for call in client.put_file_text.call_args_list]
assert f"diet-guard-sync/devices/{device_id()}/food_bank_manual.json" in pushed
@ -192,7 +192,7 @@ class TestSyncManualBank:
devices=("phone",),
files={"diet-guard-sync/devices/phone/food_bank_manual.json": remote},
)
with patch.object(_sync, "GitHubSyncClient", return_value=client):
with patch.object(_sync_client, "GitHubSyncClient", return_value=client):
_sync.run_sync()
assert "skyr" in read_manual_bank()
found = lookup_food("Skyr")
@ -221,7 +221,7 @@ class TestSyncManualBank:
devices=("phone",),
files={"diet-guard-sync/devices/phone/food_bank_manual.json": remote},
)
with patch.object(_sync, "GitHubSyncClient", return_value=client):
with patch.object(_sync_client, "GitHubSyncClient", return_value=client):
_sync.run_sync()
assert read_manual_bank()["skyr"]["kcal"] == 999.0
@ -234,7 +234,7 @@ class TestSyncManualBank:
"diet-guard-sync/devices/phone/food_bank_manual.json": "{not json",
},
)
with patch.object(_sync, "GitHubSyncClient", return_value=client):
with patch.object(_sync_client, "GitHubSyncClient", return_value=client):
_sync.run_sync()
assert read_manual_bank()["skyr"]["kcal"] == 120.0
@ -242,6 +242,6 @@ class TestSyncManualBank:
_write_token()
add_manual_entry("Skyr", {"desc": "Skyr", "kcal": 120.0, "count": 0})
client = _mock_client(devices=("phone",))
with patch.object(_sync, "GitHubSyncClient", return_value=client):
with patch.object(_sync_client, "GitHubSyncClient", return_value=client):
_sync.run_sync()
assert read_manual_bank()["skyr"]["kcal"] == 120.0

View File

@ -0,0 +1,125 @@
"""Tests for the budget's half of the sync tick.
Split out of ``test_sync.py`` to keep both files under the repo's 250-line
cap, along the same seam as the module they cover
(``diet_guard.sync_merge._budget``).
"""
from __future__ import annotations
from unittest.mock import patch
from diet_guard import _sync, _sync_client
from diet_guard._budget import budget_weight, daily_budget, write_budget
from diet_guard._budget_history import load_entries
from diet_guard._device import device_id
from diet_guard.tests._sync_fakes import (
_mock_client,
_remote_budget_json,
_write_token,
)
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_client, "GitHubSyncClient", return_value=client):
_sync.run_sync()
pushed_paths = {call.args[0] for call in client.put_file_text.call_args_list}
assert f"diet-guard-sync/devices/{device_id()}/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_client, "GitHubSyncClient", return_value=client):
_sync.run_sync()
pushed_paths = {call.args[0] for call in client.put_file_text.call_args_list}
assert f"diet-guard-sync/devices/{device_id()}/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_client, "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_client, "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_client, "GitHubSyncClient", return_value=client):
_sync.run_sync()
assert daily_budget() == 1800
def test_a_remote_edit_does_not_delete_the_local_body_weight(self) -> None:
"""The w-loss bug: ``w`` is PC-local and must survive a phone edit.
``w`` never travels (budget_to_log strips it), so before this fix a
winning remote record replaced the whole stored map and silently took
the weight -- and the protein target -- with it.
"""
_write_token()
write_budget(1500, weight_kg=78.5)
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_client, "GitHubSyncClient", return_value=client):
_sync.run_sync()
assert daily_budget() == 1800
assert budget_weight() == 78.5
def test_budget_history_survives_a_round_trip(self) -> None:
"""The history is written back locally after every merge."""
_write_token()
write_budget(2000)
client = _mock_client(devices=())
with patch.object(_sync_client, "GitHubSyncClient", return_value=client):
_sync.run_sync()
assert load_entries()[-1].kcal == 2000
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_client, "GitHubSyncClient", return_value=client):
_sync.run_sync()
assert daily_budget() == 2000

View File

@ -11,21 +11,15 @@ from __future__ import annotations
import json
from crdt_sync import Record, merge_logs
from crdt_sync import merge_logs
import pytest
from diet_guard._budget_history import BudgetEntry
from diet_guard._sync_merge import (
_budget_hlc,
from diet_guard.sync_merge import (
_entry_hlc,
_legacy_entry_id,
budget_to_log,
daylog_to_log,
entry_to_record,
log_to_budget,
log_to_daylog,
log_to_history,
parse_remote_budget,
parse_remote_log,
record_to_entry,
)
@ -187,13 +181,13 @@ class TestParseRemoteLog:
def test_parses_new_format_wire_content(self) -> None:
entry = _entry(id="x")
pushed = {"x": entry_to_record(entry).to_dict()}
log = parse_remote_log(_dumps(pushed))
log = parse_remote_log(json.dumps(pushed))
assert log["x"].id == "x"
def test_parses_old_daylog_format_for_backward_compatibility(self) -> None:
entry = _entry(id="x")
old_format = {"2026-06-22": [entry]}
log = parse_remote_log(_dumps(old_format))
log = parse_remote_log(json.dumps(old_format))
assert log["x"].id == "x"
def test_empty_object_parses_as_empty_log(self) -> None:
@ -205,210 +199,12 @@ class TestParseRemoteLog:
def test_old_format_day_not_a_list_raises_type_error(self) -> None:
with pytest.raises(TypeError):
parse_remote_log(_dumps({"2026-06-22": "not-a-list"}))
parse_remote_log(json.dumps({"2026-06-22": "not-a-list"}))
def test_old_format_entry_not_an_object_raises_type_error(self) -> None:
with pytest.raises(TypeError):
parse_remote_log(_dumps({"2026-06-22": ["not-a-dict"]}))
parse_remote_log(json.dumps({"2026-06-22": ["not-a-dict"]}))
def test_invalid_json_raises(self) -> None:
with pytest.raises(json.JSONDecodeError):
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
_HIST_SEED = BudgetEntry("1970-01-01", 2200, "2026-07-13T21:15:09+02:00")
_HIST_CUT = BudgetEntry("2026-07-26", 2000, "2026-07-26T10:00:00+02:00")
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(self) -> None:
record = _budget_record()
round_tripped = log_to_budget(budget_to_log(record))
assert round_tripped is not None
assert round_tripped["b"] == 2000
def test_weight_travels_as_its_own_field(self) -> None:
"""``w`` is shared state, carried as a field of its own.
Inside ``value`` it was collateral damage of whole-map LWW: the phone
rebuilds that map without ``w``, so any phone budget edit silently
deleted the stored weight and the protein target with it.
"""
log = budget_to_log(_budget_record(w=80.0))
assert "w" not in log["budget"].fields["value"][0]
assert log["budget"].fields["weight"][0] == 80.0
def test_weight_round_trips(self) -> None:
round_tripped = log_to_budget(budget_to_log(_budget_record(w=80.0)))
assert round_tripped is not None
assert round_tripped["w"] == 80.0
def test_no_weight_round_trips_to_no_weight(self) -> None:
round_tripped = log_to_budget(budget_to_log(_budget_record()))
assert round_tripped is not None
assert "w" not in round_tripped
def test_a_non_numeric_weight_is_not_emitted(self) -> None:
assert "weight" not in budget_to_log(_budget_record(w="heavy"))["budget"].fields
def test_a_boolean_weight_is_not_emitted(self) -> None:
assert "weight" not in budget_to_log(_budget_record(w=True))["budget"].fields
def test_a_non_numeric_weight_field_is_ignored_on_read(self) -> None:
log = {
"budget": Record(
id="budget",
fields={"weight": ("heavy", _budget_hlc({}))},
),
}
assert "w" not in (log_to_budget(log) or {})
def test_a_weightless_peer_cannot_delete_the_weight(self) -> None:
"""The whole point: the phone never sets ``w`` and must not drop it."""
ours = budget_to_log(_budget_record(w=80.0))
phone = budget_to_log(
{"v": 2, "b": 1800, "t": "2099-01-01T00:00:00+00:00"},
)
merged = log_to_budget(merge_logs(ours, phone))
assert merged is not None
assert merged["b"] == 1800
assert merged["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 TestBudgetHistoryFields:
"""The effective-from history riding along on the budget record."""
def test_history_becomes_one_field_per_date(self) -> None:
log = budget_to_log(_budget_record(), (_HIST_SEED, _HIST_CUT))
fields = log["budget"].fields
assert "value" in fields
assert fields["hist:1970-01-01"][0] == 2200
assert fields["hist:2026-07-26"][0] == 2000
def test_round_trips_back_to_entries(self) -> None:
entries = log_to_history(
budget_to_log(_budget_record(), (_HIST_SEED, _HIST_CUT))
)
assert [e.effective_from for e in entries] == ["1970-01-01", "2026-07-26"]
assert [e.kcal for e in entries] == [2200, 2000]
def test_no_history_round_trips_to_nothing(self) -> None:
assert log_to_history(budget_to_log(_budget_record())) == ()
def test_empty_log_has_no_history(self) -> None:
assert log_to_history({}) == ()
def test_non_history_fields_are_ignored(self) -> None:
log = {
"budget": Record(
id="budget", fields={"value": ({"b": 2000}, _budget_hlc({}))}
)
}
assert log_to_history(log) == ()
def test_non_int_history_value_is_skipped(self) -> None:
log = {
"budget": Record(
id="budget",
fields={"hist:2026-07-26": ("nonsense", _budget_hlc({}))},
),
}
assert log_to_history(log) == ()
def test_unparsable_edit_time_still_yields_a_field(self) -> None:
broken = BudgetEntry("2026-07-26", 2000, "not a timestamp")
log = budget_to_log(_budget_record(), (broken,))
assert "hist:2026-07-26" in log["budget"].fields
def test_history_survives_a_merge_with_a_history_free_peer(self) -> None:
"""The rollout guarantee: an old device cannot clobber the history.
merge_record is per-field LWW over the *union* of field names, so a
peer that only pushes ``value`` leaves ``hist:*`` untouched -- which
is why this needed no coordinated release.
"""
ours = budget_to_log(_budget_record(), (_HIST_SEED, _HIST_CUT))
legacy = {
"budget": Record(
id="budget",
fields={
"value": (
{"v": 2, "b": 2200},
_budget_hlc({"t": "2099-01-01T00:00:00+00:00"}),
)
},
),
}
merged = merge_logs(ours, legacy)
assert len(log_to_history(merged)) == 2
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)

View File

@ -12,7 +12,7 @@ import json
from crdt_sync import Record, merge_logs
import pytest
from diet_guard._sync_merge import (
from diet_guard.sync_merge import (
_budget_hlc,
food_bank_to_log,
log_to_food_bank,

View File

@ -0,0 +1,216 @@
"""Tests for the budget and budget-history adapters.
Split out of ``test_sync_merge.py`` to keep both files under the repo's
250-line cap, along the same seam as the module they cover
(``diet_guard.sync_merge._budget``).
"""
from __future__ import annotations
import json
from crdt_sync import Record, merge_logs
import pytest
from diet_guard._budget_history import BudgetEntry
from diet_guard.sync_merge import (
_budget_hlc,
budget_to_log,
log_to_budget,
log_to_history,
parse_remote_budget,
)
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
_HIST_SEED = BudgetEntry("1970-01-01", 2200, "2026-07-13T21:15:09+02:00")
_HIST_CUT = BudgetEntry("2026-07-26", 2000, "2026-07-26T10:00:00+02:00")
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(self) -> None:
record = _budget_record()
round_tripped = log_to_budget(budget_to_log(record))
assert round_tripped is not None
assert round_tripped["b"] == 2000
def test_weight_travels_as_its_own_field(self) -> None:
"""``w`` is shared state, carried as a field of its own.
Inside ``value`` it was collateral damage of whole-map LWW: the phone
rebuilds that map without ``w``, so any phone budget edit silently
deleted the stored weight and the protein target with it.
"""
log = budget_to_log(_budget_record(w=80.0))
assert "w" not in log["budget"].fields["value"][0]
assert log["budget"].fields["weight"][0] == 80.0
def test_weight_round_trips(self) -> None:
round_tripped = log_to_budget(budget_to_log(_budget_record(w=80.0)))
assert round_tripped is not None
assert round_tripped["w"] == 80.0
def test_no_weight_round_trips_to_no_weight(self) -> None:
round_tripped = log_to_budget(budget_to_log(_budget_record()))
assert round_tripped is not None
assert "w" not in round_tripped
def test_a_non_numeric_weight_is_not_emitted(self) -> None:
assert "weight" not in budget_to_log(_budget_record(w="heavy"))["budget"].fields
def test_a_boolean_weight_is_not_emitted(self) -> None:
assert "weight" not in budget_to_log(_budget_record(w=True))["budget"].fields
def test_a_non_numeric_weight_field_is_ignored_on_read(self) -> None:
log = {
"budget": Record(
id="budget",
fields={"weight": ("heavy", _budget_hlc({}))},
),
}
assert "w" not in (log_to_budget(log) or {})
def test_a_weightless_peer_cannot_delete_the_weight(self) -> None:
"""The whole point: the phone never sets ``w`` and must not drop it."""
ours = budget_to_log(_budget_record(w=80.0))
phone = budget_to_log(
{"v": 2, "b": 1800, "t": "2099-01-01T00:00:00+00:00"},
)
merged = log_to_budget(merge_logs(ours, phone))
assert merged is not None
assert merged["b"] == 1800
assert merged["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 TestBudgetHistoryFields:
"""The effective-from history riding along on the budget record."""
def test_history_becomes_one_field_per_date(self) -> None:
log = budget_to_log(_budget_record(), (_HIST_SEED, _HIST_CUT))
fields = log["budget"].fields
assert "value" in fields
assert fields["hist:1970-01-01"][0] == 2200
assert fields["hist:2026-07-26"][0] == 2000
def test_round_trips_back_to_entries(self) -> None:
entries = log_to_history(
budget_to_log(_budget_record(), (_HIST_SEED, _HIST_CUT))
)
assert [e.effective_from for e in entries] == ["1970-01-01", "2026-07-26"]
assert [e.kcal for e in entries] == [2200, 2000]
def test_no_history_round_trips_to_nothing(self) -> None:
assert log_to_history(budget_to_log(_budget_record())) == ()
def test_empty_log_has_no_history(self) -> None:
assert log_to_history({}) == ()
def test_non_history_fields_are_ignored(self) -> None:
log = {
"budget": Record(
id="budget", fields={"value": ({"b": 2000}, _budget_hlc({}))}
)
}
assert log_to_history(log) == ()
def test_non_int_history_value_is_skipped(self) -> None:
log = {
"budget": Record(
id="budget",
fields={"hist:2026-07-26": ("nonsense", _budget_hlc({}))},
),
}
assert log_to_history(log) == ()
def test_unparsable_edit_time_still_yields_a_field(self) -> None:
broken = BudgetEntry("2026-07-26", 2000, "not a timestamp")
log = budget_to_log(_budget_record(), (broken,))
assert "hist:2026-07-26" in log["budget"].fields
def test_history_survives_a_merge_with_a_history_free_peer(self) -> None:
"""The rollout guarantee: an old device cannot clobber the history.
merge_record is per-field LWW over the *union* of field names, so a
peer that only pushes ``value`` leaves ``hist:*`` untouched -- which
is why this needed no coordinated release.
"""
ours = budget_to_log(_budget_record(), (_HIST_SEED, _HIST_CUT))
legacy = {
"budget": Record(
id="budget",
fields={
"value": (
{"v": 2, "b": 2200},
_budget_hlc({"t": "2099-01-01T00:00:00+00:00"}),
)
},
),
}
merged = merge_logs(ours, legacy)
assert len(log_to_history(merged)) == 2
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{{{")

View File

@ -0,0 +1,153 @@
"""Tests for pulling peers' logs: revisions, skips and push suppression.
Split out of ``test_sync.py`` to keep both files under the repo's 250-line
cap. Covers the revision cache that lets a tick skip an unchanged peer, and
the no-op push suppression that keeps an idle tick from rewriting the remote.
"""
from __future__ import annotations
import json
from unittest.mock import MagicMock, patch
from crdt_sync import (
FirebaseAuthError,
FirebaseSyncError,
GitHubSyncError,
SyncState,
)
import pytest
from diet_guard import _sync, _sync_client
from diet_guard._device import device_id, device_identity
from diet_guard.tests._sync_fakes import _mock_client, _write_token
class TestPullSharedLog:
"""The fail-closed wrapper the gate and the lock-screen button share."""
def test_returns_none_on_success(self) -> None:
"""A clean pull returns None (no reason to report)."""
with patch.object(_sync, "run_sync") as run_sync:
assert _sync.pull_shared_log() is None
run_sync.assert_called_once_with()
def test_returns_reason_on_expected_failure(self) -> None:
"""A real sync failure (here a network error) becomes a reason string."""
with patch.object(_sync, "run_sync", side_effect=_sync.GitHubSyncError("boom")):
reason = _sync.pull_shared_log()
assert reason is not None
assert "boom" in reason
@pytest.mark.parametrize(
"error",
[
FirebaseAuthError("token refresh rejected"),
FirebaseSyncError("RTDB unavailable"),
],
)
def test_firebase_failure_fails_closed(self, error: Exception) -> None:
"""A Firebase failure must return a reason, never raise.
Regression guard: ``FirebaseAuthError``/``FirebaseSyncError`` are
*siblings* of ``GitHubSyncError`` under ``RemoteSyncError``, not
subclasses. Catching only the GitHub type let every per-request
failure of the *primary* backend escape, so the gate's "Fetch from
sync" button raised a traceback instead of reporting a reason and
leaving the lock up -- on the exact path this helper exists to
fail closed.
"""
with patch.object(_sync, "run_sync", side_effect=error):
reason = _sync.pull_shared_log()
assert reason is not None
assert str(error) in reason
def test_unexpected_error_is_not_swallowed(self) -> None:
"""A bug outside the known failure surface surfaces, not hidden."""
with (
patch.object(_sync, "run_sync", side_effect=KeyError("bug")),
pytest.raises(KeyError),
):
_sync.pull_shared_log()
class TestRemoteRevisions:
"""Reading the peers' revision map, which gates the big downloads."""
def test_is_empty_without_a_bulk_map_read(self) -> None:
"""GitHub has none; correctness must not depend on the optimisation."""
client = MagicMock()
del client.get_string_map
assert _sync._remote_revisions(client) == {}
def test_returns_the_published_revisions(self) -> None:
client = MagicMock()
client.get_string_map.return_value = {"phone": "abc"}
assert _sync._remote_revisions(client) == {"phone": "abc"}
def test_an_unreadable_map_degrades_to_fetching_everything(self) -> None:
"""Not worth failing a sync over; every peer is simply fetched."""
client = MagicMock()
client.get_string_map.side_effect = GitHubSyncError("boom")
assert _sync._remote_revisions(client) == {}
class TestPullSkipsUnchangedPeers:
"""The single largest traffic saving in the fleet."""
def test_skips_a_peer_whose_revision_is_unchanged(self) -> None:
client = _mock_client(devices=("phone",))
state = SyncState(pushed_rev=None, peer_revs={"phone": "rev-1"})
seen: dict[str, str] = {}
logs = _sync._pull_remote_logs(
client, {"phone": "rev-1"}, state, seen, device_identity()
)
assert logs == []
assert seen == {"phone": "rev-1"}
client.get_file_text.assert_not_called()
def test_downloads_a_peer_whose_revision_moved(self) -> None:
text = json.dumps({})
client = _mock_client(
devices=("phone",),
files={"diet-guard-sync/devices/phone/food_log.json": text},
)
state = SyncState(pushed_rev=None, peer_revs={"phone": "rev-1"})
seen: dict[str, str] = {}
_sync._pull_remote_logs(
client, {"phone": "rev-2"}, state, seen, device_identity()
)
client.get_file_text.assert_called_once()
assert seen == {"phone": "rev-2"}
class TestNoOpPushSuppression:
"""88% of the old GitHub history was byte-identical no-op pushes."""
def test_a_second_unchanged_sync_pushes_no_log(self) -> None:
"""The saving the free-tier budget depends on, at 96 ticks a day."""
_write_token()
client = _mock_client()
with patch.object(_sync_client, "GitHubSyncClient", return_value=client):
_sync.run_sync()
pushed_first = [
call.args[0] for call in client.put_file_text.call_args_list
]
client.put_file_text.reset_mock()
_sync.run_sync()
pushed_second = [call.args[0] for call in client.put_file_text.call_args_list]
assert f"diet-guard-sync/devices/{device_id()}/food_log.json" in pushed_first
assert (
f"diet-guard-sync/devices/{device_id()}/food_log.json" not in pushed_second
)
assert f"diet-guard-sync/revs/{device_id()}" not in pushed_second