diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6e8b768..c097a29 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -102,8 +102,13 @@ repos: additional_dependencies: - pytest - requests - - gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.7.1#subdirectory=gatelock - - crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.6.0#subdirectory=crdt-sync + # Kept in step with pyproject.toml on purpose: pip refuses two + # different direct URLs for one distribution, so a stale pin here + # becomes an unresolvable hook environment the moment freedays -- + # which requires gatelock v0.7.3 -- joins the list. + - gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.7.3#subdirectory=gatelock + - crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.1#subdirectory=crdt-sync + - freedays @ git+https://github.com/kuhyx/utils@freedays-v0.1.0#subdirectory=freedays - mcp>=2,<3 - pydantic # Test suites intentionally use patterns (protected-access, magic-value diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 40b41f1..7d599be 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -2,7 +2,7 @@ name: diet_guard_app description: "Companion phone app for diet_guard: log meals on the go." publish_to: 'none' -version: 1.0.76+1 +version: 1.0.77+1 environment: sdk: ^3.12.2 diff --git a/diet_guard/_gate.py b/diet_guard/_gate.py index c3d502e..e119ca6 100644 --- a/diet_guard/_gate.py +++ b/diet_guard/_gate.py @@ -17,6 +17,8 @@ from __future__ import annotations from typing import TYPE_CHECKING +import freedays + from diet_guard._meal_schedule_store import current_schedule from diet_guard._slots import missing_slots, slot_label from diet_guard._state import ( @@ -38,8 +40,15 @@ def due_slots(now: datetime | None = None) -> tuple[int, ...]: Returns: The slot hours that still need a meal logged (empty == nothing due). + + Empty on a shared free day, whatever the schedule says. The pool is read + from a single local file and never the network, so this cannot hang the + systemd-fired gate, and an unreadable pool reads as "not free" -- the + normal rules apply rather than the gate switching itself off. """ reference = now if now is not None else now_local() + if freedays.is_free_day(reference.date()): + return () return missing_slots(reference, logged_slots_today(), current_schedule()) diff --git a/diet_guard/tests/conftest.py b/diet_guard/tests/conftest.py index a5b06a6..9dd9a67 100644 --- a/diet_guard/tests/conftest.py +++ b/diet_guard/tests/conftest.py @@ -24,6 +24,7 @@ from contextlib import ExitStack from typing import TYPE_CHECKING from unittest.mock import patch +import freedays import pytest from diet_guard._estimator import Nutrition @@ -103,6 +104,14 @@ def _isolate_state(tmp_path: Path) -> Iterator[None]: ~20 redirects CPython refuses to compile it. """ redirects = [ + # The shared free-day pool lives under ~/.local/share, outside every + # diet_guard path below. Without this a real free day on the + # developer's machine would make every "the gate is due" test here + # fail, for a reason that looks nothing like the cause. + patch( + "freedays._api.resolve_paths", + lambda paths: paths or freedays.Paths.under(tmp_path / "freedays"), + ), patch( "diet_guard._budget.BUDGET_FILE", tmp_path / ".budget", diff --git a/diet_guard/tests/test_gate.py b/diet_guard/tests/test_gate.py index a4ba52e..74efc6b 100644 --- a/diet_guard/tests/test_gate.py +++ b/diet_guard/tests/test_gate.py @@ -6,11 +6,17 @@ here the logged set is mocked and ``now`` is injected to drive each decision. from __future__ import annotations -from datetime import UTC, datetime +from datetime import UTC, date, datetime +from typing import TYPE_CHECKING from unittest.mock import patch +import freedays + from diet_guard._gate import due_slots, gate_is_due, gate_message +if TYPE_CHECKING: + from pathlib import Path + def _at(hour: int) -> datetime: """Return a fixed local datetime at ``hour``.""" @@ -77,3 +83,36 @@ class TestGateMessage: with _logged(set()): message = gate_message(_at(17)) assert message == "Log your meals for 08:00, 12:00, 16:00 to unlock." + + +class TestFreeDays: + """The shared pool stands the gate down entirely.""" + + def test_a_free_day_leaves_nothing_due(self, tmp_path: Path) -> None: + with _logged(set()): + assert due_slots(_at(20)), "precondition: slots are due without a free day" + freedays.mark( + _at(20).date(), + paths=freedays.Paths.under(tmp_path / "freedays"), + now=_at(20).date(), + ) + assert due_slots(_at(20)) == () + assert not gate_is_due(_at(20)) + + def test_a_free_day_on_another_date_changes_nothing(self, tmp_path: Path) -> None: + with _logged(set()): + freedays.mark( + date(2026, 12, 24), + paths=freedays.Paths.under(tmp_path / "freedays"), + now=_at(20).date(), + ) + assert due_slots(_at(20)) + assert gate_is_due(_at(20)) + + def test_an_unreadable_pool_leaves_the_gate_armed(self, tmp_path: Path) -> None: + """Fail closed: a corrupt pool must not switch the gate off.""" + pool_dir = tmp_path / "freedays" + pool_dir.mkdir(exist_ok=True) + (pool_dir / "free_days.json").write_text("{ not json", encoding="utf-8") + with _logged(set()): + assert gate_is_due(_at(20)) diff --git a/pyproject.toml b/pyproject.toml index 88efcbb..5168027 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,8 +4,9 @@ version = "1.0.0" description = "Tkinter/systemd log-to-unlock meal gate with calorie/macro budget tracking" requires-python = ">=3.14" dependencies = [ - "gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.7.2#subdirectory=gatelock", + "gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.7.3#subdirectory=gatelock", "crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.1#subdirectory=crdt-sync", + "freedays @ git+https://github.com/kuhyx/utils@freedays-v0.1.0#subdirectory=freedays", "requests==2.34.2", ] diff --git a/requirements.txt b/requirements.txt index 89d8d08..d3f0eea 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,16 +4,17 @@ bandit==1.9.4 codespell==2.4.3 coverage==7.16.0 crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.1#subdirectory=crdt-sync -gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.7.2#subdirectory=gatelock +freedays @ git+https://github.com/kuhyx/utils@freedays-v0.1.0#subdirectory=freedays +gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.7.3#subdirectory=gatelock mcp==2.1.1 mypy==2.3.1 pre-commit==4.6.2 pydantic==2.13.5 -pylint==4.0.7 +pylint==4.0.8 pytest==9.1.1 pytest-cov==7.1.0 -pytest-randomly==4.1.0 +pytest-randomly==5.0.0 pytest-sugar==1.1.1 pytest-xdist==3.8.0 requests==2.34.2 -ruff==0.16.5 +ruff==0.16.6