mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-10 18:07:20 +01:00
Pulls the day's delivered dishes and their real per-portion macros out of
panel.kuchniavikinga.pl, so a catering meal is one keypress instead of five
retyped numbers.
A delivered meal is not an eaten meal, so nothing is ever logged unattended:
dishes land in the *curated* bank, and entries are written only after an
explicit confirmation. Auto-logging would let the gate satisfy its own
checkpoint from a delivery note, and would record what the courier dropped off
rather than what was eaten.
Three surfaces:
- `diet-guard kuchnia [--log] [--yes]`
- the lock screen's "Today's delivery" button, which prefills the meal form
dish by dish (the user still clicks Log & Continue)
- a warm-up after each logged meal, on the existing background thread
All event-driven; no new systemd timer. The two automatic paths go through
`refresh_delivery_once`, guarded to one fetch per day.
The panel is an undocumented white-labelled Dietly API; the endpoint map was
recovered from its JS bundles and confirmed against live responses. Notes,
including the traps, are in docs/kuchnia-wikinga.md.
Load-bearing details:
* Macros are per portion and `weight` is that portion in grams -- proven, not
assumed: 4P+4C+9F reproduces the stated calories within ~1% on every meal of
the captured day. `_energy_is_consistent` re-runs that check per dish and
drops any that fails; a per-100g mix-up is invisible in a payload and would
silently skew every logged meal by a factor of several.
* `bank_dishes` compares nutritional fields before calling `add_manual_entry`,
which restamps `t` unconditionally. Without the compare, every refresh
republishes the whole curated bank to every peer. Tests assert on call count.
* Duplicate log suppression reads today's synced log, not a local marker:
`log_meal` mints a fresh uuid, so a re-import after marker loss would merge
as duplicates on every peer.
* The gate hook is in `on_focus_ready`, not `_cli_gate._should_lock`:
`gate_is_due()` never reads the food bank, so a refresh there could not
change the lock decision while adding a third-party round trip to the ~105ms
`gate --check` fast path.
* `requests` is lazy-imported so the gate's not-due tick does not pay for it.
* Credentials and the cached session cookie live in ~/.config/diet_guard at
mode 600, outside the synced tree. The credentials file is written by hand,
never by the package -- the same contract as sync_token.
`_GateDashboard` is merged into `_GateMealFlow`, and both "pull it in from
elsewhere" flows share one `_PullFlows` mixin: pylint's max-parents counts
every MRO entry, so the gate's controller chain had no room for a new class.
Also adds a side-by-side row for the two pull buttons -- stacking them cost
42px and pushed the tab past the viewport at 1366x768.
989 tests, 100% branch coverage, pre-commit clean.
222 lines
7.7 KiB
Python
222 lines
7.7 KiB
Python
"""Tests for the lock screen's catering button and its off-thread worker.
|
|
|
|
Two properties matter more than the rest, because breaking either strands a
|
|
user behind a fullscreen lock:
|
|
|
|
* the worker **always** feeds its queue, even when the call raises, or the poll
|
|
waits forever with the button disabled;
|
|
* the flow **fails closed** -- an outage leaves the lock exactly as it was.
|
|
|
|
The completion path is driven directly (put a result, call the poll) rather
|
|
than through a thread, mirroring the doctrine in
|
|
:mod:`diet_guard._gatelock_fetch`: the worker touches no widget, so the Tk side
|
|
is testable with no thread at all.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import datetime
|
|
import queue
|
|
from types import SimpleNamespace
|
|
from typing import TYPE_CHECKING
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from diet_guard import _gatelock_delivery
|
|
from diet_guard._gatelock_delivery import _PullFlows, dish_field_values
|
|
from diet_guard._gatelock_kuchnia import (
|
|
UNEXPECTED,
|
|
DeliveryResult,
|
|
start_delivery_fetch,
|
|
)
|
|
from diet_guard._kuchnia_parse import Dish
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Sequence
|
|
|
|
DAY = datetime.date(2026, 8, 22)
|
|
|
|
|
|
def _dish(name: str = "Kaszotto", priority: int = 1) -> Dish:
|
|
return Dish(
|
|
name=name,
|
|
kcal=391.0,
|
|
protein_g=32.5,
|
|
carbs_g=35.6,
|
|
fat_g=13.0,
|
|
grams=318.0,
|
|
priority=priority,
|
|
slot_label="Kolacja",
|
|
)
|
|
|
|
|
|
class TestWorker:
|
|
def test_hands_back_the_dishes(self) -> None:
|
|
result = start_delivery_fetch(lambda _day: ([_dish()], None), DAY)
|
|
outcome = result.get(timeout=5)
|
|
assert outcome.reason is None
|
|
assert outcome.dishes[0].name == "Kaszotto"
|
|
|
|
def test_hands_back_a_reason(self) -> None:
|
|
result = start_delivery_fetch(lambda _day: ([], "panel down"), DAY)
|
|
assert result.get(timeout=5).reason == "panel down"
|
|
|
|
def test_a_raising_call_still_feeds_the_queue(self) -> None:
|
|
# Without the finally, the poll waits forever with the button disabled
|
|
# and the user cannot get out from behind the lock.
|
|
def boom(_day: datetime.date) -> tuple[Sequence[Dish], str | None]:
|
|
msg = "kaboom"
|
|
raise RuntimeError(msg)
|
|
|
|
result = start_delivery_fetch(boom, DAY)
|
|
assert result.get(timeout=5).reason == UNEXPECTED
|
|
|
|
|
|
class _Gate(_PullFlows):
|
|
"""The slice of the controller the catering flow drives."""
|
|
|
|
def __init__(self) -> None:
|
|
self.root = MagicMock()
|
|
self.demo_mode = False
|
|
self.statuses: list[tuple[str, bool]] = []
|
|
self._widgets = SimpleNamespace(amount_entry=MagicMock())
|
|
self._state = SimpleNamespace(source="manual")
|
|
self._descs: list[str] = []
|
|
self._cleared = 0
|
|
self._projections = 0
|
|
self._entries = [MagicMock() for _ in range(4)]
|
|
|
|
def _set_status(self, text: str, *, error: bool = False) -> None:
|
|
self.statuses.append((text, error))
|
|
|
|
def _set_desc(self, value: str) -> None:
|
|
self._descs.append(value)
|
|
|
|
def _clear_inputs(self) -> None:
|
|
self._cleared += 1
|
|
|
|
def _refresh_projection(self) -> None:
|
|
self._projections += 1
|
|
|
|
def _macro_entries(self) -> tuple[MagicMock, ...]:
|
|
return tuple(self._entries)
|
|
|
|
@property
|
|
def last_status(self) -> str:
|
|
return self.statuses[-1][0]
|
|
|
|
|
|
def _deliver(gate: _Gate, outcome: DeliveryResult, *, asked: bool = True) -> None:
|
|
"""Hand the gate a completed result and run its poll, with no thread.
|
|
|
|
``asked`` mirrors whether the fetch came from the button (the default) or
|
|
from the window opening; an automatic one stays quiet about bad news.
|
|
"""
|
|
gate._delivery_asked = asked
|
|
result: queue.Queue[DeliveryResult] = queue.Queue(maxsize=1)
|
|
result.put(outcome)
|
|
gate._delivery_result = result
|
|
gate._poll_delivery()
|
|
|
|
|
|
class TestLoadButton:
|
|
def test_demo_mode_refuses(self) -> None:
|
|
# A synthetic window must never satisfy a real checkpoint, and there is
|
|
# no reason to send real credentials from one.
|
|
gate = _Gate()
|
|
gate.demo_mode = True
|
|
gate._on_load_delivery()
|
|
assert "only available on the real lock" in gate.last_status
|
|
assert gate._delivery_result is None
|
|
|
|
def test_a_second_click_while_in_flight_is_ignored(self) -> None:
|
|
gate = _Gate()
|
|
with patch.object(_gatelock_delivery, "start_delivery_fetch") as start:
|
|
start.return_value = queue.Queue(maxsize=1)
|
|
gate._on_load_delivery()
|
|
gate._on_load_delivery()
|
|
assert start.call_count == 1
|
|
|
|
def test_an_outage_leaves_the_lock_untouched(self) -> None:
|
|
gate = _Gate()
|
|
_deliver(gate, DeliveryResult(dishes=(), reason="panel down"))
|
|
text, error = gate.statuses[-1]
|
|
assert "panel down" in text
|
|
assert "still locked" in text
|
|
assert error is True
|
|
assert gate._descs == []
|
|
|
|
def test_no_delivery_today_says_so(self) -> None:
|
|
gate = _Gate()
|
|
_deliver(gate, DeliveryResult(dishes=(), reason=None))
|
|
assert "No catering delivery today" in gate.last_status
|
|
|
|
def test_a_dish_is_offered_but_never_logged(self) -> None:
|
|
gate = _Gate()
|
|
_deliver(gate, DeliveryResult(dishes=(_dish(),), reason=None))
|
|
# The form is filled...
|
|
assert gate._descs == ["Kaszotto"]
|
|
gate._widgets.amount_entry.insert.assert_called_once_with(0, "318")
|
|
# ...and that is all. Submitting stays the user's explicit action.
|
|
assert "Log & Continue" in gate.last_status
|
|
|
|
def test_dishes_are_offered_one_at_a_time_in_meal_order(self) -> None:
|
|
gate = _Gate()
|
|
dishes = (_dish("Kolacja", priority=2), _dish("Sniadanie", priority=1))
|
|
_deliver(gate, DeliveryResult(dishes=dishes, reason=None))
|
|
assert gate._descs == ["Sniadanie"]
|
|
assert "1 more to go" in gate.last_status
|
|
gate._prefill_next_dish()
|
|
assert gate._descs == ["Sniadanie", "Kolacja"]
|
|
|
|
def test_the_last_dish_has_no_more_to_go_suffix(self) -> None:
|
|
gate = _Gate()
|
|
_deliver(gate, DeliveryResult(dishes=(_dish(),), reason=None))
|
|
assert "more to go" not in gate.last_status
|
|
|
|
def test_prefilling_past_the_end_is_a_no_op(self) -> None:
|
|
gate = _Gate()
|
|
gate._prefill_next_dish()
|
|
assert gate._descs == []
|
|
|
|
def test_the_poll_rearms_while_the_worker_runs(self) -> None:
|
|
gate = _Gate()
|
|
gate._delivery_result = queue.Queue(maxsize=1)
|
|
gate._poll_delivery()
|
|
gate.root.after.assert_called_with(
|
|
_gatelock_delivery.FETCH_POLL_MS,
|
|
gate._poll_delivery,
|
|
)
|
|
assert gate._delivery_result is not None
|
|
|
|
def test_a_stray_poll_after_completion_stops(self) -> None:
|
|
gate = _Gate()
|
|
gate._delivery_result = None
|
|
gate._poll_delivery()
|
|
assert gate.statuses == []
|
|
gate.root.after.assert_not_called()
|
|
|
|
|
|
class TestFieldFormatting:
|
|
def test_values_are_trimmed_not_padded(self) -> None:
|
|
grams, macros = dish_field_values(_dish())
|
|
assert grams == "318"
|
|
assert macros == ("391", "32.5", "35.6", "13")
|
|
|
|
def test_the_macro_order_matches_the_form(self) -> None:
|
|
# _macro_entries() is (kcal, protein, carbs, fat); a mismatch here
|
|
# would silently swap a dish's protein and carbohydrate.
|
|
_grams, macros = dish_field_values(
|
|
Dish("x", 100.0, 1.0, 2.0, 3.0, 50.0, 1, ""),
|
|
)
|
|
assert macros == ("100", "1", "2", "3")
|
|
|
|
|
|
def test_unknown_lazy_attribute_raises() -> None:
|
|
# The name lives in a variable so ruff cannot fold the lookup into a
|
|
# bare attribute access and then flag it as a useless expression.
|
|
missing = "not_a_real_helper"
|
|
with pytest.raises(AttributeError, match="no attribute"):
|
|
getattr(_gatelock_delivery, missing)
|