diet-guard/diet_guard/tests/_tk_fakes.py
Krzysztof kuhy Rudnicki c310861855 slots: minute-of-day consumers (phase 2 of 3: Python side)
Every Python consumer now speaks slot minutes:

- satisfaction is nearest-slot snapping (_slot_wire.satisfied_by_entries)
  at every "is this slot logged?" site: gate, CLI status, MCP, catering
  occupancy and dedup. Nothing on disk is rewritten; 07:00/10:00 entries
  keep covering 07:15/10:15 after a schedule move.
- log_meal writes slot_fields(minute) before signing (slot + slot_min).
- schedule store and sync encode via schedule_to_wire/from_wire; a
  whole-hour schedule stays byte-identical (f/l/n only).
- CLI ate --hour takes HH or HH:MM; MCP log_meal takes "HH:MM" or a
  legacy int hour, validated against today's slots; get_slots returns
  {minute, label}.
- gate History tab: first/last are HH:MM ttk.Comboboxes (quarter-hour
  list, typed times, Up/Down step without posting the popdown), locked
  as disabled; validation mirrors normalized().
- gate timer fires every 15 min; docs rewritten for minutes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015npsYVB2PdtWkG7MPAi4tn
2026-10-10 19:53:39 +02:00

186 lines
5.7 KiB
Python

"""Functional fake Tk widgets for the gate's display-free tests.
Extracted from ``conftest.py`` purely for size: the widget tree the gate builds
now spans several modules plus ``gatelock``, and the fakes grew past this repo's
500-line file cap when the scroll viewport and the radio-button unit selector
were added.
Stays under ``tests/`` deliberately. It was briefly moved into the package to
satisfy the ``test_*.py`` naming hook, but that lost the ruff ``ARG`` exemption
that ``**/tests/**`` already grants -- and a fake widget's whole job is to
accept arguments it ignores. Weakening ruff for package code to accommodate
test scaffolding is the wrong trade; excluding one clearly-named support file
from a naming convention is the cheaper one.
The fakes are *functional* where behaviour matters -- selecting a radio button
really sets its variable and fires its command, and the canvas really tracks a
scroll offset -- so a test can drive the gate the way a keyboard user now can,
rather than merely asserting that a widget was constructed.
"""
from __future__ import annotations
import tkinter as tk
from types import SimpleNamespace
from diet_guard.tests._tk_fakes_canvas import FakeCanvas, FakeScrollbar
from diet_guard.tests._tk_fakes_containers import (
FakeCombobox,
FakeNotebook,
FakeRadiobutton,
FakeStyle,
FakeWidget,
)
# Imported by name so `from _tk_fakes import X` keeps working for every fake,
# and so `_gate_fixtures`'s `_FAKE_TK`/`_FAKE_TTK` import has one home. Listed
# explicitly (rather than left as incidental imports) because ruff would
# otherwise strip the re-exports as unused -- and the identity assertion in
# `test_gate_tk_modules.py` is what catches it if one ever goes missing.
__all__ = [
"_FAKE_TK",
"_FAKE_TTK",
"FakeCanvas",
"FakeCombobox",
"FakeEntry",
"FakeListbox",
"FakeNotebook",
"FakeRadiobutton",
"FakeScrollbar",
"FakeStyle",
"FakeText",
"FakeVar",
"FakeWidget",
"_FakeTclError",
]
class _FakeTclError(tk.TclError):
"""Stand-in for ``tkinter.TclError`` (a real, catchable exception).
Subclasses the *real* ``tkinter.TclError`` rather than ``Exception``.
Not every module that catches a dead-widget error is in the fake-tk patch
set -- ``_gatelock_widgetgroups`` deliberately is not, since it constructs
no widgets -- so its ``contextlib.suppress(tk.TclError)`` refers to the
genuine class. With a bare ``Exception`` base the fake escaped that
suppression, meaning the "a monitor vanished mid-update" paths were only
ever exercised against the real error and would have raised through the
gate in a fake-tk test. Inheriting keeps both spellings catchable.
"""
class FakeVar:
"""A functional ``StringVar``: stores and returns a string."""
def __init__(self, master: object = None, value: str = "") -> None:
self._value = value
def get(self) -> str:
return self._value
def set(self, value: str) -> None:
self._value = value
class FakeEntry:
"""A functional one-line entry (delete clears, insert appends).
``configure``/``config`` record their kwargs into ``configured``, so a
test can assert on a read-only/editable state toggle the same way it
would with :class:`FakeWidget`.
"""
def __init__(self, *args: object, **kwargs: object) -> None:
self._value = ""
self.configured: dict[str, object] = dict(kwargs)
def get(self) -> str:
return self._value
def delete(self, first: object, last: object = None) -> None:
self._value = ""
def insert(self, index: object, text: str) -> None:
self._value += text
def pack(self, *args: object, **kwargs: object) -> FakeEntry:
return self
def bind(self, *args: object, **kwargs: object) -> None:
pass
def configure(self, *args: object, **kwargs: object) -> None:
self.configured.update(kwargs)
config = configure
def focus_set(self) -> None:
pass
def focus_force(self) -> None:
pass
class FakeText(FakeEntry):
"""A functional multi-line text box (``get`` ignores the index range)."""
def get(self, start: object = None, end: object = None) -> str:
return self._value
class FakeListbox:
"""A functional listbox tracking items and the current selection."""
def __init__(self, *args: object, **kwargs: object) -> None:
self._items: list[str] = []
self._sel: tuple[int, ...] = ()
def delete(self, first: object, last: object = None) -> None:
self._items = []
def insert(self, index: object, text: str) -> None:
self._items.append(text)
def curselection(self) -> tuple[int, ...]:
return self._sel
def selection_set(self, index: int) -> None:
self._sel = (index,)
def selection_clear(self, first: object, last: object = None) -> None:
self._sel = ()
def pack(self, *args: object, **kwargs: object) -> FakeListbox:
return self
def bind(self, *args: object, **kwargs: object) -> None:
pass
_FAKE_TK = SimpleNamespace(
END="end",
TclError=_FakeTclError,
StringVar=FakeVar,
Frame=FakeWidget,
Label=FakeWidget,
Button=FakeWidget,
OptionMenu=FakeWidget,
Radiobutton=FakeRadiobutton,
Checkbutton=FakeWidget,
Entry=FakeEntry,
Text=FakeText,
Listbox=FakeListbox,
Canvas=FakeCanvas,
Scrollbar=FakeScrollbar,
Misc=FakeWidget,
Event=object,
)
_FAKE_TTK = SimpleNamespace(
Notebook=FakeNotebook, Style=FakeStyle, Combobox=FakeCombobox
)
# Every mixin module the gate window is built from imports ``tkinter``
# independently; all of them must see the fake so ``tk.TclError`` etc. are the
# catchable ``_FakeTclError`` everywhere a test raises it.