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.
146 lines
4.9 KiB
Python
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},
|
|
],
|
|
}
|