diet-guard/diet_guard/tests/test_calendar_view.py
Krzysztof kuhy Rudnicki 84bd550331
Some checks are pending
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run
Migrate diet_guard to gatelock v0.2.0 per-output locking
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.
2026-07-25 16:56:13 +02:00

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