mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-11 15:08:11 +01:00
The gate is laid out once per live output instead of once on the primary, and claims the screen through an Arbiter at RANK_DIET_GUARD so the wake alarm and the workout locker can both outrank it. Three kinds of state needed three different answers. The display fields already fanned out for free -- GateVars holds root-mastered StringVars bound to labels. The typed-in fields did not: amount, per, meal name and the four macro cells were bare tk.Entry widgets, so N copies would have been N independent forms. They are variable-backed now, which is what makes logging work from whichever screen the user is actually looking at. The description box and the suggestion list cannot be shared at all -- tk.Text and tk.Listbox have no variable -- so those genuinely diverge and reads answer from the copy the user touched. The History tab is per-surface too: its grid, budget field and edit button are driven across every monitor, with the budget likewise variable-backed so the value cannot differ between screens mid-edit. Laying the gate out on the primary monitor only was considered and rejected: not having to hunt for the screen the form landed on is the entire point of per-output locking. Tests: the suite hung rather than failed on first run -- diet_guard patches its own `tk`, but LockWindow builds Toplevels through gatelock's, and real tkinter over a MagicMock root recurses without bound. `_hermetic_gatelock` closes that and redirects the runtime dir so no test can stand a production locker down. Every other test runs single-output, where fan-out is unobservable, so test_gatelock_fanout runs on two; each assertion was checked by cropping the fan-out loops to the first surface and confirming it goes red. _gatelock_ui and _gatelock_calendar were each split in two to stay under the 500-line limit, and GateVars' typed-in fields moved into a nested GateEntryVars rather than raising pylint's attribute cap. 470 tests, 100% branch coverage, pre-commit clean, no lint suppressions.
132 lines
4.9 KiB
Python
132 lines
4.9 KiB
Python
"""Tests for the pure (no-Tk) calendar grid math and status text."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import date
|
|
|
|
from diet_guard._calendar_view import (
|
|
CalendarCell,
|
|
build_month_cells,
|
|
cell_style,
|
|
streaks_text,
|
|
ytd_text,
|
|
)
|
|
from diet_guard._daystatus import DayStatus
|
|
from diet_guard._gatelock_ui import BG, FG
|
|
|
|
|
|
class TestBuildMonthCells:
|
|
"""Boundary matrix for :func:`build_month_cells`."""
|
|
|
|
def test_blank_cells_outside_the_month_have_no_day_or_status(self) -> None:
|
|
# July 2026 starts on a Wednesday, so the first row has leading blanks.
|
|
weeks = build_month_cells(2026, 7, {}, today=date(2026, 7, 31))
|
|
assert weeks[0][0] == CalendarCell(day=None, status=None)
|
|
|
|
def test_a_day_present_in_the_status_map_carries_its_status(self) -> None:
|
|
weeks = build_month_cells(
|
|
2026,
|
|
7,
|
|
{"2026-07-15": DayStatus.GREEN},
|
|
today=date(2026, 7, 31),
|
|
)
|
|
cell = next(c for week in weeks for c in week if c.day == 15)
|
|
assert cell.status is DayStatus.GREEN
|
|
|
|
def test_a_past_day_absent_from_the_status_map_is_not_logged(self) -> None:
|
|
weeks = build_month_cells(2026, 7, {}, today=date(2026, 7, 31))
|
|
cell = next(c for week in weeks for c in week if c.day == 1)
|
|
assert cell.status is DayStatus.NOT_LOGGED
|
|
|
|
def test_a_future_day_is_neutral_not_not_logged(self) -> None:
|
|
weeks = build_month_cells(2026, 7, {}, today=date(2026, 7, 10))
|
|
cell = next(c for week in weeks for c in week if c.day == 15)
|
|
assert cell.status is None
|
|
|
|
def test_today_itself_is_classified_not_treated_as_future(self) -> None:
|
|
weeks = build_month_cells(
|
|
2026,
|
|
7,
|
|
{"2026-07-10": DayStatus.RED},
|
|
today=date(2026, 7, 10),
|
|
)
|
|
cell = next(c for week in weeks for c in week if c.day == 10)
|
|
assert cell.status is DayStatus.RED
|
|
|
|
def test_none_status_map_renders_every_past_day_neutral(self) -> None:
|
|
weeks = build_month_cells(2026, 7, None, today=date(2026, 7, 31))
|
|
cell = next(c for week in weeks for c in week if c.day == 1)
|
|
assert cell.status is None
|
|
|
|
def test_defaults_today_to_the_real_current_date(self) -> None:
|
|
# No explicit `today` -- exercises the now_local() fallback branch.
|
|
weeks = build_month_cells(2020, 1, {})
|
|
assert weeks[0][0].day in (None, 1)
|
|
|
|
def test_a_28_day_february_produces_four_or_five_weeks(self) -> None:
|
|
weeks = build_month_cells(2026, 2, {}, today=date(2026, 2, 28))
|
|
assert len(weeks) in (4, 5)
|
|
|
|
def test_a_31_day_month_starting_on_saturday_produces_six_weeks(self) -> None:
|
|
# August 2026 starts on a Saturday and has 31 days -> 6 rows.
|
|
weeks = build_month_cells(2026, 8, {}, today=date(2026, 8, 31))
|
|
assert len(weeks) == 6
|
|
|
|
def test_every_day_of_the_month_is_present_exactly_once(self) -> None:
|
|
weeks = build_month_cells(2026, 7, {}, today=date(2026, 7, 31))
|
|
days = [cell.day for week in weeks for cell in week if cell.day is not None]
|
|
assert days == list(range(1, 32))
|
|
|
|
|
|
class TestCellStyle:
|
|
"""Boundary matrix for :func:`cell_style`."""
|
|
|
|
def test_none_status_is_neutral(self) -> None:
|
|
bg, _fg, outline = cell_style(None)
|
|
assert bg == outline
|
|
|
|
def test_not_logged_is_hollow_with_a_visible_outline(self) -> None:
|
|
"""A "not logged" cell blends into the background but keeps a
|
|
near-white outline, distinguishing "no data" from a black square
|
|
(safe-design-rules rule 1: no pure black)."""
|
|
bg, _fg, outline = cell_style(DayStatus.NOT_LOGGED)
|
|
assert bg == BG
|
|
assert outline == FG
|
|
assert outline != bg
|
|
|
|
def test_green_yellow_red_each_style_distinctly(self) -> None:
|
|
styles = {
|
|
status: cell_style(status)
|
|
for status in (DayStatus.GREEN, DayStatus.YELLOW, DayStatus.RED)
|
|
}
|
|
assert len({style[0] for style in styles.values()}) == 3
|
|
|
|
|
|
class TestStreaksText:
|
|
""":func:`streaks_text` boundary cases (singular/plural, zero)."""
|
|
|
|
def test_zero_streaks_render_as_zero_days(self) -> None:
|
|
text = streaks_text({}, today=date(2026, 7, 13))
|
|
assert "0 days" in text
|
|
|
|
def test_a_one_day_streak_uses_the_singular(self) -> None:
|
|
text = streaks_text(
|
|
{"2026-07-13": DayStatus.GREEN},
|
|
today=date(2026, 7, 13),
|
|
)
|
|
assert "Logging streak: 1 day " in text
|
|
assert "Adherence streak: 1 day" in text
|
|
|
|
|
|
class TestYtdText:
|
|
""":func:`ytd_text` renders the tally's three counts."""
|
|
|
|
def test_reports_logged_elapsed_and_adherent_counts(self) -> None:
|
|
status_map_ = {
|
|
"2026-01-01": DayStatus.GREEN,
|
|
"2026-01-02": DayStatus.RED,
|
|
}
|
|
text = ytd_text(status_map_, today=date(2026, 1, 10))
|
|
assert "logged 2/10 days" in text
|
|
assert "1 within budget" in text
|