diet-guard/diet_guard/tests/_kuchnia_fakes.py
Krzysztof kuhy Rudnicki 26f1770090 feat: import Kuchnia Wikinga catering deliveries into the food bank
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.
2026-08-22 23:04:22 +02:00

146 lines
4.9 KiB
Python

"""Shared fakes for the catering-import tests.
Not a ``test_`` module: these are fixtures-by-hand imported by the
``test_kuchnia_*`` files, and pytest must not collect them as tests. The repo
already excludes this naming pattern from the ``name-tests-test`` hook.
The fake stands in for ``requests`` at the module seam
(``patch.object(_kuchnia_client, "requests", ...)``), which is the same shape
``_estimator_off``'s tests use and the reason both modules resolve the name
through ``sys.modules`` rather than binding it at import.
"""
from __future__ import annotations
import json
from types import SimpleNamespace
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Iterator
from pathlib import Path
class FakeResponse:
"""The slice of ``requests.Response`` the client actually touches."""
def __init__(
self, status: int = 200, payload: object = None, *, text: str = ""
) -> None:
"""Build a response with ``status`` and either JSON or raw text."""
self.status_code = status
self._payload = payload
self.text = text or (json.dumps(payload) if payload is not None else "")
@property
def ok(self) -> bool:
"""Mirror ``requests``: 2xx and 3xx are OK."""
return self.status_code < 400
def json(self) -> object:
"""Return the decoded payload, raising like ``requests`` on non-JSON."""
if self._payload is None:
msg = "no json"
raise ValueError(msg)
return self._payload
class FakeCookieJar(dict[str, str]):
"""A cookie jar with the two methods the client uses."""
def set(self, name: str, value: str, **_kwargs: object) -> None:
"""Store a cookie, ignoring domain/path keywords."""
self[name] = value
class FakeSession:
"""Records requests and replays queued responses in order."""
def __init__(self, responses: list[FakeResponse] | None = None) -> None:
"""Queue ``responses`` to be returned one per request."""
self.headers: dict[str, str] = {}
self.cookies = FakeCookieJar()
self.calls: list[tuple[str, str]] = []
self.bodies: list[dict[str, str]] = []
self._responses: Iterator[FakeResponse] = iter(responses or [])
self.login_cookie: str | None = "session-abc"
self.raise_on_request: Exception | None = None
def request(
self,
method: str,
url: str,
data: dict[str, str] | None = None,
timeout: float | None = None,
) -> FakeResponse:
"""Return the next queued response, recording the call and body."""
del timeout
if self.raise_on_request is not None:
raise self.raise_on_request
self.calls.append((method, url))
if data is not None:
self.bodies.append(data)
if method == "POST" and url.endswith("auth/login") and self.login_cookie:
self.cookies.set("SESSION", self.login_cookie)
try:
return next(self._responses)
except StopIteration:
return FakeResponse(200, {})
def fake_requests(session: FakeSession) -> SimpleNamespace:
"""Return a stand-in for the ``requests`` module itself.
A namespace rather than a class: ``requests.Session`` is capitalised, and
defining a method by that name would need an ``N802`` suppression for no
benefit. ``RequestException`` is included because the client catches it.
"""
return SimpleNamespace(
Session=lambda: session,
RequestException=FakeRequestError,
)
class FakeRequestError(Exception):
"""Stands in for ``requests.RequestException``."""
def write_credentials(path: Path, user: str = "me@example.com", pw: str = "pw") -> None:
"""Write a credentials file in the two-line format the reader expects."""
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(f"{user}\n{pw}\n", encoding="utf-8")
#: A menu payload shaped like the live one, macros per portion.
def menu_payload(count: int = 2) -> dict[str, object]:
"""Return a ``menus/delivery/{id}/new`` body with ``count`` valid dishes."""
return {
"deliveryMenuMeal": [
{
"menuMealName": f"Danie {index}",
"mealName": "Obiad",
"mealPriority": index,
"nutrition": {
"weight": 300.0,
"calories": 400.0,
"protein": 25.0,
"carbohydrate": 50.0,
"fat": 11.1,
},
}
for index in range(1, count + 1)
],
}
def order_payload(
delivery_id: int = 111, date: str = "2026-08-22"
) -> dict[str, object]:
"""Return an order body embedding one delivery on ``date``."""
return {
"orderId": 1,
"deliveries": [
{"deliveryId": delivery_id, "date": date, "deleted": False},
],
}