mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 17:47:24 +01:00
sickdays 0.3.0: desktop sick-day picker and embeddable KeepPanel
`sickdays gui` opens a Tk window modelled on the phone's Sick days screen: a Monday-first month calendar (cat-2 fill, a dot for kept lockers, past days faded and inert), a five-switch panel for the clicked day, and the "Marked sick" list with "Kept: ..." lines. A free day is refused with the phone's wording. Each write starts sync_quietly() on a non-daemon thread. The window class is "sickdays" and Escape closes it. sickdays.gui.KeepPanel is the panel on its own, a plain tk.Frame with no root, mainloop, grab or wm_* call, so screen-locker can embed it under its lock-screen grab. `import sickdays` and the rest of the CLI never import tkinter: the CLI loads the gui module by name only for `gui`, and a subprocess test checks this. The tests drive real Tk on a withdrawn root, and CI now runs them under xvfb-run, as gatelock's CI does. Adds a sickdays.desktop launcher, which install.sh installs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PAhbNszs5JKdtnD4uBi3xp
This commit is contained in:
parent
0a51c9998d
commit
bd64b27109
11
.github/workflows/sickdays-tests.yml
vendored
11
.github/workflows/sickdays-tests.yml
vendored
@ -38,7 +38,12 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pip install -r requirements.txt && pip install --no-deps -e .
|
||||
|
||||
# No display needed: sickdays draws nothing. It is policy, a JSON file
|
||||
# and a CLI.
|
||||
# The library draws nothing, but sickdays.gui (0.3.0) does, and its
|
||||
# tests drive real Tk: option names and Checkbutton/variable wiring are
|
||||
# Tk's own behaviour. Headless they skip, taking coverage with them --
|
||||
# so the runner gets a virtual display, as gatelock's does.
|
||||
- name: Install a virtual X display
|
||||
run: sudo apt-get update && sudo apt-get install -y xvfb
|
||||
|
||||
- name: Run tests with coverage
|
||||
run: python -m pytest -v
|
||||
run: xvfb-run -a -s "-screen 0 1600x1200x24" python -m pytest -v
|
||||
|
||||
@ -67,6 +67,26 @@ refused (past day, already sick, not sick, a free day, sync unavailable),
|
||||
`2` input not understood (a bad date, or a locker id not in `LOCKERS` --
|
||||
`--keep`, `keep` and `--locker` only accept the five ids).
|
||||
|
||||
## The picker window (0.3.0)
|
||||
|
||||
`sickdays gui` (or the "Sick days" launcher): the phone's Sick days screen on
|
||||
Tk -- calendar, a five-switch panel per day, the days ahead. Past days are
|
||||
inert; a free day is refused; each write starts `sync_quietly()` on a
|
||||
non-daemon thread. `WM_CLASS "sickdays", "Sickdays"`; `Escape` closes. Embed
|
||||
just the panel (`import sickdays` never imports tkinter):
|
||||
|
||||
```python
|
||||
from sickdays.gui import KeepPanel # a plain tk.Frame: no root, grab or wm_* call
|
||||
KeepPanel(parent, day, *, on_saved, on_cancel=None, paths=None).pack()
|
||||
```
|
||||
|
||||
Normal day: five switches, all off (off = stands down), "Mark sick"; sick
|
||||
day: its switches, "Save", "Clear sick day". Past/free/uncheckable days show
|
||||
why and offer no write; a refused write shows in the panel, never raised.
|
||||
`on_saved(SickDay | None)` fires after the write and sync start: the entry on
|
||||
Mark sick / a changed Save (`is_sick=True`) or Clear (`is_sick=False`), `None`
|
||||
on an unchanged Save (nothing written). The panel never destroys itself.
|
||||
|
||||
## API
|
||||
|
||||
| call | does |
|
||||
@ -216,7 +236,7 @@ Don't hand-roll this on Dart: use `crdt_sync_dart` (`syncLog`, `logToJson`
|
||||
## Install
|
||||
|
||||
```console
|
||||
$ ./install.sh # own venv (crdt-sync + freedays + sickdays), CLI on PATH, 15-min sync timer
|
||||
$ ./install.sh # own venv (crdt-sync + freedays + sickdays), CLI on PATH, launcher, 15-min sync timer
|
||||
$ ./install.sh --no-timer
|
||||
```
|
||||
|
||||
@ -225,6 +245,6 @@ $ ./install.sh --no-timer
|
||||
```console
|
||||
$ pip install ../crdt-sync && pip install --no-deps ../freedays
|
||||
$ pip install -r requirements.txt && pip install --no-deps -e .
|
||||
$ python -m pytest # 100% branch coverage is enforced
|
||||
$ xvfb-run -a python -m pytest # 100% branch coverage; the GUI tests need a display
|
||||
$ pre-commit run --config sickdays/.pre-commit-config.yaml --all-files # from the repo root
|
||||
```
|
||||
|
||||
@ -20,6 +20,7 @@ readonly STATE_DIR="${HOME}/.local/share/sickdays"
|
||||
readonly VENV_DIR="${STATE_DIR}/venv"
|
||||
readonly BIN_DIR="${HOME}/.local/bin"
|
||||
readonly UNIT_DIR="${HOME}/.config/systemd/user"
|
||||
readonly APPS_DIR="${HOME}/.local/share/applications"
|
||||
readonly SYSTEM_PYTHON="/usr/bin/python3"
|
||||
|
||||
log() { printf 'install: %s\n' "$1" >&2; }
|
||||
@ -57,6 +58,16 @@ link_cli() {
|
||||
ln -sf "$VENV_DIR/bin/sickdays" "$BIN_DIR/sickdays"
|
||||
}
|
||||
|
||||
install_launcher() {
|
||||
# Exec is the bare command, so the launcher follows the $BIN_DIR symlink.
|
||||
log "installing the 'Sick days' launcher into $APPS_DIR"
|
||||
mkdir -p "$APPS_DIR"
|
||||
install -m 644 "$REPO_DIR/sickdays.desktop" "$APPS_DIR/"
|
||||
if command -v update-desktop-database >/dev/null; then
|
||||
update-desktop-database "$APPS_DIR" || log "NOTE: update-desktop-database failed"
|
||||
fi
|
||||
}
|
||||
|
||||
install_timer() {
|
||||
log "installing the sync timer"
|
||||
mkdir -p "$UNIT_DIR"
|
||||
@ -73,6 +84,8 @@ verify() {
|
||||
"$BIN_DIR/sickdays" list >/dev/null || fail "the sickdays CLI does not run"
|
||||
"$VENV_DIR/bin/python" -c "import freedays" \
|
||||
|| fail "freedays is missing from the venv; adding a sick day would refuse"
|
||||
"$VENV_DIR/bin/python" -c "import tkinter" \
|
||||
|| fail "the venv's python has no tkinter; 'sickdays gui' would not open"
|
||||
"$SYSTEM_PYTHON" -c "import sickdays" 2>/dev/null || {
|
||||
log "NOTE: the system python cannot import sickdays yet."
|
||||
log " The gate apps need it there; their own install.sh does that."
|
||||
@ -86,6 +99,7 @@ verify() {
|
||||
main() {
|
||||
build_venv
|
||||
link_cli
|
||||
install_launcher
|
||||
if [[ "$INSTALL_TIMER" -eq 1 ]]; then
|
||||
install_timer
|
||||
fi
|
||||
|
||||
@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "sickdays"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
description = "Shared sick-day pool: per-date, uncapped, synced; every gate respects it"
|
||||
requires-python = ">=3.14"
|
||||
# Deliberately NOT gatelock (its __init__ imports tkinter); the HMAC lives in
|
||||
|
||||
9
sickdays/sickdays.desktop
Normal file
9
sickdays/sickdays.desktop
Normal file
@ -0,0 +1,9 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Sick days
|
||||
Comment=Mark sick days and choose which lockers still apply
|
||||
Exec=sickdays gui
|
||||
Icon=face-sick
|
||||
Terminal=false
|
||||
Categories=Utility;
|
||||
StartupWMClass=sickdays
|
||||
@ -9,6 +9,10 @@ A sick day stands every locker down unless it *keeps* some (``--keep`` on
|
||||
``add``, or ``keep`` afterwards). Locker ids are argparse ``choices``, so a
|
||||
typo exits 2 instead of silently keeping nothing.
|
||||
|
||||
``gui`` opens the Tk picker. It is imported only when asked for, so every
|
||||
other subcommand -- and every gate importing the library -- stays free of
|
||||
tkinter.
|
||||
|
||||
``sync-quiet`` is what the timer runs: it reports success even with no
|
||||
network, because a unit that goes red whenever the laptop is offline is a
|
||||
unit you learn to ignore.
|
||||
@ -17,6 +21,7 @@ unit you learn to ignore.
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import importlib
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
@ -91,6 +96,7 @@ def _build_parser() -> argparse.ArgumentParser:
|
||||
metavar=_LOCKER_METAVAR,
|
||||
help="instead: exit 0 iff this locker stands down on the day, 1 if not",
|
||||
)
|
||||
sub.add_parser("gui", help="open the sick-day picker window")
|
||||
sub.add_parser("sync", help="pull and push the pool now")
|
||||
sub.add_parser(
|
||||
"sync-quiet",
|
||||
@ -143,6 +149,12 @@ def _do_check(raw_day: str, locker: str | None) -> int:
|
||||
return _EXIT_OK if sick else _EXIT_REFUSED
|
||||
|
||||
|
||||
def _do_gui() -> int:
|
||||
# By name, at call time: the library and the rest of the CLI never load Tk.
|
||||
gui = importlib.import_module("sickdays.gui")
|
||||
return int(gui.run())
|
||||
|
||||
|
||||
def _do_sync() -> int:
|
||||
sync()
|
||||
emit("pool synced")
|
||||
@ -160,6 +172,7 @@ _HANDLERS: dict[str, Callable[[argparse.Namespace], int]] = {
|
||||
"remove": lambda args: _do_remove(args.day),
|
||||
"list": lambda _args: _do_list(),
|
||||
"check": lambda args: _do_check(args.day, args.locker),
|
||||
"gui": lambda _args: _do_gui(),
|
||||
"sync": lambda _args: _do_sync(),
|
||||
"sync-quiet": lambda _args: _do_sync_quiet(),
|
||||
}
|
||||
|
||||
24
sickdays/sickdays/gui/__init__.py
Normal file
24
sickdays/sickdays/gui/__init__.py
Normal file
@ -0,0 +1,24 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""The Tk sick-day picker, and the switch panel other apps embed.
|
||||
|
||||
Kept out of :mod:`sickdays` on purpose: every headless gate imports
|
||||
``sickdays`` once a minute, and none of them may pull in tkinter. Import
|
||||
this module only from the ``sickdays gui`` command or an app that already
|
||||
draws with Tk::
|
||||
|
||||
from sickdays.gui import KeepPanel
|
||||
|
||||
panel = KeepPanel(surface, today, on_saved=lambda entry: panel.destroy())
|
||||
panel.pack()
|
||||
|
||||
:class:`KeepPanel` is a plain ``tk.Frame`` (no root, no mainloop, no grab),
|
||||
so it works in-process under another window's X grab. See its docstring for
|
||||
what ``on_saved`` receives. :func:`run` opens the whole window.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sickdays.gui._panel import KeepPanel
|
||||
from sickdays.gui._window import SickDaysApp, run
|
||||
|
||||
__all__ = ["KeepPanel", "SickDaysApp", "run"]
|
||||
130
sickdays/sickdays/gui/_calendar.py
Normal file
130
sickdays/sickdays/gui/_calendar.py
Normal file
@ -0,0 +1,130 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""The month grid: Monday-first, one button per day, prev/next month arrows.
|
||||
|
||||
The phone's ``DayCalendar``/``DayCell``: a sick day is filled ``cat-2``, a
|
||||
sick day that keeps a locker carries a dot, Tue-Thu are shaded, and past
|
||||
days keep their mark but faded and inert. Today stays clickable -- a sick
|
||||
day can be marked or cleared for today. Deciding what a click *means* stays
|
||||
with the window; this only reports the date.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tkinter as tk
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sickdays._day import to_iso
|
||||
from sickdays.gui import _text
|
||||
from sickdays.gui import _tokens as tok
|
||||
from sickdays.gui import _widgets as ui
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
from datetime import date
|
||||
|
||||
_PREV = "\N{SINGLE LEFT-POINTING ANGLE QUOTATION MARK}"
|
||||
_NEXT = "\N{SINGLE RIGHT-POINTING ANGLE QUOTATION MARK}"
|
||||
|
||||
|
||||
def _cell_colours(*, sick: bool, past: bool, workday: bool) -> tuple[str, str]:
|
||||
"""(fill, text) for one day, faded when past, as ``DayCell`` draws it."""
|
||||
if sick:
|
||||
fill = tok.blend(tok.SICK, tok.INK, tok.PAST_ALPHA) if past else tok.SICK
|
||||
return fill, tok.ON_FILL
|
||||
fill = tok.INK_RAISED_1 if workday else tok.INK
|
||||
text = (
|
||||
tok.blend(tok.TEXT_ON_DARK, fill, tok.PAST_ALPHA) if past else tok.TEXT_ON_DARK
|
||||
)
|
||||
return fill, text
|
||||
|
||||
|
||||
class MonthCalendar(tk.Frame):
|
||||
"""One month of one pool; :meth:`redraw` repaints it from the callbacks."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: tk.Misc,
|
||||
*,
|
||||
today: date,
|
||||
is_sick: Callable[[date], bool],
|
||||
has_dot: Callable[[date], bool],
|
||||
on_day: Callable[[date], None],
|
||||
) -> None:
|
||||
"""Show the month holding ``today``."""
|
||||
super().__init__(parent, bg=parent.cget("bg"))
|
||||
self._today = today
|
||||
self._is_sick = is_sick
|
||||
self._has_dot = has_dot
|
||||
self._on_day = on_day
|
||||
self._first, self._last = _text.month_bounds(today)
|
||||
self.month = today.replace(day=1)
|
||||
self._header = tk.Frame(self, name="header", bg=self.cget("bg"))
|
||||
self._header.pack(fill="x")
|
||||
self._grid = tk.Frame(self, name="days", bg=self.cget("bg"))
|
||||
self._grid.pack(pady=(tok.XS, 0))
|
||||
self.redraw()
|
||||
|
||||
def step(self, months: int) -> None:
|
||||
"""Move ``months`` months (within the phone's range) and redraw."""
|
||||
target = _text.shift_month(self.month, months)
|
||||
if self._first <= target <= self._last:
|
||||
self.month = target
|
||||
self.redraw()
|
||||
|
||||
def redraw(self) -> None:
|
||||
"""Rebuild the header and every day from the current pool."""
|
||||
for frame in (self._header, self._grid):
|
||||
for child in frame.winfo_children():
|
||||
child.destroy()
|
||||
self._draw_header()
|
||||
for column, initial in enumerate(_text.WEEKDAY_INITIALS):
|
||||
ui.label(self._grid, initial, "caption", fg=tok.MUTED_ON_DARK).grid(
|
||||
row=0, column=column
|
||||
)
|
||||
for row, week in enumerate(_text.month_weeks(self.month), start=1):
|
||||
for column, day in enumerate(week):
|
||||
if day is not None:
|
||||
self._cell(day).grid(
|
||||
row=row, column=column, padx=tok.XS // 2, pady=tok.XS // 2
|
||||
)
|
||||
|
||||
def _draw_header(self) -> None:
|
||||
back = ui.button(self._header, _PREV, lambda: self.step(-1), name="prev")
|
||||
forward = ui.button(self._header, _NEXT, lambda: self.step(1), name="next")
|
||||
back.configure(state="normal" if self.month > self._first else "disabled")
|
||||
forward.configure(state="normal" if self.month < self._last else "disabled")
|
||||
back.pack(side="left")
|
||||
forward.pack(side="right")
|
||||
ui.label(self._header, _text.month_title(self.month), "subtitle").pack(
|
||||
side="top", pady=tok.XS
|
||||
)
|
||||
|
||||
def _cell(self, day: date) -> tk.Button:
|
||||
sick = self._is_sick(day)
|
||||
past = day < self._today
|
||||
fill, text = _cell_colours(sick=sick, past=past, workday=_text.is_workday(day))
|
||||
cell = tk.Button(
|
||||
self._grid,
|
||||
name=f"day-{to_iso(day)}",
|
||||
text=str(day.day),
|
||||
command=lambda: self._on_day(day),
|
||||
state="disabled" if past else "normal",
|
||||
bg=fill,
|
||||
fg=text,
|
||||
activebackground=fill,
|
||||
activeforeground=text,
|
||||
disabledforeground=text,
|
||||
font=tok.font(ui.family(self), "body", bold=sick),
|
||||
width=3,
|
||||
relief="flat",
|
||||
borderwidth=0,
|
||||
highlightthickness=0,
|
||||
padx=tok.SM,
|
||||
pady=tok.SM,
|
||||
cursor="arrow" if past else "hand2",
|
||||
)
|
||||
if sick and self._has_dot(day):
|
||||
dot = tk.Label(cell, name="dot", text="●", fg=text, bg=fill)
|
||||
dot.configure(font=tok.font(ui.family(self), "caption"), padx=0, pady=0)
|
||||
dot.place(relx=1.0, x=-2, y=0, anchor="ne")
|
||||
return cell
|
||||
181
sickdays/sickdays/gui/_panel.py
Normal file
181
sickdays/sickdays/gui/_panel.py
Normal file
@ -0,0 +1,181 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""KeepPanel: the five switches for one day, and the buttons that write them.
|
||||
|
||||
The embeddable half of the picker. It is a plain ``tk.Frame``: no root, no
|
||||
mainloop, no ``bind_all``, no grab, no focus stealing and no ``wm_*`` call,
|
||||
so screen-locker can drop it onto its fullscreen surface in-process, under
|
||||
its own X grab (a separate window could not get input there).
|
||||
|
||||
On is "this locker still applies", off is "stands down", all off by default
|
||||
-- the phone's ``SickDaySheet``. A normal day offers "Mark sick"; a sick day
|
||||
offers "Save" (a keep-only edit) and "Clear sick day". A past day, a free
|
||||
day, or a free-day pool that cannot be checked shows why and offers no
|
||||
write. A refused write is shown in the panel, never raised: an exception in a
|
||||
Tk callback only reaches stderr, which nobody sees under a lock.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import tkinter as tk
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sickdays._api import add_sick_day, is_sick_day, remove_sick_day
|
||||
from sickdays._constants import LOCKERS
|
||||
from sickdays._day import today
|
||||
from sickdays._errors import SickDayError
|
||||
from sickdays._keep_api import kept_lockers, set_kept_lockers
|
||||
from sickdays._paths import resolve_paths
|
||||
from sickdays._sync import sync_quietly
|
||||
from sickdays.gui import _text
|
||||
from sickdays.gui import _tokens as tok
|
||||
from sickdays.gui import _widgets as ui
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
from datetime import date
|
||||
|
||||
from sickdays._model import SickDay
|
||||
from sickdays._paths import Paths
|
||||
|
||||
|
||||
def start_sync(paths: Paths) -> threading.Thread:
|
||||
"""Push the write in the background so the phone sees it; never raises.
|
||||
|
||||
Not a daemon: a window closed right after "Mark sick" must not kill the
|
||||
push half-way. The thread touches no widget.
|
||||
"""
|
||||
thread = threading.Thread(
|
||||
target=sync_quietly,
|
||||
kwargs={"paths": paths},
|
||||
name="sickdays-sync",
|
||||
daemon=False,
|
||||
)
|
||||
thread.start()
|
||||
return thread
|
||||
|
||||
|
||||
class KeepPanel(tk.Frame):
|
||||
"""Mark ``day`` sick with some lockers kept, or edit / clear a sick day.
|
||||
|
||||
``on_saved`` is called once per successful click, after the write and
|
||||
after the background sync has started:
|
||||
|
||||
* "Mark sick" -> the new :class:`~sickdays.SickDay` (``is_sick=True``);
|
||||
* "Save" with a changed set -> the stored entry (``is_sick=True``);
|
||||
* "Save" with nothing changed -> ``None``: nothing written, no sync;
|
||||
* "Clear sick day" -> the cleared entry (``is_sick=False``).
|
||||
|
||||
A refused write (:class:`~sickdays.SickDayError`) is shown in the panel
|
||||
and ``on_saved`` is not called. ``on_cancel``, when given, adds a
|
||||
"Cancel" button that calls it. The panel never destroys itself; the
|
||||
callbacks decide what happens to it.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: tk.Misc,
|
||||
day: date,
|
||||
*,
|
||||
on_saved: Callable[[SickDay | None], None],
|
||||
on_cancel: Callable[[], None] | None = None,
|
||||
paths: Paths | None = None,
|
||||
) -> None:
|
||||
"""Read ``day``'s current state from the pool and draw the panel."""
|
||||
super().__init__(parent, bg=tok.INK_RAISED_1, padx=tok.MD, pady=tok.MD)
|
||||
self._day = day
|
||||
self._paths = resolve_paths(paths)
|
||||
self._on_saved = on_saved
|
||||
self._sick = is_sick_day(day, log_path=self._paths.log)
|
||||
self._kept = kept_lockers(day, log_path=self._paths.log)
|
||||
self._vars = {
|
||||
locker: tk.BooleanVar(self, value=locker in self._kept)
|
||||
for locker in LOCKERS
|
||||
}
|
||||
#: The last background sync, for callers (and tests) that wait on it.
|
||||
self.sync_thread: threading.Thread | None = None
|
||||
blocked = self._blocked_reason()
|
||||
self._draw_switches(editable=blocked is None)
|
||||
self._status = ui.label(
|
||||
self, blocked or "", "label", fg=tok.DANGER, name="status"
|
||||
)
|
||||
self._status.grid(row=len(LOCKERS) + 2, column=0, columnspan=2, sticky="w")
|
||||
self._draw_actions(writable=blocked is None, on_cancel=on_cancel)
|
||||
|
||||
def kept(self) -> frozenset[str]:
|
||||
"""The lockers currently switched on."""
|
||||
return frozenset(locker for locker, var in self._vars.items() if var.get())
|
||||
|
||||
def _blocked_reason(self) -> str | None:
|
||||
if self._day < today():
|
||||
return _text.PAST_NOTE
|
||||
return None if self._sick else _text.free_day_refusal(self._day)
|
||||
|
||||
def _draw_switches(self, *, editable: bool) -> None:
|
||||
title = _text.panel_title(self._day, sick=self._sick)
|
||||
ui.label(self, title, "subtitle", name="title").grid(
|
||||
row=0, column=0, columnspan=2, sticky="w"
|
||||
)
|
||||
ui.label(self, _text.SHEET_HELP, "caption", fg=tok.MUTED_ON_DARK).grid(
|
||||
row=1, column=0, columnspan=2, sticky="w", pady=(tok.XS, tok.SM)
|
||||
)
|
||||
for row, locker in enumerate(LOCKERS, start=2):
|
||||
ui.label(self, _text.LOCKER_LABELS[locker], "body").grid(
|
||||
row=row, column=0, sticky="w", pady=tok.XS
|
||||
)
|
||||
switch = ui.toggle(self, self._vars[locker], name=f"keep-{locker}")
|
||||
switch.grid(row=row, column=1, sticky="e", padx=(tok.LG, 0), pady=tok.XS)
|
||||
if not editable:
|
||||
switch.configure(state="disabled")
|
||||
|
||||
def _draw_actions(
|
||||
self, *, writable: bool, on_cancel: Callable[[], None] | None
|
||||
) -> None:
|
||||
actions = tk.Frame(self, name="actions", bg=self.cget("bg"))
|
||||
actions.grid(
|
||||
row=len(LOCKERS) + 3, column=0, columnspan=2, sticky="we", pady=(tok.MD, 0)
|
||||
)
|
||||
buttons: list[tk.Button] = []
|
||||
if writable and self._sick:
|
||||
buttons.append(
|
||||
ui.button(actions, "Save", self._save, kind="primary", name="save")
|
||||
)
|
||||
buttons.append(
|
||||
ui.button(
|
||||
actions, "Clear sick day", self._clear, kind="danger", name="clear"
|
||||
)
|
||||
)
|
||||
elif writable:
|
||||
buttons.append(
|
||||
ui.button(actions, "Mark sick", self._mark, kind="primary", name="mark")
|
||||
)
|
||||
if on_cancel is not None:
|
||||
buttons.append(ui.button(actions, "Cancel", on_cancel, name="cancel"))
|
||||
for button in buttons:
|
||||
button.pack(side="left", padx=(0, tok.SM))
|
||||
|
||||
def _mark(self) -> None:
|
||||
self._write(
|
||||
lambda: add_sick_day(self._day, keep=self.kept(), paths=self._paths)
|
||||
)
|
||||
|
||||
def _save(self) -> None:
|
||||
keep = self.kept()
|
||||
if keep == self._kept:
|
||||
# A fresh tick on an unchanged set would only churn the sync.
|
||||
self._on_saved(None)
|
||||
return
|
||||
self._write(lambda: set_kept_lockers(self._day, keep, paths=self._paths))
|
||||
|
||||
def _clear(self) -> None:
|
||||
self._write(lambda: remove_sick_day(self._day, paths=self._paths))
|
||||
|
||||
def _write(self, action: Callable[[], SickDay]) -> None:
|
||||
try:
|
||||
entry = action()
|
||||
except SickDayError as exc:
|
||||
self._status.configure(text=str(exc), fg=tok.DANGER)
|
||||
return
|
||||
self.sync_thread = start_sync(self._paths)
|
||||
# Last: the callback may destroy this panel.
|
||||
self._on_saved(entry)
|
||||
122
sickdays/sickdays/gui/_text.py
Normal file
122
sickdays/sickdays/gui/_text.py
Normal file
@ -0,0 +1,122 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""Wording and calendar arithmetic for the picker -- no Tk in here.
|
||||
|
||||
Every string is the phone's (``wake-alarm/phone_app/lib/screens/
|
||||
sick_day_sheet.dart`` and ``sick_days_body.dart``), so the two pickers read
|
||||
the same. Kept here, apart from the widgets, so it is testable without a
|
||||
display.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import calendar
|
||||
from datetime import date
|
||||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
from sickdays._conflict import free_day_predicate
|
||||
from sickdays._constants import (
|
||||
LOCKER_ALARM,
|
||||
LOCKER_BOOK,
|
||||
LOCKER_DIET,
|
||||
LOCKER_LEETCODE,
|
||||
LOCKER_WORKOUT,
|
||||
)
|
||||
from sickdays._day import to_iso
|
||||
from sickdays._errors import ConflictCheckUnavailableError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterable
|
||||
|
||||
#: What each locker is called on screen, in ``sickdays.LOCKERS`` order.
|
||||
LOCKER_LABELS: Final = {
|
||||
LOCKER_WORKOUT: "Workout lock",
|
||||
LOCKER_LEETCODE: "LeetCode",
|
||||
LOCKER_BOOK: "Book",
|
||||
LOCKER_DIET: "Diet",
|
||||
LOCKER_ALARM: "Wake alarm",
|
||||
}
|
||||
|
||||
SHEET_HELP: Final = (
|
||||
"Off = stands down for the day. Switch on any locker that should still "
|
||||
"apply; it then behaves exactly as on a normal day."
|
||||
)
|
||||
INTRO: Final = (
|
||||
"Click a day to mark it sick and choose which lockers still apply; click "
|
||||
"a sick day to change them or clear it. Today or later only. No limit and "
|
||||
"no reason needed."
|
||||
)
|
||||
INTRO_DETAIL: Final = (
|
||||
"On a sick day every locker left off stands down: no alarm, no lock, and "
|
||||
"work left undone is dropped rather than carried forward. A locker "
|
||||
"switched on behaves as on a normal day; a dot marks a sick day that "
|
||||
"keeps one."
|
||||
)
|
||||
PAST_NOTE: Final = "This day has passed; it can no longer be changed."
|
||||
NONE_AHEAD: Final = "None ahead."
|
||||
WEEKDAY_INITIALS: Final = ("M", "T", "W", "T", "F", "S", "S")
|
||||
|
||||
_WEEKDAYS: Final = ("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")
|
||||
#: Tue/Wed/Thu, shaded like the phone's grid (wake-alarm's ``isWorkday``).
|
||||
_WORKDAYS: Final = frozenset({1, 2, 3})
|
||||
|
||||
|
||||
def day_label(day: date) -> str:
|
||||
"""``"Tue 2026-10-20"``."""
|
||||
return f"{_WEEKDAYS[day.weekday()]} {to_iso(day)}"
|
||||
|
||||
|
||||
def kept_label(kept: Iterable[str]) -> str:
|
||||
"""The kept lockers as one line, in :data:`LOCKER_LABELS` order."""
|
||||
chosen = set(kept)
|
||||
return ", ".join(label for key, label in LOCKER_LABELS.items() if key in chosen)
|
||||
|
||||
|
||||
def panel_title(day: date, *, sick: bool) -> str:
|
||||
"""``"Mark sick: Tue 2026-10-20"``, or ``"Sick day: ..."`` when editing."""
|
||||
return f"{'Sick day' if sick else 'Mark sick'}: {day_label(day)}"
|
||||
|
||||
|
||||
def free_day_refusal(day: date) -> str | None:
|
||||
"""Why ``day`` cannot be marked sick for the free-day pool, or None.
|
||||
|
||||
"Could not check" refuses too, with the library's own message: it must
|
||||
never count as "not a free day".
|
||||
"""
|
||||
try:
|
||||
is_free = free_day_predicate()
|
||||
except ConflictCheckUnavailableError as exc:
|
||||
return str(exc)
|
||||
if not is_free(day):
|
||||
return None
|
||||
return f"{to_iso(day)} is a free day — release it in Free days first."
|
||||
|
||||
|
||||
def is_workday(day: date) -> bool:
|
||||
"""Whether ``day`` is a weekly workday (Tue/Wed/Thu), shaded on the grid."""
|
||||
return day.weekday() in _WORKDAYS
|
||||
|
||||
|
||||
def month_title(month: date) -> str:
|
||||
"""``"October 2026"``."""
|
||||
return f"{calendar.month_name[month.month]} {month.year}"
|
||||
|
||||
|
||||
def month_weeks(month: date) -> list[list[date | None]]:
|
||||
"""The month as Monday-first weeks, ``None`` padding either end."""
|
||||
return [
|
||||
[None if not cell else date(month.year, month.month, cell) for cell in week]
|
||||
for week in calendar.Calendar(firstweekday=0).monthdayscalendar(
|
||||
month.year, month.month
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def shift_month(month: date, step: int) -> date:
|
||||
"""The first of the month ``step`` months after ``month``."""
|
||||
index = month.year * 12 + month.month - 1 + step
|
||||
return date(index // 12, index % 12 + 1, 1)
|
||||
|
||||
|
||||
def month_bounds(reference: date) -> tuple[date, date]:
|
||||
"""January this year to December next year, the phone's range."""
|
||||
return date(reference.year, 1, 1), date(reference.year + 1, 12, 1)
|
||||
70
sickdays/sickdays/gui/_tokens.py
Normal file
70
sickdays/sickdays/gui/_tokens.py
Normal file
@ -0,0 +1,70 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""Design tokens for the Tk picker, copied from the unified design system.
|
||||
|
||||
Source: ``~/src/utils/unified-design-system/DOCS-tokens.md`` -- the same
|
||||
values as daily-limits' ``_tokens.py`` and gatelock's ``LockPalette``.
|
||||
gatelock is not imported: its ``__init__`` pulls in the lock machinery, and
|
||||
this package must stay importable by every headless gate. The hexes are the
|
||||
frozen shared palette, so a copy cannot drift unnoticed.
|
||||
|
||||
A sick day is filled ``cat-2``, as on the phone (``DayMarkColors.sick``):
|
||||
the categorical ramp leaves the warm wedge, so a sick day can never be read
|
||||
as the free days' accent or as success/warning/danger.
|
||||
|
||||
Font sizes are **pixels**, so :func:`font` returns Tk's negative form (a
|
||||
positive size means points and renders about a third larger).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Final, Literal
|
||||
|
||||
# Neutrals (dark theme) + semantic roles.
|
||||
INK: Final = "#211D1B"
|
||||
INK_RAISED_1: Final = "#2B2624"
|
||||
INK_RAISED_2: Final = "#38312E"
|
||||
LINE_DARK: Final = "#463E3A"
|
||||
TEXT_ON_DARK: Final = "#ECEAE9"
|
||||
MUTED_ON_DARK: Final = "#AAA09A"
|
||||
ACCENT: Final = "#B8862E"
|
||||
SUCCESS: Final = "#8A9A3C"
|
||||
DANGER: Final = "#E2585F"
|
||||
ON_FILL: Final = INK
|
||||
#: ``cat-2``: a sick day's fill.
|
||||
SICK: Final = "#398FC0"
|
||||
|
||||
#: The phone fades a past day to 38 % opacity; Tk has no alpha, so blend.
|
||||
PAST_ALPHA: Final = 0.38
|
||||
|
||||
# Spacing scale (4px base).
|
||||
XS: Final = 4
|
||||
SM: Final = 8
|
||||
MD: Final = 16
|
||||
LG: Final = 24
|
||||
|
||||
#: Width, in px, that wrapped prose lines break at.
|
||||
WRAP: Final = 420
|
||||
|
||||
TypeRole = Literal["title", "subtitle", "body", "label", "caption"]
|
||||
_TYPE_PX: Final[dict[TypeRole, int]] = {
|
||||
"title": 24,
|
||||
"subtitle": 20,
|
||||
"body": 16,
|
||||
"label": 14,
|
||||
"caption": 12,
|
||||
}
|
||||
|
||||
|
||||
def font(family: str, role: TypeRole, *, bold: bool = False) -> tuple[str, int, str]:
|
||||
"""A Tk font tuple for ``role`` in ``family``, sized in pixels (negative)."""
|
||||
return (family, -_TYPE_PX[role], "bold" if bold else "normal")
|
||||
|
||||
|
||||
def blend(colour: str, onto: str, alpha: float) -> str:
|
||||
"""``colour`` at ``alpha`` opacity over ``onto``, as an opaque ``#RRGGBB``."""
|
||||
top = [int(colour[i : i + 2], 16) for i in (1, 3, 5)]
|
||||
under = [int(onto[i : i + 2], 16) for i in (1, 3, 5)]
|
||||
mixed = (
|
||||
round(t * alpha + u * (1 - alpha)) for t, u in zip(top, under, strict=True)
|
||||
)
|
||||
return "#" + "".join(f"{channel:02X}" for channel in mixed)
|
||||
125
sickdays/sickdays/gui/_widgets.py
Normal file
125
sickdays/sickdays/gui/_widgets.py
Normal file
@ -0,0 +1,125 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""Token-styled Tk building blocks shared by the panel and the window.
|
||||
|
||||
Every colour comes from :mod:`sickdays.gui._tokens`, and the text colour of a
|
||||
filled control is picked from its fill (``on-fill`` on the accent, never the
|
||||
near-white body text), the bug the design system's README warns about.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tkinter as tk
|
||||
from tkinter import font as tkfont
|
||||
from typing import TYPE_CHECKING, Final, Literal
|
||||
|
||||
from sickdays.gui import _tokens as tok
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
ButtonKind = Literal["primary", "danger", "plain"]
|
||||
_BOLD_ROLES: Final = frozenset({"title", "subtitle"})
|
||||
|
||||
#: (background, foreground) per button kind. Danger is a text button, as on
|
||||
#: the phone, so its red sits on the dark surface rather than under ink.
|
||||
_BUTTON_COLOURS: Final[dict[ButtonKind, tuple[str, str]]] = {
|
||||
"primary": (tok.ACCENT, tok.ON_FILL),
|
||||
"danger": (tok.INK_RAISED_2, tok.DANGER),
|
||||
"plain": (tok.INK_RAISED_2, tok.TEXT_ON_DARK),
|
||||
}
|
||||
|
||||
|
||||
def family(widget: tk.Misc) -> str:
|
||||
"""The family Tk's default font actually resolves to on this display."""
|
||||
return str(tkfont.nametofont("TkDefaultFont", root=widget).actual("family"))
|
||||
|
||||
|
||||
def label(
|
||||
parent: tk.Misc,
|
||||
text: str,
|
||||
role: tok.TypeRole,
|
||||
*,
|
||||
fg: str = tok.TEXT_ON_DARK,
|
||||
name: str = "",
|
||||
) -> tk.Label:
|
||||
"""A label on ``parent``'s own background; prose wraps at :data:`tok.WRAP`.
|
||||
|
||||
Titles and subtitles are bold, everything else regular.
|
||||
"""
|
||||
return tk.Label(
|
||||
parent,
|
||||
name=name,
|
||||
text=text,
|
||||
fg=fg,
|
||||
bg=parent.cget("bg"),
|
||||
font=tok.font(family(parent), role, bold=role in _BOLD_ROLES),
|
||||
justify="left",
|
||||
anchor="w",
|
||||
wraplength=tok.WRAP,
|
||||
)
|
||||
|
||||
|
||||
def button(
|
||||
parent: tk.Misc,
|
||||
text: str,
|
||||
command: Callable[[], object],
|
||||
*,
|
||||
kind: ButtonKind = "plain",
|
||||
name: str = "",
|
||||
) -> tk.Button:
|
||||
"""A flat button whose text colour follows its fill."""
|
||||
bg, fg = _BUTTON_COLOURS[kind]
|
||||
return tk.Button(
|
||||
parent,
|
||||
name=name,
|
||||
text=text,
|
||||
command=command,
|
||||
bg=bg,
|
||||
fg=fg,
|
||||
activebackground=bg,
|
||||
activeforeground=fg,
|
||||
font=tok.font(family(parent), "body", bold=kind == "primary"),
|
||||
relief="flat",
|
||||
borderwidth=0,
|
||||
highlightthickness=0,
|
||||
padx=tok.MD,
|
||||
pady=tok.SM,
|
||||
cursor="hand2",
|
||||
)
|
||||
|
||||
|
||||
def paint_toggle(switch: tk.Checkbutton, *, on: bool) -> None:
|
||||
"""Draw ``switch`` as a toggle: accent fill reading "On", or a dark "Off"."""
|
||||
bg, fg = (tok.ACCENT, tok.ON_FILL) if on else (tok.INK_RAISED_2, tok.TEXT_ON_DARK)
|
||||
switch.configure(
|
||||
text="On" if on else "Off",
|
||||
bg=bg,
|
||||
selectcolor=bg,
|
||||
activebackground=bg,
|
||||
fg=fg,
|
||||
activeforeground=fg,
|
||||
)
|
||||
|
||||
|
||||
def toggle(parent: tk.Misc, variable: tk.BooleanVar, *, name: str) -> tk.Checkbutton:
|
||||
"""A Checkbutton drawn as an On/Off switch, repainted on every change."""
|
||||
switch = tk.Checkbutton(
|
||||
parent,
|
||||
name=name,
|
||||
variable=variable,
|
||||
onvalue=True,
|
||||
offvalue=False,
|
||||
indicatoron=False,
|
||||
width=4,
|
||||
font=tok.font(family(parent), "label", bold=True),
|
||||
relief="flat",
|
||||
borderwidth=0,
|
||||
highlightthickness=0,
|
||||
padx=tok.SM,
|
||||
pady=tok.XS,
|
||||
disabledforeground=tok.MUTED_ON_DARK,
|
||||
cursor="hand2",
|
||||
)
|
||||
switch.configure(command=lambda: paint_toggle(switch, on=variable.get()))
|
||||
paint_toggle(switch, on=variable.get())
|
||||
return switch
|
||||
147
sickdays/sickdays/gui/_window.py
Normal file
147
sickdays/sickdays/gui/_window.py
Normal file
@ -0,0 +1,147 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""``sickdays gui``: the desktop picker, the phone's Sick days screen on Tk.
|
||||
|
||||
The calendar on the left, :class:`~sickdays.gui.KeepPanel` on the right for
|
||||
the clicked day, the sick days still ahead underneath. The window class is
|
||||
``sickdays`` (Tk reports ``WM_CLASS "sickdays", "Sickdays"``) so an i3
|
||||
``for_window`` rule can float it; ``Escape`` closes it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tkinter as tk
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sickdays._api import is_sick_day, load
|
||||
from sickdays._day import today
|
||||
from sickdays._keep_api import kept_lockers
|
||||
from sickdays._model import sick_entries
|
||||
from sickdays._paths import resolve_paths
|
||||
from sickdays.gui import _text
|
||||
from sickdays.gui import _tokens as tok
|
||||
from sickdays.gui import _widgets as ui
|
||||
from sickdays.gui._calendar import MonthCalendar
|
||||
from sickdays.gui._panel import KeepPanel
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datetime import date
|
||||
|
||||
from sickdays._model import SickDay
|
||||
from sickdays._paths import Paths
|
||||
|
||||
WINDOW_CLASS = "sickdays"
|
||||
|
||||
|
||||
def saved_message(entry: SickDay | None, day: date) -> str:
|
||||
"""The status line after the panel closed with ``entry``."""
|
||||
label = _text.day_label(day)
|
||||
if entry is None:
|
||||
return f"{label}: nothing changed"
|
||||
if not entry.is_sick:
|
||||
return f"{label} is a normal day again"
|
||||
kept = _text.kept_label(entry.keep)
|
||||
return f"{label} is a sick day — " + (
|
||||
f"kept: {kept}" if kept else "every locker stands down"
|
||||
)
|
||||
|
||||
|
||||
class SickDaysApp:
|
||||
"""The whole window, drawn onto ``root``."""
|
||||
|
||||
def __init__(self, root: tk.Tk, *, paths: Paths | None = None) -> None:
|
||||
"""Style ``root`` and draw the current pool."""
|
||||
self._root = root
|
||||
self._paths = resolve_paths(paths)
|
||||
root.title("Sick days")
|
||||
root.configure(bg=tok.INK, padx=tok.LG, pady=tok.LG)
|
||||
root.bind("<Escape>", self.close)
|
||||
left = tk.Frame(root, bg=tok.INK)
|
||||
left.grid(row=0, column=0, sticky="n")
|
||||
#: Where the clicked day's panel goes.
|
||||
self.host = tk.Frame(root, name="host", bg=tok.INK)
|
||||
self.host.grid(row=0, column=1, sticky="n", padx=(tok.LG, 0))
|
||||
ui.label(left, "Sick days", "title").pack(anchor="w")
|
||||
ui.label(left, _text.INTRO, "body").pack(anchor="w", pady=(tok.SM, 0))
|
||||
ui.label(left, _text.INTRO_DETAIL, "caption", fg=tok.MUTED_ON_DARK).pack(
|
||||
anchor="w", pady=(tok.SM, 0)
|
||||
)
|
||||
self.status = ui.label(left, "", "label", name="status")
|
||||
self.status.pack(anchor="w", pady=(tok.SM, 0))
|
||||
log = self._paths.log
|
||||
self.calendar = MonthCalendar(
|
||||
left,
|
||||
today=today(),
|
||||
is_sick=lambda day: is_sick_day(day, log_path=log),
|
||||
has_dot=lambda day: bool(kept_lockers(day, log_path=log)),
|
||||
on_day=self.select,
|
||||
)
|
||||
self.calendar.pack(anchor="w", pady=(tok.MD, 0))
|
||||
ui.label(left, "Marked sick", "subtitle").pack(
|
||||
anchor="w", pady=(tok.LG, tok.SM)
|
||||
)
|
||||
self._list = tk.Frame(left, name="upcoming", bg=tok.INK)
|
||||
self._list.pack(anchor="w", fill="x")
|
||||
self._draw_upcoming()
|
||||
|
||||
def close(self, _event: object = None) -> None:
|
||||
"""Close the window (bound to ``Escape``)."""
|
||||
self._root.destroy()
|
||||
|
||||
def select(self, day: date) -> None:
|
||||
"""Open the panel for ``day``, or refuse a free day in the status line."""
|
||||
self._close_panel()
|
||||
if not is_sick_day(day, log_path=self._paths.log):
|
||||
refusal = _text.free_day_refusal(day)
|
||||
if refusal is not None:
|
||||
self._set_status(refusal, failed=True)
|
||||
return
|
||||
self._set_status("", failed=False)
|
||||
KeepPanel(
|
||||
self.host,
|
||||
day,
|
||||
on_saved=lambda entry: self._saved(entry, day),
|
||||
on_cancel=self._close_panel,
|
||||
paths=self._paths,
|
||||
).pack(anchor="n")
|
||||
|
||||
def _saved(self, entry: SickDay | None, day: date) -> None:
|
||||
self._close_panel()
|
||||
self._set_status(saved_message(entry, day), failed=False)
|
||||
self.calendar.redraw()
|
||||
self._draw_upcoming()
|
||||
|
||||
def _close_panel(self) -> None:
|
||||
for child in self.host.winfo_children():
|
||||
child.destroy()
|
||||
|
||||
def _set_status(self, text: str, *, failed: bool) -> None:
|
||||
self.status.configure(text=text, fg=tok.DANGER if failed else tok.SUCCESS)
|
||||
|
||||
def _draw_upcoming(self) -> None:
|
||||
for child in self._list.winfo_children():
|
||||
child.destroy()
|
||||
reference = today()
|
||||
upcoming = [
|
||||
entry
|
||||
for entry in sick_entries(load(self._paths.log))
|
||||
if entry.day >= reference
|
||||
]
|
||||
if not upcoming:
|
||||
ui.label(self._list, _text.NONE_AHEAD, "body", fg=tok.MUTED_ON_DARK).pack(
|
||||
anchor="w"
|
||||
)
|
||||
for entry in upcoming:
|
||||
ui.label(self._list, _text.day_label(entry.day), "body").pack(anchor="w")
|
||||
kept = _text.kept_label(entry.keep)
|
||||
if kept:
|
||||
ui.label(
|
||||
self._list, f"Kept: {kept}", "label", fg=tok.MUTED_ON_DARK
|
||||
).pack(anchor="w", pady=(0, tok.XS))
|
||||
|
||||
|
||||
def run(*, paths: Paths | None = None) -> int:
|
||||
"""Open the window and block until it is closed."""
|
||||
root = tk.Tk(className=WINDOW_CLASS)
|
||||
SickDaysApp(root, paths=paths)
|
||||
root.mainloop()
|
||||
return 0
|
||||
@ -5,6 +5,9 @@ Every writer resolves its paths through :func:`sickdays._paths.resolve_paths`,
|
||||
so redirecting that one function in each module that imports it moves all of
|
||||
them, including any file added later.
|
||||
|
||||
The GUI modules are in the list too, and their background sync is stubbed:
|
||||
a test thread must never find the real crdt-sync credential.
|
||||
|
||||
The cross-pool check would otherwise import the real freedays and read the
|
||||
real ``~/.local/share/freedays`` pool, so it is stubbed to "nothing is free"
|
||||
for every test; tests of the check itself pass their own predicate.
|
||||
@ -19,8 +22,10 @@ import pytest
|
||||
from sickdays._paths import Paths
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
import tkinter as tk
|
||||
|
||||
_MODULES = (
|
||||
"sickdays._api",
|
||||
@ -28,6 +33,8 @@ _MODULES = (
|
||||
"sickdays._conflict",
|
||||
"sickdays._keep_api",
|
||||
"sickdays._sync",
|
||||
"sickdays.gui._panel",
|
||||
"sickdays.gui._window",
|
||||
)
|
||||
|
||||
|
||||
@ -35,6 +42,12 @@ def _never_free(_day: date) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _no_sync(*, paths: Paths | None = None) -> bool:
|
||||
"""The GUI's background sync, stubbed: no test may reach Firebase."""
|
||||
del paths
|
||||
return False
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _redirect_state(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
"""Point every default path at ``tmp_path`` before any test runs."""
|
||||
@ -48,9 +61,29 @@ def _redirect_state(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
for module in _MODULES:
|
||||
monkeypatch.setattr(f"{module}.resolve_paths", _resolve)
|
||||
monkeypatch.setattr("sickdays._api.free_day_predicate", lambda: _never_free)
|
||||
monkeypatch.setattr("sickdays.gui._text.free_day_predicate", lambda: _never_free)
|
||||
monkeypatch.setattr("sickdays.gui._panel.sync_quietly", _no_sync)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def state_dir(tmp_path: Path) -> Path:
|
||||
"""The redirected state directory, for tests that assert on files."""
|
||||
return tmp_path / "state"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tk_root() -> Iterator[tk.Tk]:
|
||||
"""A real, never-mapped Tk root; skips when there is no X display.
|
||||
|
||||
Real Tk rather than a fake: option names and Checkbutton/variable wiring
|
||||
are Tk's own behaviour. CI runs the suite under ``xvfb-run``.
|
||||
"""
|
||||
import tkinter as tk
|
||||
|
||||
try:
|
||||
root = tk.Tk(className="sickdays-test")
|
||||
except tk.TclError as exc:
|
||||
pytest.skip(f"no X display ({exc}); run the suite under xvfb-run")
|
||||
root.withdraw()
|
||||
yield root
|
||||
root.destroy()
|
||||
|
||||
@ -5,6 +5,9 @@ from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
import runpy
|
||||
import subprocess
|
||||
import sys
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
@ -168,3 +171,28 @@ def test_check_with_a_locker_on_a_normal_day_exits_one(
|
||||
) -> None:
|
||||
assert main(["check", "--locker", "alarm"]) == 1
|
||||
assert "alarm applies" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_gui_opens_the_window_through_a_lazy_import(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
opened: list[bool] = []
|
||||
fake = types.ModuleType("sickdays.gui")
|
||||
|
||||
def run() -> int:
|
||||
opened.append(True)
|
||||
return 0
|
||||
|
||||
fake.__dict__["run"] = run
|
||||
monkeypatch.setitem(sys.modules, "sickdays.gui", fake)
|
||||
assert main(["gui"]) == 0
|
||||
assert opened == [True]
|
||||
|
||||
|
||||
def test_importing_the_library_and_cli_never_imports_tkinter() -> None:
|
||||
probe = (
|
||||
"import sys, sickdays, sickdays._cli; "
|
||||
"sys.exit(1 if 'tkinter' in sys.modules else 0)"
|
||||
)
|
||||
done = subprocess.run([sys.executable, "-c", probe], check=False)
|
||||
assert done.returncode == 0
|
||||
|
||||
205
sickdays/sickdays/tests/test_gui_panel.py
Normal file
205
sickdays/sickdays/tests/test_gui_panel.py
Normal file
@ -0,0 +1,205 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""KeepPanel: the five switches for one day, against a real (unmapped) Tk."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
import tkinter as tk
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
|
||||
from sickdays._api import add_sick_day, is_sick_day
|
||||
from sickdays._day import today
|
||||
from sickdays._errors import ConflictCheckUnavailableError
|
||||
from sickdays._keep_api import kept_lockers
|
||||
from sickdays._paths import Paths
|
||||
from sickdays.gui import KeepPanel, _text
|
||||
from sickdays.gui import _tokens as tok
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
from sickdays._model import SickDay
|
||||
|
||||
FUTURE = today() + timedelta(days=3)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def synced(monkeypatch: pytest.MonkeyPatch) -> list[Paths | None]:
|
||||
"""Record every background sync the panel starts."""
|
||||
calls: list[Paths | None] = []
|
||||
|
||||
def record(*, paths: Paths | None = None) -> bool:
|
||||
calls.append(paths)
|
||||
return True
|
||||
|
||||
monkeypatch.setattr("sickdays.gui._panel.sync_quietly", record)
|
||||
return calls
|
||||
|
||||
|
||||
def _button(panel: KeepPanel, name: str) -> tk.Button:
|
||||
widget = panel.nametowidget(f"actions.{name}")
|
||||
assert isinstance(widget, tk.Button)
|
||||
return widget
|
||||
|
||||
|
||||
def _toggle(panel: KeepPanel, locker: str) -> tk.Checkbutton:
|
||||
widget = panel.nametowidget(f"keep-{locker}")
|
||||
assert isinstance(widget, tk.Checkbutton)
|
||||
return widget
|
||||
|
||||
|
||||
def _status(panel: KeepPanel) -> str:
|
||||
return str(panel.nametowidget("status").cget("text"))
|
||||
|
||||
|
||||
def _actions(panel: KeepPanel) -> set[str]:
|
||||
actions = panel.nametowidget("actions")
|
||||
return {child.winfo_name() for child in actions.winfo_children()}
|
||||
|
||||
|
||||
def _join(panel: KeepPanel) -> None:
|
||||
assert panel.sync_thread is not None
|
||||
panel.sync_thread.join(timeout=5)
|
||||
|
||||
|
||||
def test_mark_sick_keeping_leetcode(tk_root: tk.Tk, synced: list[Paths | None]) -> None:
|
||||
saved: list[SickDay | None] = []
|
||||
panel = KeepPanel(tk_root, FUTURE, on_saved=saved.append)
|
||||
assert panel.kept() == frozenset()
|
||||
assert _actions(panel) == {"mark"}
|
||||
toggle = _toggle(panel, "leetcode")
|
||||
assert toggle.cget("text") == "Off"
|
||||
toggle.invoke()
|
||||
assert toggle.cget("text") == "On"
|
||||
assert str(toggle.cget("bg")).upper() == tok.ACCENT
|
||||
assert str(toggle.cget("fg")).upper() == tok.ON_FILL
|
||||
_button(panel, "mark").invoke()
|
||||
_join(panel)
|
||||
assert is_sick_day(FUTURE)
|
||||
assert kept_lockers(FUTURE) == frozenset({"leetcode"})
|
||||
assert len(saved) == 1
|
||||
assert saved[0] is not None
|
||||
assert saved[0].is_sick
|
||||
assert saved[0].keep == frozenset({"leetcode"})
|
||||
assert len(synced) == 1
|
||||
|
||||
|
||||
def test_an_existing_sick_day_opens_with_its_switches(tk_root: tk.Tk) -> None:
|
||||
add_sick_day(FUTURE, keep=["workout"])
|
||||
panel = KeepPanel(tk_root, FUTURE, on_saved=lambda _entry: None)
|
||||
assert panel.kept() == frozenset({"workout"})
|
||||
assert _actions(panel) == {"save", "clear"}
|
||||
title = panel.nametowidget("title").cget("text")
|
||||
assert title == f"Sick day: {_text.day_label(FUTURE)}"
|
||||
|
||||
|
||||
def test_save_replaces_the_kept_set(tk_root: tk.Tk, synced: list[Paths | None]) -> None:
|
||||
add_sick_day(FUTURE, keep=["workout"])
|
||||
saved: list[SickDay | None] = []
|
||||
panel = KeepPanel(tk_root, FUTURE, on_saved=saved.append)
|
||||
_toggle(panel, "workout").invoke()
|
||||
_toggle(panel, "diet").invoke()
|
||||
_button(panel, "save").invoke()
|
||||
_join(panel)
|
||||
assert kept_lockers(FUTURE) == frozenset({"diet"})
|
||||
assert saved[0] is not None
|
||||
assert saved[0].keep == frozenset({"diet"})
|
||||
assert len(synced) == 1
|
||||
|
||||
|
||||
def test_an_unchanged_save_writes_nothing(
|
||||
tk_root: tk.Tk, synced: list[Paths | None], state_dir: Path
|
||||
) -> None:
|
||||
add_sick_day(FUTURE, keep=["book"])
|
||||
before = (state_dir / "audit.jsonl").read_text(encoding="utf-8")
|
||||
saved: list[SickDay | None] = []
|
||||
panel = KeepPanel(tk_root, FUTURE, on_saved=saved.append)
|
||||
_button(panel, "save").invoke()
|
||||
assert saved == [None]
|
||||
assert panel.sync_thread is None
|
||||
assert synced == []
|
||||
assert (state_dir / "audit.jsonl").read_text(encoding="utf-8") == before
|
||||
|
||||
|
||||
def test_clear_makes_the_day_normal(tk_root: tk.Tk, synced: list[Paths | None]) -> None:
|
||||
add_sick_day(FUTURE, keep=["alarm"])
|
||||
saved: list[SickDay | None] = []
|
||||
panel = KeepPanel(tk_root, FUTURE, on_saved=saved.append)
|
||||
_button(panel, "clear").invoke()
|
||||
_join(panel)
|
||||
assert not is_sick_day(FUTURE)
|
||||
assert saved[0] is not None
|
||||
assert not saved[0].is_sick
|
||||
assert len(synced) == 1
|
||||
|
||||
|
||||
def test_a_past_day_is_shown_but_not_editable(tk_root: tk.Tk) -> None:
|
||||
panel = KeepPanel(tk_root, today() - timedelta(days=1), on_saved=lambda _e: None)
|
||||
assert _status(panel) == _text.PAST_NOTE
|
||||
assert _actions(panel) == set()
|
||||
assert str(_toggle(panel, "diet").cget("state")) == "disabled"
|
||||
|
||||
|
||||
def test_a_free_day_is_refused_with_the_phones_message(
|
||||
tk_root: tk.Tk, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
"sickdays.gui._text.free_day_predicate", lambda: lambda _d: True
|
||||
)
|
||||
panel = KeepPanel(tk_root, FUTURE, on_saved=lambda _e: None)
|
||||
expected = "is a free day — release it in Free days first."
|
||||
assert _status(panel).endswith(expected)
|
||||
assert _actions(panel) == set()
|
||||
|
||||
|
||||
def test_an_uncheckable_free_pool_refuses(
|
||||
tk_root: tk.Tk, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
def unavailable() -> None:
|
||||
msg = "cannot check the free-day pool"
|
||||
raise ConflictCheckUnavailableError(msg)
|
||||
|
||||
monkeypatch.setattr("sickdays.gui._text.free_day_predicate", unavailable)
|
||||
panel = KeepPanel(tk_root, FUTURE, on_saved=lambda _e: None)
|
||||
assert _status(panel) == "cannot check the free-day pool"
|
||||
assert _actions(panel) == set()
|
||||
|
||||
|
||||
def test_a_refused_write_is_shown_not_raised(
|
||||
tk_root: tk.Tk, synced: list[Paths | None]
|
||||
) -> None:
|
||||
saved: list[SickDay | None] = []
|
||||
panel = KeepPanel(tk_root, FUTURE, on_saved=saved.append)
|
||||
add_sick_day(FUTURE) # marked elsewhere while the panel was open
|
||||
_button(panel, "mark").invoke()
|
||||
assert "already a sick day" in _status(panel)
|
||||
assert saved == []
|
||||
assert synced == []
|
||||
|
||||
|
||||
def test_cancel_calls_back(tk_root: tk.Tk) -> None:
|
||||
cancelled: list[bool] = []
|
||||
panel = KeepPanel(
|
||||
tk_root,
|
||||
FUTURE,
|
||||
on_saved=lambda _e: None,
|
||||
on_cancel=lambda: cancelled.append(True),
|
||||
)
|
||||
_button(panel, "cancel").invoke()
|
||||
assert cancelled == [True]
|
||||
|
||||
|
||||
def test_explicit_paths_are_used_for_every_read_write_and_sync(
|
||||
tk_root: tk.Tk, synced: list[Paths | None], tmp_path: Path
|
||||
) -> None:
|
||||
other = Paths.under(tmp_path / "other")
|
||||
add_sick_day(FUTURE, keep=["book"], paths=other)
|
||||
panel = KeepPanel(tk_root, FUTURE, on_saved=lambda _e: None, paths=other)
|
||||
assert panel.kept() == frozenset({"book"})
|
||||
_button(panel, "clear").invoke()
|
||||
_join(panel)
|
||||
assert not is_sick_day(FUTURE, log_path=other.log)
|
||||
assert synced == [other]
|
||||
assert not (tmp_path / "state" / "sick_days.json").exists()
|
||||
85
sickdays/sickdays/tests/test_gui_text.py
Normal file
85
sickdays/sickdays/tests/test_gui_text.py
Normal file
@ -0,0 +1,85 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""The picker's wording, calendar arithmetic and tokens -- no display needed."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sickdays._errors import ConflictCheckUnavailableError
|
||||
from sickdays.gui import _text
|
||||
from sickdays.gui import _tokens as tok
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import pytest
|
||||
|
||||
DAY = date(2026, 10, 20)
|
||||
|
||||
|
||||
def test_labels_match_the_phone() -> None:
|
||||
assert _text.day_label(DAY) == "Tue 2026-10-20"
|
||||
assert _text.panel_title(DAY, sick=False) == "Mark sick: Tue 2026-10-20"
|
||||
assert _text.panel_title(DAY, sick=True) == "Sick day: Tue 2026-10-20"
|
||||
assert list(_text.LOCKER_LABELS.values()) == [
|
||||
"Workout lock",
|
||||
"LeetCode",
|
||||
"Book",
|
||||
"Diet",
|
||||
"Wake alarm",
|
||||
]
|
||||
|
||||
|
||||
def test_kept_label_follows_locker_order_not_the_alphabet() -> None:
|
||||
assert _text.kept_label({"alarm", "book", "workout"}) == (
|
||||
"Workout lock, Book, Wake alarm"
|
||||
)
|
||||
assert _text.kept_label(frozenset()) == ""
|
||||
|
||||
|
||||
def test_free_day_refusal(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
assert _text.free_day_refusal(DAY) is None
|
||||
monkeypatch.setattr(
|
||||
"sickdays.gui._text.free_day_predicate", lambda: lambda _d: True
|
||||
)
|
||||
assert _text.free_day_refusal(DAY) == (
|
||||
"2026-10-20 is a free day — release it in Free days first."
|
||||
)
|
||||
|
||||
def unavailable() -> None:
|
||||
msg = "freedays is not installed"
|
||||
raise ConflictCheckUnavailableError(msg)
|
||||
|
||||
monkeypatch.setattr("sickdays.gui._text.free_day_predicate", unavailable)
|
||||
assert _text.free_day_refusal(DAY) == "freedays is not installed"
|
||||
|
||||
|
||||
def test_month_grid_is_monday_first() -> None:
|
||||
weeks = _text.month_weeks(date(2026, 10, 1))
|
||||
assert weeks[0] == [None, None, None, *(date(2026, 10, d) for d in (1, 2, 3, 4))]
|
||||
assert weeks[-1][-1] is None
|
||||
assert _text.month_title(date(2026, 10, 1)) == "October 2026"
|
||||
|
||||
|
||||
def test_month_arithmetic_and_range() -> None:
|
||||
assert _text.shift_month(date(2026, 12, 1), 1) == date(2027, 1, 1)
|
||||
assert _text.shift_month(date(2026, 1, 1), -1) == date(2025, 12, 1)
|
||||
assert _text.month_bounds(DAY) == (date(2026, 1, 1), date(2027, 12, 1))
|
||||
|
||||
|
||||
def test_workdays_are_tuesday_to_thursday() -> None:
|
||||
assert [_text.is_workday(date(2026, 10, d)) for d in range(19, 26)] == [
|
||||
False,
|
||||
True,
|
||||
True,
|
||||
True,
|
||||
False,
|
||||
False,
|
||||
False,
|
||||
]
|
||||
|
||||
|
||||
def test_tokens() -> None:
|
||||
assert tok.font("Fake Sans", "body") == ("Fake Sans", -16, "normal")
|
||||
assert tok.font("Fake Sans", "title", bold=True) == ("Fake Sans", -24, "bold")
|
||||
assert tok.blend("#FFFFFF", "#000000", 0.5) == "#808080"
|
||||
assert tok.blend(tok.SICK, tok.INK, 1.0) == tok.SICK
|
||||
167
sickdays/sickdays/tests/test_gui_window.py
Normal file
167
sickdays/sickdays/tests/test_gui_window.py
Normal file
@ -0,0 +1,167 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""The picker window and its month grid, against a real (unmapped) Tk."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, timedelta
|
||||
import tkinter as tk
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sickdays._api import add_sick_day
|
||||
from sickdays._day import to_iso, today
|
||||
from sickdays._model import SickDay
|
||||
from sickdays.gui import KeepPanel, SickDaysApp, _text, _window
|
||||
from sickdays.gui import _tokens as tok
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import pytest
|
||||
|
||||
FUTURE = today() + timedelta(days=3)
|
||||
|
||||
|
||||
def _texts(widget: tk.Misc) -> list[str]:
|
||||
found: list[str] = []
|
||||
for child in widget.winfo_children():
|
||||
if isinstance(child, tk.Label):
|
||||
found.append(str(child.cget("text")))
|
||||
found.extend(_texts(child))
|
||||
return found
|
||||
|
||||
|
||||
def _cell(app: SickDaysApp, day: date) -> tk.Button:
|
||||
widget = app.calendar.nametowidget(f"days.day-{to_iso(day)}")
|
||||
assert isinstance(widget, tk.Button)
|
||||
return widget
|
||||
|
||||
|
||||
def _panel(app: SickDaysApp) -> KeepPanel:
|
||||
(panel,) = app.host.winfo_children()
|
||||
assert isinstance(panel, KeepPanel)
|
||||
return panel
|
||||
|
||||
|
||||
def test_an_empty_pool_says_none_ahead(tk_root: tk.Tk) -> None:
|
||||
app = SickDaysApp(tk_root)
|
||||
assert tk_root.title() == "Sick days"
|
||||
assert _text.NONE_AHEAD in _texts(tk_root)
|
||||
assert app.host.winfo_children() == []
|
||||
|
||||
|
||||
def test_mark_a_day_from_the_calendar(tk_root: tk.Tk) -> None:
|
||||
app = SickDaysApp(tk_root)
|
||||
app.calendar.month = FUTURE.replace(day=1)
|
||||
app.calendar.redraw()
|
||||
_cell(app, FUTURE).invoke()
|
||||
panel = _panel(app)
|
||||
panel.nametowidget("keep-leetcode").invoke()
|
||||
panel.nametowidget("actions.mark").invoke()
|
||||
assert panel.sync_thread is not None
|
||||
panel.sync_thread.join(timeout=5)
|
||||
assert app.host.winfo_children() == []
|
||||
assert app.status.cget("text") == _window.saved_message(
|
||||
SickDay(FUTURE, is_sick=True, keep=frozenset({"leetcode"})), FUTURE
|
||||
)
|
||||
assert "Kept: LeetCode" in _texts(tk_root)
|
||||
cell = _cell(app, FUTURE)
|
||||
assert str(cell.cget("bg")).upper() == tok.SICK
|
||||
assert cell.nametowidget("dot").winfo_exists()
|
||||
|
||||
|
||||
def test_cancel_closes_the_panel(tk_root: tk.Tk) -> None:
|
||||
app = SickDaysApp(tk_root)
|
||||
app.select(FUTURE)
|
||||
_panel(app).nametowidget("actions.cancel").invoke()
|
||||
assert app.host.winfo_children() == []
|
||||
|
||||
|
||||
def test_a_free_day_is_refused_before_the_panel_opens(
|
||||
tk_root: tk.Tk, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
"sickdays.gui._text.free_day_predicate", lambda: lambda _d: True
|
||||
)
|
||||
app = SickDaysApp(tk_root)
|
||||
app.select(FUTURE)
|
||||
assert app.host.winfo_children() == []
|
||||
assert str(app.status.cget("text")).endswith("release it in Free days first.")
|
||||
assert str(app.status.cget("fg")).upper() == tok.DANGER
|
||||
|
||||
|
||||
def test_a_sick_day_on_a_free_day_still_opens(
|
||||
tk_root: tk.Tk, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
add_sick_day(FUTURE)
|
||||
monkeypatch.setattr(
|
||||
"sickdays.gui._text.free_day_predicate", lambda: lambda _d: True
|
||||
)
|
||||
app = SickDaysApp(tk_root)
|
||||
app.select(FUTURE)
|
||||
assert _panel(app).nametowidget("actions.clear").winfo_exists()
|
||||
|
||||
|
||||
def test_the_list_shows_upcoming_days_with_kept_lockers(tk_root: tk.Tk) -> None:
|
||||
add_sick_day(FUTURE, keep=["alarm", "workout"])
|
||||
add_sick_day(FUTURE + timedelta(days=1))
|
||||
texts = _texts(SickDaysApp(tk_root).calendar.master)
|
||||
assert _text.day_label(FUTURE) in texts
|
||||
assert "Kept: Workout lock, Wake alarm" in texts
|
||||
assert _text.NONE_AHEAD not in texts
|
||||
|
||||
|
||||
def test_saved_message_wording() -> None:
|
||||
sick = SickDay(FUTURE, is_sick=True)
|
||||
label = _text.day_label(FUTURE)
|
||||
assert _window.saved_message(None, FUTURE) == f"{label}: nothing changed"
|
||||
assert _window.saved_message(sick, FUTURE).endswith("every locker stands down")
|
||||
cleared = SickDay(FUTURE, is_sick=False)
|
||||
assert _window.saved_message(cleared, FUTURE) == f"{label} is a normal day again"
|
||||
|
||||
|
||||
def test_past_days_are_inert_and_faded(tk_root: tk.Tk) -> None:
|
||||
app = SickDaysApp(tk_root)
|
||||
first = today().replace(day=1)
|
||||
app.calendar.month = first - timedelta(days=1)
|
||||
app.calendar.redraw()
|
||||
cell = _cell(app, app.calendar.month.replace(day=1))
|
||||
assert str(cell.cget("state")) == "disabled"
|
||||
|
||||
|
||||
def test_month_arrows_stop_at_the_phones_range(tk_root: tk.Tk) -> None:
|
||||
app = SickDaysApp(tk_root)
|
||||
first, last = _text.month_bounds(today())
|
||||
for _ in range(30):
|
||||
app.calendar.step(1)
|
||||
assert app.calendar.month == last
|
||||
for _ in range(30):
|
||||
app.calendar.step(-1)
|
||||
assert app.calendar.month == first
|
||||
back = app.calendar.nametowidget("header.prev")
|
||||
assert str(back.cget("state")) == "disabled"
|
||||
|
||||
|
||||
def test_escape_closes_the_window(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
closed: list[bool] = []
|
||||
root = tk.Tk(className="sickdays-test")
|
||||
root.withdraw()
|
||||
app = SickDaysApp(root)
|
||||
assert root.bind("<Escape>")
|
||||
real_destroy = root.destroy
|
||||
monkeypatch.setattr(root, "destroy", lambda: closed.append(True))
|
||||
app.close()
|
||||
assert closed == [True]
|
||||
real_destroy()
|
||||
|
||||
|
||||
def test_run_opens_the_window_and_blocks(
|
||||
tk_root: tk.Tk, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
classes: list[str] = []
|
||||
|
||||
def make_root(**options: str) -> tk.Tk:
|
||||
classes.append(options["className"])
|
||||
return tk_root
|
||||
|
||||
monkeypatch.setattr(tk, "Tk", make_root)
|
||||
monkeypatch.setattr(tk_root, "mainloop", lambda: None)
|
||||
assert _window.run() == 0
|
||||
assert classes == ["sickdays"]
|
||||
@ -58,3 +58,25 @@ def test_resolve_returns_the_bundle_it_was_given(tmp_path: Path) -> None:
|
||||
|
||||
def test_resolve_falls_back_to_the_defaults() -> None:
|
||||
assert resolve_paths(None) == Paths.default()
|
||||
|
||||
|
||||
def test_every_module_resolving_paths_is_redirected_by_conftest() -> None:
|
||||
"""A module that resolves the real paths but is not redirected would let
|
||||
a test write the real pool."""
|
||||
import importlib
|
||||
import pkgutil
|
||||
|
||||
import sickdays
|
||||
from sickdays.tests import conftest
|
||||
|
||||
names = [
|
||||
info.name
|
||||
for info in pkgutil.walk_packages(sickdays.__path__, "sickdays.")
|
||||
if ".tests" not in info.name and info.name != "sickdays.__main__"
|
||||
]
|
||||
resolving = {
|
||||
name
|
||||
for name in names
|
||||
if hasattr(importlib.import_module(name), "resolve_paths")
|
||||
} - {"sickdays._paths"}
|
||||
assert resolving == set(conftest._MODULES)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user