mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-11 19:33:55 +01:00
The Log Meal tab did not fit this laptop, and had not for some time. Measured
under a real 1366x768 X server: it required 866px inside a 739px content pane
with every field EMPTY, and 938px with a day of meals logged.
Because the content frame was place()-centred it was sheared at BOTH edges at
once -- the "Diet Gate" title and the slot header (which meal you are being
asked to log) off the top, the calorie headline and the whole dashboard off the
bottom -- with no scrollbar anywhere in the package and nothing to indicate
content was missing. This is a hard lock: overrideredirect, a global grab, VT
switching disabled. Unreachable content is unrecoverable.
Two independent causes, both needed fixing:
- Every font size was handed to Tk as *points*. Tk encodes the unit in the
SIGN of the size, and _gatelock_typography carried a comment asserting the
opposite ("Tk has no separate px/pt distinction"). At ~100 DPI that made
every string ~30% oversized. Negative sizes now; the docstring records why.
- There was no scroll container. build_layout now uses gatelock's
ScrollableSurface, which top-anchors, scrolls by keyboard, and scrolls to
follow focus.
Font sizes alone were not enough: they take the layout to 702px, still over the
571px pane at 1024x600. Both fixes are load-bearing.
The grams/items selector was mouse-only. tk.OptionMenu's Menubutton defaults to
takefocus=0, so ::tk::FocusOK rejects it and it never entered the tab ring --
verified by walking the real ring, which ran Entry -> Text -> Listbox -> Button
-> TNotebook and skipped it entirely. Switching units rescales the whole entry,
so a pointerless user could not correct "250 grams" to "3 items" inside a lock
they cannot leave without logging a meal. It is now two Radiobuttons, which are
in the ring by default and take both Space and Return -- and which post nothing,
unlike a menu, whose grab-stealing is why screen-locker bans OptionMenu on lock
surfaces outright.
Also: the focus ring was invisible (Tk's default is 1px BLACK on #211D1B) and
_build_desc actively inverted it by setting highlightbackground, the UNfocused
ring, so the box outlined while idle and went black the moment it took focus.
Enter now activates buttons (X11 Tk binds only <space>). Text widgets no longer
trap Tab. notebook.enable_traversal() is called, so Ctrl+Tab and Ctrl+PageDown
work. wraplength drops 900 -> 640, the line-length cap, which also stops a
label forcing a 900px-wide layout onto a 1024px screen.
The Flutter app had two controls with no keyboard route at all. Its desktop
target is the web build in a Chrome --app window, so a touch gesture is not a
fallback there, it is the only interaction:
- Deleting a history entry was bound solely to onLongPress. Long-press has no
keyboard equivalent whatsoever. It now has a focusable IconButton; the
long-press stays as a redundant pointer shortcut.
- Calendar day cells were a bare GestureDetector, so navigating to
date-filtered history was pointer-only and invisible to a screen reader.
Now Semantics(button: true) over an InkWell.
Plus: the filter sheet built a TextEditingController inside build() with the
caret forced to end-of-text, so every keystroke discarded it and silently undid
mid-string Home/End/arrow editing. And nothing capped width, so on the 1366px
desktop window prose ran ~180 characters per line (twice the readable limit)
and a four-digit kcal field spanned ~1334px. AppWidth adds those caps.
Tests: the render check is a failing test, not a script -- ruff runs with
--unsafe-fixes, whose T201 fix DELETES print statements, which would silently
turn a diagnostic into a no-op. test_gate_layout_fits.py was verified red on
the pre-fix code ("content 866 in viewport 768 -> NO SCROLL VIEWPORT ->
UNREACHABLE CONTENT") and green after. The Flutter suite gains its first
keyboard-reachability assertions; it previously had ~109 tester.tap calls and
zero checks that anything was focusable.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
332 lines
13 KiB
Python
332 lines
13 KiB
Python
"""Tests for _gatelock.py — the fullscreen log-to-unlock gate window.
|
|
|
|
Construction, MealGate's gatelock wiring (LockConfig choice, hooks), and the
|
|
shared module-level helpers. The fullscreen/grab/VT-disable mechanics
|
|
themselves are tested in the ``gatelock`` package, not here. The
|
|
nutrition/meal-flow tests live in :mod:`test_gatelock_mealflow`; the
|
|
functional fake ``tk`` widgets and the ``gate`` fixture live in
|
|
``conftest.py`` and are shared by both files.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from types import SimpleNamespace
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from diet_guard import (
|
|
_gatelock,
|
|
_gatelock_support,
|
|
_gatelock_ui,
|
|
)
|
|
from diet_guard._budget import write_budget
|
|
from diet_guard._gatelock import (
|
|
MealGate,
|
|
_pending_slots,
|
|
acquire_gate_lock,
|
|
release_gate_lock,
|
|
)
|
|
from diet_guard._gatelock_core import _safe_float
|
|
from diet_guard._gatelock_nutrition import _format_preview
|
|
from diet_guard._gatelock_support import wait_for_display
|
|
from diet_guard._gatelock_ui import DEFAULT_PER_GRAMS
|
|
from diet_guard._portions import DEFAULT_ITEM_GRAMS
|
|
from diet_guard.tests.conftest import (
|
|
_FakeTclError,
|
|
_nutrition,
|
|
fake_tk,
|
|
)
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Module-level helpers
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
class TestModuleHelpers:
|
|
"""Pure functions and the single-instance lock."""
|
|
|
|
def test_safe_float_blank(self) -> None:
|
|
"""A blank string is None."""
|
|
assert _safe_float("") is None
|
|
|
|
def test_safe_float_number(self) -> None:
|
|
"""A numeric string parses."""
|
|
assert _safe_float("3.5") == 3.5
|
|
|
|
def test_safe_float_non_numeric(self) -> None:
|
|
"""A non-numeric string is None."""
|
|
assert _safe_float("abc") is None
|
|
|
|
def test_format_preview_with_portion(self) -> None:
|
|
"""A non-zero portion shows the grams segment."""
|
|
text = _format_preview(_nutrition(grams=200))
|
|
assert "200g" in text
|
|
|
|
def test_format_preview_without_portion(self) -> None:
|
|
"""A zero portion omits the grams segment."""
|
|
text = _format_preview(_nutrition(grams=0))
|
|
assert "g ·" not in text
|
|
|
|
def test_gate_lock_single_instance(self) -> None:
|
|
"""A second acquire while the first is held returns None."""
|
|
first = acquire_gate_lock()
|
|
assert first is not None
|
|
assert acquire_gate_lock() is None
|
|
release_gate_lock(first)
|
|
|
|
def test_pending_slots_due(self) -> None:
|
|
"""When slots are due, those are returned verbatim."""
|
|
with patch.object(_gatelock, "due_slots", return_value=[12, 16]):
|
|
assert _pending_slots(demo_mode=False) == [12, 16]
|
|
|
|
def test_pending_slots_demo_fallback(self) -> None:
|
|
"""Demo mode invents a representative slot when nothing is due."""
|
|
with patch.object(_gatelock, "due_slots", return_value=[]):
|
|
assert len(_pending_slots(demo_mode=True)) == 1
|
|
|
|
def test_pending_slots_production_empty(self) -> None:
|
|
"""Production with nothing due returns no slots."""
|
|
with patch.object(_gatelock, "due_slots", return_value=[]):
|
|
assert not _pending_slots(demo_mode=False)
|
|
|
|
|
|
class TestAssertNotUnderPytest:
|
|
"""The safety net that blocks a real Tk gate under pytest."""
|
|
|
|
def test_raises_with_real_tkinter(self) -> None:
|
|
"""Real tkinter under pytest is refused."""
|
|
with (
|
|
patch.object(_gatelock, "tk", SimpleNamespace(__name__="tkinter")),
|
|
pytest.raises(RuntimeError),
|
|
):
|
|
_gatelock._assert_not_under_pytest()
|
|
|
|
def test_passes_with_mock(self) -> None:
|
|
"""A mocked tk (name != tkinter) passes straight through."""
|
|
with patch.object(_gatelock, "tk", SimpleNamespace(__name__="mock")):
|
|
_gatelock._assert_not_under_pytest()
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Construction
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
class TestConstruction:
|
|
"""Building the window in both modes."""
|
|
|
|
def test_demo_builds(self, gate: MealGate) -> None:
|
|
"""A demo gate constructs with a pending slot, grams basis, and a soft lock."""
|
|
assert gate.demo_mode is True
|
|
assert gate._vars.unit.get() == "grams"
|
|
assert gate._lock._config.mode == "soft"
|
|
|
|
def test_production_builds(self) -> None:
|
|
"""A production gate builds with a hard lock config."""
|
|
# Use the shared patch set: the widget tree spans several modules plus
|
|
# gatelock's scroll viewport, and leaving any of them real mixes a real
|
|
# widget with a fake master.
|
|
with fake_tk():
|
|
gate = MealGate(demo_mode=False)
|
|
assert gate.demo_mode is False
|
|
assert gate._lock._config.mode == "hard"
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Form logic
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
class TestFormBasics:
|
|
"""Field helpers and the numeric validator."""
|
|
|
|
def test_numeric_validator(self) -> None:
|
|
"""Blank and numbers are allowed; words are not."""
|
|
assert _gatelock_ui.is_numeric_or_blank("")
|
|
assert _gatelock_ui.is_numeric_or_blank("12.5")
|
|
assert not _gatelock_ui.is_numeric_or_blank("abc")
|
|
|
|
def test_desc_get_set(self, gate: MealGate) -> None:
|
|
"""The description round-trips through its helpers, trimmed."""
|
|
gate._set_desc(" shoarma ")
|
|
assert gate._get_desc() == "shoarma"
|
|
|
|
def test_desc_return_suppresses_newline(self, gate: MealGate) -> None:
|
|
"""Enter in the description submits and returns the break sentinel."""
|
|
gate._set_desc("apple")
|
|
with patch.object(gate, "_on_submit") as submit:
|
|
assert gate._on_desc_return(None) == "break"
|
|
submit.assert_called_once()
|
|
|
|
def test_macro_values_non_numeric(self, gate: MealGate) -> None:
|
|
"""A non-numeric macro field makes the whole read None."""
|
|
gate._widgets.macros.kcal.insert(0, "abc")
|
|
assert gate._macro_values() is None
|
|
|
|
|
|
class TestBasisAndAmount:
|
|
"""Edge branches in the grams/items basis and amount maths."""
|
|
|
|
def test_basis_typed_value(self, gate: MealGate) -> None:
|
|
"""A typed per-value is honoured directly."""
|
|
gate._set_entry(gate._widgets.per_entry, "50")
|
|
assert gate._basis_grams() == 50
|
|
|
|
def test_basis_items_known_staple(self, gate: MealGate) -> None:
|
|
"""Items mode with a blank per falls back to the staple weight."""
|
|
gate._widgets.per_entry.delete(0)
|
|
gate._vars.unit.set("items")
|
|
gate._set_desc("apple")
|
|
assert gate._basis_grams() == 182
|
|
|
|
def test_basis_items_unknown(self, gate: MealGate) -> None:
|
|
"""An unknown item uses the default piece weight."""
|
|
gate._widgets.per_entry.delete(0)
|
|
gate._vars.unit.set("items")
|
|
gate._set_desc("mystery")
|
|
assert gate._basis_grams() == DEFAULT_ITEM_GRAMS
|
|
|
|
def test_basis_grams_default(self, gate: MealGate) -> None:
|
|
"""Grams mode with a blank per uses the per-100 g default."""
|
|
gate._widgets.per_entry.delete(0)
|
|
assert gate._basis_grams() == DEFAULT_PER_GRAMS
|
|
|
|
def test_eaten_grams_none(self, gate: MealGate) -> None:
|
|
"""No amount typed yields no eaten weight."""
|
|
assert gate._eaten_grams() is None
|
|
|
|
def test_eaten_grams_items(self, gate: MealGate) -> None:
|
|
"""Items mode multiplies the count by the per-item weight."""
|
|
gate._vars.unit.set("items")
|
|
gate._set_desc("apple")
|
|
gate._set_entry(gate._widgets.per_entry, "182")
|
|
gate._set_entry(gate._widgets.amount_entry, "5")
|
|
assert gate._eaten_grams() == 5 * 182
|
|
|
|
def test_amount_change_refreshes(self, gate: MealGate) -> None:
|
|
"""Changing the amount recomputes the preview."""
|
|
gate._set_entry(gate._widgets.macros.kcal, "100")
|
|
gate._set_entry(gate._widgets.amount_entry, "200")
|
|
gate._on_amount_change(None)
|
|
assert gate._vars.preview.get()
|
|
|
|
def test_projection_else_without_item(self, gate: MealGate) -> None:
|
|
"""With a budget but no priced item, no after-this-item is shown."""
|
|
write_budget(2000)
|
|
gate._refresh_projection()
|
|
text = gate._vars.projection.get()
|
|
assert "left" in text
|
|
assert "after this item" not in text
|
|
|
|
def test_keyrelease_grams_mode(self, gate: MealGate) -> None:
|
|
"""In grams mode the per-item weight is not touched on keyrelease."""
|
|
gate._vars.unit.set("grams")
|
|
gate._set_desc("apple")
|
|
gate._on_desc_keyrelease(None)
|
|
|
|
def test_keyrelease_items_unknown(self, gate: MealGate) -> None:
|
|
"""An unknown item in items mode leaves the per field unchanged."""
|
|
gate._vars.unit.set("items")
|
|
gate._set_desc("zzzz")
|
|
gate._on_desc_keyrelease(None)
|
|
|
|
def test_apply_reference_keeps_existing_amount(self, gate: MealGate) -> None:
|
|
"""A grams-mode pick does not overwrite an amount already typed."""
|
|
gate._set_entry(gate._widgets.amount_entry, "50")
|
|
gate._apply_reference(_nutrition(100, 100))
|
|
assert gate._widgets.amount_entry.get() == "50"
|
|
|
|
|
|
class TestLockDelegation:
|
|
"""MealGate's gatelock wiring: hooks delegate, run()/close() delegate."""
|
|
|
|
def test_on_focus_ready_focuses_desc_text(self, gate: MealGate) -> None:
|
|
"""on_focus_ready puts keyboard focus on the description box.
|
|
|
|
``desc_text`` is a group built fresh on each access now, so the
|
|
assertion has to bind to the widget underneath it.
|
|
"""
|
|
widget = gate._widgets.bundles[0].desc_text
|
|
widget.focus_force = MagicMock()
|
|
|
|
gate.on_focus_ready(MagicMock())
|
|
|
|
widget.focus_force.assert_called_once()
|
|
|
|
def test_on_focus_ready_with_no_live_output(self, gate: MealGate) -> None:
|
|
"""No surface at all: the gate is held with nothing to focus."""
|
|
widget = gate._widgets.bundles[0].desc_text
|
|
widget.focus_force = MagicMock()
|
|
|
|
gate.on_focus_ready(None)
|
|
|
|
widget.focus_force.assert_not_called()
|
|
|
|
def test_on_close_is_a_noop(self, gate: MealGate) -> None:
|
|
"""on_close has no hardware/state to release; must not raise."""
|
|
gate.on_close()
|
|
|
|
def test_callback_error_status(self, gate: MealGate) -> None:
|
|
"""An unexpected callback error surfaces a recoverable message."""
|
|
gate.on_callback_error()
|
|
assert "went wrong" in gate._vars.status.get()
|
|
|
|
def test_run_delegates_to_lock(self, gate: MealGate) -> None:
|
|
"""run() hands off to the owned LockWindow."""
|
|
with patch.object(gate._lock, "run") as mock_run:
|
|
gate.run()
|
|
mock_run.assert_called_once_with()
|
|
|
|
def test_close_delegates_to_lock(self, gate: MealGate) -> None:
|
|
"""close() hands off to the owned LockWindow."""
|
|
with patch.object(gate._lock, "close") as mock_close:
|
|
gate.close()
|
|
mock_close.assert_called_once_with()
|
|
|
|
|
|
class TestDisplayReadiness:
|
|
"""The session-start display wait that absorbs the X auth-cookie race."""
|
|
|
|
def test_ready_when_root_connects(self) -> None:
|
|
"""A Tk root that builds and destroys cleanly means the display is up."""
|
|
fake_tk = SimpleNamespace(Tk=MagicMock(), TclError=_FakeTclError)
|
|
with patch.object(_gatelock_support, "tk", fake_tk):
|
|
assert _gatelock_support._display_is_ready() is True
|
|
fake_tk.Tk.return_value.destroy.assert_called_once()
|
|
|
|
def test_not_ready_on_tclerror(self) -> None:
|
|
"""A TclError from Tk() (no display / no cookie yet) means not ready."""
|
|
fake_tk = SimpleNamespace(
|
|
Tk=MagicMock(side_effect=_FakeTclError("no display")),
|
|
TclError=_FakeTclError,
|
|
)
|
|
with patch.object(_gatelock_support, "tk", fake_tk):
|
|
assert _gatelock_support._display_is_ready() is False
|
|
|
|
def test_wait_returns_immediately_when_ready(self) -> None:
|
|
"""A display ready on the first probe returns at once and never sleeps."""
|
|
sleep = MagicMock()
|
|
with patch.object(_gatelock_support, "_display_is_ready", return_value=True):
|
|
ready = wait_for_display(sleep=sleep, monotonic=MagicMock(return_value=0.0))
|
|
assert ready is True
|
|
sleep.assert_not_called()
|
|
|
|
def test_wait_polls_then_succeeds(self) -> None:
|
|
"""Not-ready then ready sleeps once between probes, then unblocks."""
|
|
sleep = MagicMock()
|
|
monotonic = MagicMock(side_effect=[0.0, 0.0])
|
|
with patch.object(
|
|
_gatelock_support, "_display_is_ready", side_effect=[False, True]
|
|
):
|
|
assert wait_for_display(sleep=sleep, monotonic=monotonic) is True
|
|
sleep.assert_called_once()
|
|
|
|
def test_wait_times_out_and_defers(self) -> None:
|
|
"""A display still down at the deadline gives up so the next tick retries."""
|
|
sleep = MagicMock()
|
|
monotonic = MagicMock(side_effect=[0.0, 60.0])
|
|
with patch.object(_gatelock_support, "_display_is_ready", return_value=False):
|
|
assert wait_for_display(sleep=sleep, monotonic=monotonic) is False
|
|
sleep.assert_not_called()
|