mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 22:27:13 +01:00
Compare commits
7 Commits
focus-proj
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9d6b17fd28 | |||
| ae56037e41 | |||
| eef7af4c86 | |||
| 9daa376254 | |||
| 17e300af0a | |||
| afcc1a47c7 | |||
| 0527bc680d |
@ -4,13 +4,13 @@ description: >-
|
||||
Flutter apps: system Save-as on Android/iOS (never cloud-only), a save
|
||||
dialog on desktop, a download on web, plus a versioned export envelope.
|
||||
publish_to: 'none'
|
||||
version: 0.2.1
|
||||
version: 0.2.2
|
||||
|
||||
environment:
|
||||
sdk: ^3.12.2
|
||||
|
||||
dependencies:
|
||||
file_picker: 13.3.2
|
||||
file_picker: 13.3.3
|
||||
file_selector: 1.1.0
|
||||
flutter:
|
||||
sdk: flutter
|
||||
|
||||
@ -199,6 +199,7 @@ their `max_gaming_minutes` + pure bonuses again (240/120 min = 2/min; full day =
|
||||
from the start day, cutting all of it, 180 with LeetCode's formerly free hour (0.20.0); maturity never delays it. Ledger `~/.local/share/focus_project/ledger.json`:
|
||||
signed `credit` rows, `entry_id` `<project>-<YYYYMMDD>-m<n>`, `detail.minutes` (positive int),
|
||||
`detail.ended_at` (else `day`). `focus_project` unusable = no project (logged).
|
||||
Unbacked timer time pays 0 from 2026-11-08 (focus_project 0.10.0).
|
||||
|
||||
## Adding a gate
|
||||
|
||||
|
||||
88
earned_time/earned_time/tests/test_focus_backing.py
Normal file
88
earned_time/earned_time/tests/test_focus_backing.py
Normal file
@ -0,0 +1,88 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""Regression: a timer-only focus day after the anti-cheat cutover pays 0.
|
||||
|
||||
Runs the REAL ``focus_project`` (the sibling package of this monorepo):
|
||||
timer rows -> harvester -> signed ledger -> ``credit_units``. earned_time
|
||||
itself does not change; this pins that the gate reaches pay.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
import importlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
|
||||
from earned_time import credit_units, earners_for
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from types import ModuleType
|
||||
|
||||
DAY = date(2026, 11, 9) # after focus_project.BACKING_FROM
|
||||
_SIBLING = Path(__file__).resolve().parents[3] / "focus_project"
|
||||
|
||||
|
||||
def at(hour: int, minute: int) -> float:
|
||||
return datetime(2026, 11, 9, hour, minute).astimezone().timestamp()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def real_fp(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, key_file: Path
|
||||
) -> ModuleType:
|
||||
"""The real focus_project on redirected state (HOME, data dir, key)."""
|
||||
monkeypatch.setenv("HOME", str(tmp_path / "home"))
|
||||
monkeypatch.setenv("FOCUS_PROJECT_DATA", str(tmp_path / "data"))
|
||||
monkeypatch.setenv("FOCUS_PROJECT_KEY", str(key_file))
|
||||
monkeypatch.setenv("FOCUS_PROJECT_LEETCODE_LEDGER", str(tmp_path / "lc.json"))
|
||||
monkeypatch.delitem(sys.modules, "focus_project") # the conftest's fake
|
||||
monkeypatch.syspath_prepend(str(_SIBLING))
|
||||
fp = importlib.import_module("focus_project")
|
||||
fp.create_project(
|
||||
"hr",
|
||||
name="HR",
|
||||
start=DAY,
|
||||
end=DAY,
|
||||
replaces=["leetcode"],
|
||||
daily_minutes=120,
|
||||
unlock_minutes=30,
|
||||
)
|
||||
return fp
|
||||
|
||||
|
||||
def pay(fp: ModuleType, tmp_path: Path, key_file: Path, *rows: object) -> int | None:
|
||||
log = tmp_path / "data" / "activity.jsonl"
|
||||
log.write_text("".join(json.dumps(r) + "\n" for r in rows), encoding="utf-8")
|
||||
harvest = importlib.import_module("focus_project.harvest")
|
||||
assert harvest.harvest(now=at(23, 0)) == 0
|
||||
(item,) = (e for e in earners_for(DAY) if e.name == "focus")
|
||||
return credit_units(item, tmp_path / "data" / "ledger.json", key_file, DAY)
|
||||
|
||||
|
||||
def row(source: str, kind: str, ts: float) -> dict[str, object]:
|
||||
return {"ts": ts, "source": source, "kind": kind, "project": "hr"}
|
||||
|
||||
|
||||
TIMER = (
|
||||
row("timer", "start", at(9, 0)),
|
||||
row("timer", "checkin", at(9, 5)),
|
||||
row("timer", "stop", at(9, 10)),
|
||||
)
|
||||
|
||||
|
||||
def test_timer_only_day_pays_zero(
|
||||
real_fp: ModuleType, tmp_path: Path, key_file: Path
|
||||
) -> None:
|
||||
assert date(2026, 11, 8) == real_fp.BACKING_FROM
|
||||
assert pay(real_fp, tmp_path, key_file, *TIMER) == 0
|
||||
|
||||
|
||||
def test_backed_timer_day_still_pays(
|
||||
real_fp: ModuleType, tmp_path: Path, key_file: Path
|
||||
) -> None:
|
||||
beat = row("tutor", "heartbeat", at(9, 5))
|
||||
assert pay(real_fp, tmp_path, key_file, *TIMER, beat) == 10
|
||||
@ -36,6 +36,10 @@ uncovers today. A NEW project starting today may replace gates and set any targe
|
||||
new obligation, not a reduction (but see the floor).
|
||||
Floor (0.7.1, coordinator decision for kuhy): `daily_minutes >= 30 x replaced gates` (min 30; `book` = `reading`),
|
||||
enforced on create and update (`daily_floor(replaces)`), so a day cannot be made cheap.
|
||||
Backed timer (0.10.0, days from `BACKING_FROM` = 2026-11-08): a timer span earns only if a
|
||||
same-project `tutor`/`sql_drills`/`leetcode` beat (or accepted submission) is in it, 5 min
|
||||
before it, or bridges it (beats <= 20 min apart). `unbacked_minutes_for(day)` /
|
||||
`status()["unbacked_minutes"]` show the rest. A beat backs only the spans it touches (the tutor chain makes a graded card back the whole stretch). Known gap: any coach turn backs.
|
||||
Open the work: `python3 -m focus_project.open [--no-site]` (tutor focus screen, `work_url`).
|
||||
Install: `./install.sh` (stdlib only, editable user install for `/usr/bin/python3`).
|
||||
|
||||
|
||||
@ -31,10 +31,12 @@ from focus_project._update import Update, apply_update, update_project
|
||||
from focus_project.activity import record_activity
|
||||
from focus_project.checks import register_source, run_checks
|
||||
from focus_project.evidence import Evidence
|
||||
from focus_project.minutes import minutes_for
|
||||
from focus_project.minutes import minutes_for, unbacked_minutes_for
|
||||
from focus_project.sources.activity import BACKING_FROM
|
||||
from focus_project.status import status
|
||||
|
||||
__all__ = [
|
||||
"BACKING_FROM",
|
||||
"Evidence",
|
||||
"Limits",
|
||||
"Milestone",
|
||||
@ -59,5 +61,6 @@ __all__ = [
|
||||
"run_checks",
|
||||
"stands_down",
|
||||
"status",
|
||||
"unbacked_minutes_for",
|
||||
"update_project",
|
||||
]
|
||||
|
||||
@ -21,6 +21,7 @@ from typing import TYPE_CHECKING, Any, Literal
|
||||
from focus_project import _api, _interview, evidence
|
||||
from focus_project._errors import RegistryError
|
||||
from focus_project._kind import INTERVIEW
|
||||
from focus_project.sources import activity
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
@ -30,6 +31,7 @@ if TYPE_CHECKING:
|
||||
_LOG = logging.getLogger(__name__)
|
||||
State = Literal["pending", "done", "missed", "late_done", "unknown"]
|
||||
PHONE_LEAD = timedelta(hours=12)
|
||||
MOCK_SESSION_S = 90 * 60
|
||||
_RED = frozenset({"missed", "unknown"})
|
||||
|
||||
|
||||
@ -134,6 +136,21 @@ def _by_evidence(kind: str) -> Callable[[str, Rows, Project], Completion]:
|
||||
return judge
|
||||
|
||||
|
||||
def _mock(mid: str, rows: Rows, project: Project) -> Completion:
|
||||
"""Done at the first score row that follows a >= 90 min focus session.
|
||||
|
||||
The session (contiguous beats, :func:`activity.sessions`) must END at or
|
||||
before the score row; a score with no such session completes nothing.
|
||||
"""
|
||||
ends = [hi for lo, hi in activity.sessions(project.id) if hi - lo >= MOCK_SESSION_S]
|
||||
for row in rows:
|
||||
if (row.milestone, row.kind) == (mid, "score") and any(
|
||||
end <= row.ms / 1000 for end in ends
|
||||
):
|
||||
return row.at, False
|
||||
return None, False
|
||||
|
||||
|
||||
def _trip_ask(mid: str, rows: Rows, project: Project) -> Completion:
|
||||
"""Done at the answer row; an answer with no row was given at creation."""
|
||||
if date.fromisoformat(mid.partition(":")[2]) not in dict(project.weekend_answers):
|
||||
@ -164,7 +181,7 @@ def _phone(mid: str, rows: Rows, _project: Project) -> Completion:
|
||||
|
||||
_JUDGES = {
|
||||
"rules": _by_evidence("file"),
|
||||
"mock": _by_evidence("score"),
|
||||
"mock": _mock,
|
||||
"trip_ask": _trip_ask,
|
||||
"slot": _slot,
|
||||
"phone": _phone,
|
||||
|
||||
@ -8,6 +8,10 @@ minutes (floor). Time not yet lived is never credited: every interval is
|
||||
clipped at "now", so an event stamped ahead of the clock (a fast phone clock)
|
||||
earns only once the real clock reaches it. An unreadable registry reads as 0
|
||||
(logged): fail closed.
|
||||
|
||||
Unbacked minutes (0.10.0): timer time the anti-cheat gate refused
|
||||
(:mod:`focus_project.sources.activity`) and no other source covers, on the
|
||||
local day up to "now"; carry and the cap play no part. Shown, never paid.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@ -15,9 +19,10 @@ from __future__ import annotations
|
||||
from datetime import date # noqa: TC003
|
||||
import logging
|
||||
|
||||
from focus_project import _api, _clock, carry
|
||||
from focus_project import _api, _clock, carry, sources
|
||||
from focus_project._errors import RegistryError
|
||||
from focus_project._own import union_seconds
|
||||
from focus_project.sources import activity
|
||||
|
||||
_LOG = logging.getLogger(__name__)
|
||||
|
||||
@ -38,4 +43,26 @@ def minutes_for(day: date, now: float | None = None) -> int:
|
||||
return carry.balance(day, moment).counted(project.daily_on(day))
|
||||
|
||||
|
||||
__all__ = ["minutes_for", "union_seconds"]
|
||||
def unbacked_minutes_for(day: date, now: float | None = None) -> int:
|
||||
"""Whole minutes of timer time on ``day`` that the gate did not count.
|
||||
|
||||
0 with no project, before :data:`~focus_project.sources.activity.BACKING_FROM`,
|
||||
or with an unreadable registry (logged).
|
||||
"""
|
||||
try:
|
||||
project = _api.active(day)
|
||||
if project is None:
|
||||
return 0
|
||||
lo, hi = sources.day_bounds(day)
|
||||
bounds = (lo, min(hi, _clock.now() if now is None else now))
|
||||
unbacked = sources.clip(activity.split_timer(day, project)[1], bounds)
|
||||
counted = sources.clip(sources.collect(day, project), bounds)
|
||||
except (RegistryError, OSError) as exc:
|
||||
_LOG.warning("focus_project registry unreadable, 0 unbacked: %s", exc)
|
||||
return 0
|
||||
# |unbacked minus counted| = |unbacked union counted| - |counted|.
|
||||
gap = union_seconds(unbacked + counted) - union_seconds(counted)
|
||||
return int(gap // 60)
|
||||
|
||||
|
||||
__all__ = ["minutes_for", "unbacked_minutes_for", "union_seconds"]
|
||||
|
||||
@ -13,23 +13,36 @@ heartbeat covering the five minutes before it. Only events recorded for
|
||||
the open segment, like a ``checkin``: within :data:`MAX_GAP_S` of the last
|
||||
beat it extends the segment; later it ends the segment at that last beat. It
|
||||
never opens a segment. Other heartbeat sources never chain the timer.
|
||||
|
||||
0.10.0 anti-cheat (days from :data:`BACKING_FROM`): a timer span earns only
|
||||
if real activity of the same project backs it, whole span or nothing. A
|
||||
backing event is a ``tutor``/``sql_drills``/``leetcode`` heartbeat or an
|
||||
accepted LeetCode submission (leetcode projects only); ``browser`` never
|
||||
backs. A span ``(a, b)`` is backed if a backing event lies in
|
||||
``[a - 300, b]``, or between two consecutive backing events at most
|
||||
:data:`BRIDGE_S` apart. Check-ins alone earn 0. Earlier days keep the old
|
||||
rule (every span counts), so paid days never change.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
import importlib
|
||||
from itertools import pairwise
|
||||
import json
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import TYPE_CHECKING, Any, Final
|
||||
|
||||
from focus_project._paths import activity_path
|
||||
from focus_project.sources import Interval, heartbeat_interval
|
||||
from focus_project.sources import HEARTBEAT_WINDOW_S, Interval, heartbeat_interval
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datetime import date
|
||||
|
||||
from focus_project._model import Project
|
||||
|
||||
MAX_GAP_S = 600.0
|
||||
BACKING_FROM: Final = date(2026, 11, 8)
|
||||
BACKING_SOURCES: Final = frozenset({"tutor", "sql_drills", "leetcode"})
|
||||
BRIDGE_S: Final = 1200.0
|
||||
_LOG = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@ -84,12 +97,68 @@ def _is_beat(event: dict[str, Any]) -> bool:
|
||||
return event.get("source") == "tutor" and event.get("kind") == "heartbeat"
|
||||
|
||||
|
||||
def intervals(day: date, project: Project) -> list[Interval]: # noqa: ARG001
|
||||
"""Timer segments plus heartbeats of ``project`` (the registry clips)."""
|
||||
events = read_events(project.id)
|
||||
def sessions(project_id: str) -> list[Interval]:
|
||||
"""Contiguous focus sessions of ``project_id``, time-ordered, as (start, end).
|
||||
|
||||
Built from the same timer/tutor-beat segments (and so the same
|
||||
:data:`MAX_GAP_S` rule) that credit minutes; spans that touch are one
|
||||
session, which ends at its last beat.
|
||||
"""
|
||||
events = read_events(project_id)
|
||||
timer = [e for e in events if e.get("source") == "timer" or _is_beat(e)]
|
||||
merged: list[Interval] = []
|
||||
for lo, hi in timer_intervals(timer):
|
||||
if merged and lo <= merged[-1][1]:
|
||||
merged[-1] = (merged[-1][0], max(hi, merged[-1][1]))
|
||||
else:
|
||||
merged.append((lo, hi))
|
||||
return merged
|
||||
|
||||
|
||||
def backing_times(events: list[dict[str, Any]], project: Project) -> list[float]:
|
||||
"""Sorted times of the events that may back a timer span.
|
||||
|
||||
The LeetCode ledger is read in its own guard: if it fails, only the
|
||||
submissions are lost, never the heartbeats (fail closed, narrow).
|
||||
"""
|
||||
times = [float(e["ts"]) for e in events if e.get("source") in BACKING_SOURCES]
|
||||
try:
|
||||
# Imported lazily: it pulls in earned_time, and the timer must keep
|
||||
# working (heartbeat backing only) where that is not installed.
|
||||
leetcode = importlib.import_module("focus_project.sources.leetcode")
|
||||
times += leetcode.submission_times(project)
|
||||
except Exception: # noqa: BLE001 (lose the submissions, keep the beats)
|
||||
_LOG.exception("leetcode backing unavailable, heartbeats only")
|
||||
return sorted(times)
|
||||
|
||||
|
||||
def is_backed(span: Interval, times: list[float]) -> bool:
|
||||
"""Whether a backing event lies in or just before ``span``, or bridges it."""
|
||||
lo, hi = span
|
||||
if any(lo - HEARTBEAT_WINDOW_S <= t <= hi for t in times):
|
||||
return True
|
||||
return any(a <= lo and hi <= b and b - a <= BRIDGE_S for a, b in pairwise(times))
|
||||
|
||||
|
||||
def split_timer(
|
||||
day: date, project: Project, events: list[dict[str, Any]] | None = None
|
||||
) -> tuple[list[Interval], list[Interval]]:
|
||||
"""``(backed, unbacked)`` timer spans of ``project``, judged for ``day``."""
|
||||
found = read_events(project.id) if events is None else events
|
||||
chain = [e for e in found if e.get("source") == "timer" or _is_beat(e)]
|
||||
spans = timer_intervals(chain)
|
||||
if day < BACKING_FROM:
|
||||
return spans, []
|
||||
times = backing_times(found, project)
|
||||
backed = [s for s in spans if is_backed(s, times)]
|
||||
return backed, [s for s in spans if s not in backed]
|
||||
|
||||
|
||||
def intervals(day: date, project: Project) -> list[Interval]:
|
||||
"""Backed timer segments plus heartbeats of ``project`` (the registry clips)."""
|
||||
events = read_events(project.id)
|
||||
beats = [e for e in events if e.get("source") != "timer"]
|
||||
return [
|
||||
*timer_intervals(timer),
|
||||
*split_timer(day, project, events)[0],
|
||||
*(heartbeat_interval(float(e["ts"])) for e in beats),
|
||||
]
|
||||
|
||||
@ -55,8 +55,8 @@ def _submitted_at(row: object, key: bytes) -> float | None:
|
||||
return None
|
||||
|
||||
|
||||
def intervals(day: date, project: Project) -> list[Interval]: # noqa: ARG001
|
||||
"""One heartbeat per accepted submission (the registry clips to the day)."""
|
||||
def submission_times(project: Project) -> list[float]:
|
||||
"""Times of verified accepted submissions; [] for a non-leetcode project."""
|
||||
if not any("leetcode" in pattern.lower() for pattern in project.site_patterns):
|
||||
return []
|
||||
key = ledger.read_key()
|
||||
@ -64,4 +64,9 @@ def intervals(day: date, project: Project) -> list[Interval]: # noqa: ARG001
|
||||
_LOG.warning("leetcode source: no signing key, contributes 0")
|
||||
return []
|
||||
stamps = (_submitted_at(row, key) for row in ledger.read_rows(ledger_file()))
|
||||
return [heartbeat_interval(ts) for ts in stamps if ts is not None]
|
||||
return [ts for ts in stamps if ts is not None]
|
||||
|
||||
|
||||
def intervals(day: date, project: Project) -> list[Interval]: # noqa: ARG001
|
||||
"""One heartbeat per accepted submission (the registry clips to the day)."""
|
||||
return [heartbeat_interval(ts) for ts in submission_times(project)]
|
||||
|
||||
@ -6,7 +6,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from focus_project import _api, _milestones, carry, evidence
|
||||
from focus_project import _api, _milestones, carry, evidence, minutes
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datetime import date
|
||||
@ -36,7 +36,8 @@ def status(day: date) -> dict[str, Any]:
|
||||
|
||||
``minutes`` is what was really worked plus what the day took from the
|
||||
bank, so it may exceed ``daily_minutes`` (121/120); ``counted_minutes``
|
||||
is the capped figure that pays.
|
||||
is the capped figure that pays. ``unbacked_minutes`` is timer time the
|
||||
anti-cheat gate refused (shown as "not counted", never paid).
|
||||
"""
|
||||
project = _api.active(day)
|
||||
if project is None:
|
||||
@ -52,6 +53,7 @@ def status(day: date) -> dict[str, Any]:
|
||||
"unlock_minutes": None,
|
||||
"ends": None,
|
||||
"replaces": [],
|
||||
"unbacked_minutes": 0,
|
||||
"milestones": [],
|
||||
"missed": False,
|
||||
}
|
||||
@ -72,6 +74,7 @@ def status(day: date) -> dict[str, Any]:
|
||||
"unlock_minutes": project.unlock_on(day),
|
||||
"ends": project.last_day.isoformat(),
|
||||
"replaces": list(project.replaces_on(day)),
|
||||
"unbacked_minutes": minutes.unbacked_minutes_for(day),
|
||||
# Interview projects: [{id, due ISO, state}] and whether any is red.
|
||||
"milestones": rows,
|
||||
"missed": missed,
|
||||
|
||||
@ -3,10 +3,12 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, time
|
||||
from datetime import date, datetime, time, timedelta
|
||||
import json
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import focus_project as fp
|
||||
from focus_project._paths import activity_path
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
@ -50,3 +52,14 @@ def shot(tmp_path: Path, name: str = "rules.png") -> Path:
|
||||
def states(project: fp.Project, now: datetime) -> dict[str, str]:
|
||||
"""Milestone id -> state."""
|
||||
return {m.id: m.state for m in fp.milestones(project, now)}
|
||||
|
||||
|
||||
def session(start: datetime, minutes: int, *, step: int = 5, pid: str = "iv") -> None:
|
||||
"""A timer session start -> check-in every ``step`` min -> stop."""
|
||||
activity_path().parent.mkdir(parents=True, exist_ok=True)
|
||||
with activity_path().open("a", encoding="utf-8") as handle:
|
||||
for offset in range(0, minutes + 1, step):
|
||||
kind = "start" if offset == 0 else "checkin"
|
||||
when = start + timedelta(minutes=offset)
|
||||
row = {"ts": when.timestamp(), "source": "timer", "kind": kind}
|
||||
handle.write(json.dumps({**row, "project": pid, "dev": "pc"}) + "\n")
|
||||
|
||||
@ -137,6 +137,7 @@ def test_status_active_reports_the_worked_minutes() -> None:
|
||||
"unlock_minutes": 30,
|
||||
"ends": "2026-11-05",
|
||||
"replaces": ["leetcode"],
|
||||
"unbacked_minutes": 0,
|
||||
"milestones": [],
|
||||
"missed": False,
|
||||
}
|
||||
@ -160,6 +161,7 @@ def test_status_inactive() -> None:
|
||||
"unlock_minutes",
|
||||
"ends",
|
||||
"replaces",
|
||||
"unbacked_minutes",
|
||||
"milestones",
|
||||
"missed",
|
||||
}
|
||||
|
||||
181
focus_project/focus_project/tests/test_backing.py
Normal file
181
focus_project/focus_project/tests/test_backing.py
Normal file
@ -0,0 +1,181 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""0.10.0: a timer span earns only if a same-project beat backs it."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import focus_project as fp
|
||||
from focus_project import _api, minutes
|
||||
from focus_project.sources import activity
|
||||
from focus_project.tests import test_minutes as tm
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import pytest
|
||||
|
||||
DAY = date(2026, 11, 8) # BACKING_FROM
|
||||
|
||||
|
||||
def at(hour: int, minute: int = 0) -> float:
|
||||
return tm.at(hour, minute, day=DAY)
|
||||
|
||||
|
||||
def proj() -> None:
|
||||
fp.create_project(
|
||||
"hr",
|
||||
name="HR prep",
|
||||
start=DAY,
|
||||
end=date(2026, 11, 12),
|
||||
replaces=["leetcode"],
|
||||
daily_minutes=120,
|
||||
unlock_minutes=30,
|
||||
site_patterns=["leetcode.com"],
|
||||
)
|
||||
|
||||
|
||||
def rows(*items: tuple[str, str, float]) -> None:
|
||||
tm.events(*items)
|
||||
|
||||
|
||||
def timer_only() -> None:
|
||||
rows(
|
||||
("timer", "start", at(9)),
|
||||
("timer", "checkin", at(9, 5)),
|
||||
("timer", "stop", at(9, 10)),
|
||||
)
|
||||
|
||||
|
||||
def test_timer_only_stretch_pays_zero_and_shows_unbacked() -> None:
|
||||
proj()
|
||||
timer_only()
|
||||
assert minutes.minutes_for(DAY) == 0
|
||||
assert minutes.unbacked_minutes_for(DAY) == 10
|
||||
assert fp.status(DAY)["unbacked_minutes"] == 10
|
||||
|
||||
|
||||
def test_a_beat_in_the_span_backs_all_of_it() -> None:
|
||||
proj()
|
||||
timer_only()
|
||||
rows(("sql_drills", "heartbeat", at(9, 5)))
|
||||
assert minutes.unbacked_minutes_for(DAY) == 0
|
||||
assert minutes.minutes_for(DAY) == 10
|
||||
|
||||
|
||||
def test_a_beat_backs_only_the_spans_it_touches() -> None:
|
||||
proj()
|
||||
timer_only()
|
||||
rows(("sql_drills", "heartbeat", at(9, 7)))
|
||||
# 9:00-9:05 stays unbacked, but the beat itself pays 9:02-9:07.
|
||||
assert minutes.unbacked_minutes_for(DAY) == 2
|
||||
|
||||
|
||||
def test_a_beat_at_the_end_of_the_span_backs_it() -> None:
|
||||
proj()
|
||||
rows(
|
||||
("timer", "start", at(9)),
|
||||
("timer", "stop", at(9, 10)),
|
||||
("sql_drills", "heartbeat", at(9, 10)),
|
||||
)
|
||||
assert minutes.unbacked_minutes_for(DAY) == 0
|
||||
assert minutes.minutes_for(DAY) == 10
|
||||
|
||||
|
||||
def test_a_beat_just_before_the_span_backs_it() -> None:
|
||||
proj()
|
||||
rows(
|
||||
("sql_drills", "heartbeat", at(8, 56)), # within 300 s before 9:00
|
||||
("timer", "start", at(9)),
|
||||
("timer", "stop", at(9, 5)),
|
||||
)
|
||||
assert minutes.unbacked_minutes_for(DAY) == 0
|
||||
|
||||
|
||||
def test_a_late_beat_does_not_back_it() -> None:
|
||||
proj()
|
||||
timer_only()
|
||||
rows(("sql_drills", "heartbeat", at(9, 40)))
|
||||
assert minutes.unbacked_minutes_for(DAY) == 10
|
||||
|
||||
|
||||
def test_beats_up_to_twenty_minutes_apart_bridge_the_span() -> None:
|
||||
proj()
|
||||
rows(
|
||||
("tutor", "heartbeat", at(8, 50)),
|
||||
("timer", "start", at(9)),
|
||||
("timer", "checkin", at(9, 5)),
|
||||
("timer", "stop", at(9, 8)),
|
||||
("tutor", "heartbeat", at(9, 10)),
|
||||
)
|
||||
assert minutes.unbacked_minutes_for(DAY) == 0
|
||||
|
||||
|
||||
def test_beats_over_twenty_minutes_apart_do_not_bridge() -> None:
|
||||
proj()
|
||||
rows(
|
||||
("tutor", "heartbeat", at(8, 30)),
|
||||
("timer", "start", at(9)),
|
||||
("timer", "stop", at(9, 8)),
|
||||
("tutor", "heartbeat", at(9, 40)),
|
||||
)
|
||||
assert minutes.unbacked_minutes_for(DAY) == 8
|
||||
|
||||
|
||||
def test_browser_never_backs() -> None:
|
||||
proj()
|
||||
timer_only()
|
||||
rows(("browser", "heartbeat", at(9, 7)))
|
||||
# The beat pays its own 9:02-9:07 but backs no timer span.
|
||||
assert minutes.unbacked_minutes_for(DAY) == 5
|
||||
assert minutes.minutes_for(DAY) == 5
|
||||
|
||||
|
||||
def test_days_before_backing_from_keep_the_old_rule() -> None:
|
||||
fp.create_project(
|
||||
"hr",
|
||||
name="Old",
|
||||
start=date(2026, 11, 2),
|
||||
end=date(2026, 11, 5),
|
||||
replaces=["leetcode"],
|
||||
daily_minutes=120,
|
||||
unlock_minutes=30,
|
||||
site_patterns=["leetcode.com"],
|
||||
)
|
||||
old = date(2026, 11, 2)
|
||||
rows(("timer", "start", tm.at(9)), ("timer", "stop", tm.at(9, 10)))
|
||||
assert minutes.minutes_for(old) == 10
|
||||
assert minutes.unbacked_minutes_for(old) == 0
|
||||
|
||||
|
||||
def test_no_project_is_zero_unbacked() -> None:
|
||||
assert minutes.unbacked_minutes_for(DAY) == 0
|
||||
assert fp.status(DAY)["unbacked_minutes"] == 0
|
||||
|
||||
|
||||
def test_unbacked_is_clipped_at_now() -> None:
|
||||
proj()
|
||||
timer_only()
|
||||
assert minutes.unbacked_minutes_for(DAY, now=at(9, 4)) == 4
|
||||
|
||||
|
||||
def test_split_timer_returns_backed_and_unbacked() -> None:
|
||||
proj()
|
||||
timer_only()
|
||||
project = _api.active(DAY)
|
||||
assert project is not None
|
||||
backed, unbacked = activity.split_timer(DAY, project)
|
||||
assert backed == []
|
||||
assert unbacked == [(at(9), at(9, 5)), (at(9, 5), at(9, 10))]
|
||||
|
||||
|
||||
def test_backing_from_is_exported() -> None:
|
||||
assert date(2026, 11, 8) == fp.BACKING_FROM
|
||||
assert fp.unbacked_minutes_for is minutes.unbacked_minutes_for
|
||||
|
||||
|
||||
def test_registry_error_reads_zero(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def boom(_day: date) -> None:
|
||||
raise OSError
|
||||
|
||||
monkeypatch.setattr("focus_project._api.active", boom)
|
||||
assert minutes.unbacked_minutes_for(DAY) == 0
|
||||
131
focus_project/focus_project/tests/test_backing_edges.py
Normal file
131
focus_project/focus_project/tests/test_backing_edges.py
Normal file
@ -0,0 +1,131 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""More 0.10.0 backing cases: sources, projects, the cutover midnight, chains.
|
||||
|
||||
The core rule lives in ``test_backing.py``; these pin the edges the spec
|
||||
named (C5-C7) with leetcode-ledger and other-project fixtures.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
import json
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
|
||||
import focus_project as fp
|
||||
from focus_project import minutes
|
||||
from focus_project._paths import activity_path
|
||||
from focus_project.tests.test_leetcode_source import ac_row, write
|
||||
from focus_project.tests.test_minutes import at
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
DAY = date(2026, 11, 9) # after the cutover
|
||||
PRE = date(2026, 11, 7) # before it
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
"""Nothing may fall back to the real home directory."""
|
||||
monkeypatch.setenv("HOME", str(tmp_path / "home"))
|
||||
|
||||
|
||||
def make(pid: str = "hr", patterns: tuple[str, ...] = ("leetcode.com",)) -> None:
|
||||
fp.create_project(
|
||||
pid,
|
||||
name=pid,
|
||||
start=date(2026, 11, 6),
|
||||
end=date(2026, 11, 12),
|
||||
replaces=["leetcode"],
|
||||
daily_minutes=240,
|
||||
unlock_minutes=30,
|
||||
site_patterns=list(patterns),
|
||||
)
|
||||
|
||||
|
||||
def rows(*items: tuple[str, str, float] | tuple[str, str, float, str]) -> None:
|
||||
"""Append ``(source, kind, ts[, project])`` events (project default hr)."""
|
||||
activity_path().parent.mkdir(parents=True, exist_ok=True)
|
||||
with activity_path().open("a", encoding="utf-8") as handle:
|
||||
for item in items:
|
||||
source, kind, ts = item[:3]
|
||||
pid = item[3] if len(item) == 4 else "hr"
|
||||
row = {"ts": ts, "source": source, "kind": kind, "project": pid}
|
||||
handle.write(json.dumps(row) + "\n")
|
||||
|
||||
|
||||
def timer(day: date, *marks: tuple[int, int]) -> None:
|
||||
"""A segment: start at the first mark, check-ins, stop at the last."""
|
||||
kinds = ["start", *["checkin"] * (len(marks) - 2), "stop"]
|
||||
rows(
|
||||
*(
|
||||
("timer", k, at(h, m, day=day))
|
||||
for k, (h, m) in zip(kinds, marks, strict=True)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def beat(source: str, h: int, m: int, day: date = DAY, pid: str = "hr") -> None:
|
||||
rows((source, "heartbeat", at(h, m, day=day), pid))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("source", ["sql_drills", "leetcode"])
|
||||
def test_sql_drills_and_leetcode_heartbeats_back_too(source: str) -> None:
|
||||
make()
|
||||
timer(DAY, (9, 0), (9, 5), (9, 10))
|
||||
beat(source, 9, 5)
|
||||
assert minutes.minutes_for(DAY) == 10
|
||||
|
||||
|
||||
def test_browser_and_other_project_beats_do_not_back() -> None:
|
||||
make() # "other" needs no registry entry: rows are filtered by id
|
||||
timer(DAY, (9, 0), (9, 5), (9, 10))
|
||||
beat("browser", 9, 4)
|
||||
beat("tutor", 9, 4, pid="other")
|
||||
assert minutes.minutes_for(DAY) == 5 # the browser beat's own window only
|
||||
assert minutes.unbacked_minutes_for(DAY) == 6 # 9:04-9:10
|
||||
|
||||
|
||||
def test_accepted_submission_backs_a_leetcode_project(tmp_path: Path) -> None:
|
||||
make()
|
||||
timer(DAY, (9, 0), (9, 5), (9, 10))
|
||||
write(tmp_path, ac_row(at(9, 4, day=DAY)))
|
||||
assert minutes.minutes_for(DAY) == 11 # 8:59-9:10
|
||||
|
||||
|
||||
def test_submission_does_not_back_a_non_leetcode_project(tmp_path: Path) -> None:
|
||||
make("hr", ("sqlzoo.net",))
|
||||
timer(DAY, (9, 0), (9, 5), (9, 10))
|
||||
write(tmp_path, ac_row(at(9, 4, day=DAY)))
|
||||
assert minutes.minutes_for(DAY) == 0
|
||||
|
||||
|
||||
def test_span_across_the_cutover_midnight_follows_the_day() -> None:
|
||||
make()
|
||||
rows(
|
||||
("timer", "start", at(23, 58, day=date(2026, 11, 7))),
|
||||
("timer", "checkin", at(0, 3, day=date(2026, 11, 8))),
|
||||
("timer", "stop", at(0, 5, day=date(2026, 11, 8))),
|
||||
)
|
||||
assert minutes.minutes_for(date(2026, 11, 7)) == 2
|
||||
assert minutes.minutes_for(date(2026, 11, 8)) == 0
|
||||
assert minutes.unbacked_minutes_for(date(2026, 11, 8)) == 5
|
||||
|
||||
|
||||
def test_tutor_chained_span_is_backed_but_earlier_reading_is_not() -> None:
|
||||
"""A tutor beat chains the segment (0.8.0) and backs the span it ends.
|
||||
|
||||
start 9:00, checkin 9:08, tutor beat 9:16: the span 9:08-9:16 ends at a
|
||||
backing beat and earns; 9:00-9:08 has no beat in or 5 min before it and
|
||||
nothing bridges it, so it shows as unbacked.
|
||||
"""
|
||||
make()
|
||||
rows(
|
||||
("timer", "start", at(9, 0, day=DAY)),
|
||||
("timer", "checkin", at(9, 8, day=DAY)),
|
||||
)
|
||||
beat("tutor", 9, 16)
|
||||
assert minutes.minutes_for(DAY) == 8
|
||||
assert minutes.unbacked_minutes_for(DAY) == 8
|
||||
@ -14,6 +14,8 @@ import pytest
|
||||
import focus_project as fp
|
||||
from focus_project import minutes, sources
|
||||
from focus_project.sources import leetcode
|
||||
from focus_project.tests.test_backing import DAY, proj, rows
|
||||
from focus_project.tests.test_backing import at as day_at
|
||||
from focus_project.tests.test_minutes import D1, at, project
|
||||
|
||||
KEY = b"k" * 32
|
||||
@ -127,3 +129,29 @@ def test_read_only_replay_of_real_ledger(monkeypatch: pytest.MonkeyPatch) -> Non
|
||||
assert got
|
||||
assert all(b - a == sources.HEARTBEAT_WINDOW_S for a, b in got)
|
||||
assert REAL.stat().st_mtime_ns == before
|
||||
|
||||
|
||||
def test_accepted_submission_backs_a_timer_span(tmp_path: Path) -> None:
|
||||
"""0.10.0: an accepted submission backs timer time on a backed day."""
|
||||
proj()
|
||||
rows(("timer", "start", day_at(9)), ("timer", "stop", day_at(9, 10)))
|
||||
assert minutes.unbacked_minutes_for(DAY) == 10
|
||||
write(tmp_path, ac_row(day_at(9, 10)))
|
||||
assert minutes.unbacked_minutes_for(DAY) == 0
|
||||
assert minutes.minutes_for(DAY) == 10
|
||||
|
||||
|
||||
def test_unreadable_ledger_leaves_heartbeat_backing(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
def boom(_project: object) -> list[float]:
|
||||
raise OSError
|
||||
|
||||
monkeypatch.setattr(leetcode, "submission_times", boom)
|
||||
proj()
|
||||
rows(
|
||||
("timer", "start", day_at(9)),
|
||||
("timer", "stop", day_at(9, 10)),
|
||||
("tutor", "heartbeat", day_at(9, 10)),
|
||||
)
|
||||
assert minutes.unbacked_minutes_for(DAY) == 0
|
||||
|
||||
@ -8,7 +8,7 @@ from typing import TYPE_CHECKING
|
||||
|
||||
import focus_project as fp
|
||||
from focus_project import evidence
|
||||
from focus_project.tests.helpers import SAT1, SAT2, at, make, shot, states
|
||||
from focus_project.tests.helpers import SAT1, SAT2, at, make, session, shot, states
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
@ -97,6 +97,7 @@ def _clean(tmp_path: Path) -> None:
|
||||
fp.record_evidence("iv", "rules", "file", file=shot(tmp_path), now=at(10, 9))
|
||||
fp.answer_weekend("iv", SAT1, trip=False, now=at(11, 9))
|
||||
fp.answer_weekend("iv", SAT2, trip=False, now=at(11, 9))
|
||||
session(at(24, 7), 90)
|
||||
fp.record_evidence("iv", "mock", "score", 70, now=at(24, 9))
|
||||
|
||||
|
||||
@ -147,13 +148,6 @@ def test_on_time_is_done_at_the_boundary(tmp_path: Path) -> None:
|
||||
assert states(p, at(10, 23, 59))["rules"] == "done"
|
||||
|
||||
|
||||
def test_mock_needs_a_score(tmp_path: Path) -> None:
|
||||
p = make()
|
||||
assert states(p, at(26, 8))["mock"] == "missed"
|
||||
fp.record_evidence("iv", "mock", "score", 64, now=at(26, 8))
|
||||
assert states(fp.list_projects()[0], at(26, 9))["mock"] == "late_done"
|
||||
|
||||
|
||||
def test_slot_needs_entry_and_confirm_tap() -> None:
|
||||
p = make()
|
||||
fp.record_evidence("iv", "slot", "slot", "2026-11-26T20:00:00", now=at(25, 9))
|
||||
|
||||
61
focus_project/focus_project/tests/test_milestones_mock.py
Normal file
61
focus_project/focus_project/tests/test_milestones_mock.py
Normal file
@ -0,0 +1,61 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""The mock milestone: a score counts only after a >= 90 min focus session."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import focus_project as fp
|
||||
from focus_project.tests.helpers import at, make, session, states
|
||||
|
||||
|
||||
def test_mock_needs_a_score_after_a_long_session() -> None:
|
||||
p = make()
|
||||
assert states(p, at(26, 8))["mock"] == "missed"
|
||||
session(at(25, 20), 90)
|
||||
fp.record_evidence("iv", "mock", "score", 64, now=at(26, 8))
|
||||
assert states(fp.list_projects()[0], at(26, 9))["mock"] == "late_done"
|
||||
|
||||
|
||||
def test_score_without_a_session_does_not_complete_mock() -> None:
|
||||
p = make()
|
||||
fp.record_evidence("iv", "mock", "score", 64, now=at(24, 9))
|
||||
assert states(p, at(24, 10))["mock"] == "pending"
|
||||
assert states(p, at(26, 8))["mock"] == "missed"
|
||||
assert fp.owed_lock(at(26, 8)) is not None
|
||||
|
||||
|
||||
def test_short_session_does_not_complete_mock() -> None:
|
||||
p = make()
|
||||
session(at(24, 7), 89)
|
||||
fp.record_evidence("iv", "mock", "score", 64, now=at(24, 9))
|
||||
assert states(p, at(24, 10))["mock"] == "pending"
|
||||
|
||||
|
||||
def test_session_broken_by_a_gap_does_not_complete_mock() -> None:
|
||||
p = make()
|
||||
session(at(24, 6), 60) # 06:00-07:00, then an 11 min hole (> 600 s)
|
||||
session(at(24, 7, 11), 60)
|
||||
fp.record_evidence("iv", "mock", "score", 64, now=at(24, 9))
|
||||
assert states(p, at(24, 10))["mock"] == "pending"
|
||||
|
||||
|
||||
def test_session_must_end_before_the_score() -> None:
|
||||
p = make()
|
||||
session(at(24, 8), 90) # ends 09:30
|
||||
fp.record_evidence("iv", "mock", "score", 64, now=at(24, 9))
|
||||
assert states(p, at(24, 10))["mock"] == "pending"
|
||||
fp.record_evidence("iv", "mock", "score", 66, now=at(24, 9, 30))
|
||||
assert states(p, at(24, 10))["mock"] == "done" # ends exactly at the score
|
||||
|
||||
|
||||
def test_other_projects_sessions_do_not_count() -> None:
|
||||
p = make()
|
||||
session(at(24, 7), 90, pid="other")
|
||||
fp.record_evidence("iv", "mock", "score", 64, now=at(24, 9))
|
||||
assert states(p, at(24, 10))["mock"] == "pending"
|
||||
|
||||
|
||||
def test_session_on_time_but_score_late_is_late_done() -> None:
|
||||
p = make()
|
||||
session(at(25, 20), 100)
|
||||
fp.record_evidence("iv", "mock", "score", 64, now=at(26, 0, 5))
|
||||
assert states(p, at(26, 1))["mock"] == "late_done"
|
||||
@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "focus_project"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
description = "Time-boxed focus projects: replace chosen gates, pay a daily-minutes gate"
|
||||
requires-python = ">=3.14"
|
||||
# Stdlib only on purpose: guards import it from the system python.
|
||||
|
||||
@ -93,6 +93,14 @@ web-relevant file is staged (`web.DEFAULT_FILES`, overridable with
|
||||
degrades to a pass, and the gate defers to that workflow when run inside
|
||||
another GitHub Actions job.
|
||||
|
||||
## Flutter apps: no Google non-free sign-in
|
||||
|
||||
A repo with a `pubspec.yaml` beside an `android/` dir (root or one level
|
||||
down) also gets `scripts/check_no_gms_signin.sh` (a shim) and the always-run
|
||||
`no-gms-signin` hook (`install_gms.py`). The shared gate fails on
|
||||
google_sign_in / play-services-auth / googleid in pubspec.lock or gradle, or
|
||||
in a built APK with `--apk`; apps use `google_account_token` instead.
|
||||
|
||||
## Flutter apps: the launcher rules
|
||||
|
||||
Decided by kuhy 2026-10-23 (`launcher.py`). They apply to every tracked
|
||||
|
||||
@ -25,6 +25,7 @@ from dep_freshness.install import NO_WORKFLOW_MARKER
|
||||
from repo_contract import bootstrap
|
||||
from repo_contract.check import violations
|
||||
from repo_contract.infer import propose
|
||||
from repo_contract.install_gms import install_gms, plan_gms
|
||||
from repo_contract.install_web import install_web, plan_web
|
||||
from repo_contract.precommit import PRECOMMIT, needs_hook, write_hook
|
||||
from repo_contract.stacks import detect_stacks
|
||||
@ -64,6 +65,7 @@ def plan(repo: Path) -> list[str]:
|
||||
|
||||
A website (detected or declared in repo-contract.toml) also gets the
|
||||
AgentReady gate: config, shim, workflow and hook (see install_web.py).
|
||||
A Flutter Android app also gets the no-GMS-sign-in gate (install_gms.py).
|
||||
"""
|
||||
todo = []
|
||||
if _needs_shim(repo):
|
||||
@ -72,7 +74,7 @@ def plan(repo: Path) -> list[str]:
|
||||
todo.append(str(WORKFLOW))
|
||||
if needs_hook(repo, HOOK_ID):
|
||||
todo.append(f"{PRECOMMIT} ({HOOK_ID} hook)")
|
||||
return todo + plan_web(repo)
|
||||
return todo + plan_web(repo) + plan_gms(repo)
|
||||
|
||||
|
||||
def install(repo: Path) -> list[str]:
|
||||
@ -92,7 +94,7 @@ def install(repo: Path) -> list[str]:
|
||||
if needs_hook(repo, HOOK_ID):
|
||||
write_hook(repo, _template("pre-commit-snippet.yaml"))
|
||||
done.append(f"{PRECOMMIT} ({HOOK_ID} hook)")
|
||||
return done + install_web(repo)
|
||||
return done + install_web(repo) + install_gms(repo)
|
||||
|
||||
|
||||
def bootstrap_commands(repo: Path) -> list[str]:
|
||||
|
||||
62
repo_contract/install_gms.py
Normal file
62
repo_contract/install_gms.py
Normal file
@ -0,0 +1,62 @@
|
||||
"""The no-GMS-sign-in half of the installer: Flutter Android apps get the gate.
|
||||
|
||||
Why it rides on install_repo_contract_gate.sh: that installer is the one step
|
||||
every new repo gets after `git init`, so a new Flutter app cannot come out of
|
||||
scaffolding able to re-add google_sign_in (13 apps were migrated off it onto
|
||||
utils/google_account_token in November 2026). Pieces: the
|
||||
scripts/check_no_gms_signin.sh delegate and an always-run pre-commit hook.
|
||||
Only repos with a pubspec.yaml next to an android/ dir (at the root or one
|
||||
level down, e.g. tutor/app) get it; utils itself holds the real gate.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from repo_contract.precommit import PRECOMMIT, needs_hook, write_hook
|
||||
|
||||
TEMPLATES = Path(__file__).resolve().parent / "templates"
|
||||
SHIM = Path("scripts/check_no_gms_signin.sh")
|
||||
HOOK_ID = "no-gms-signin"
|
||||
HOOK_PIECE = f"{PRECOMMIT} ({HOOK_ID} hook)"
|
||||
SHIM_TEMPLATE = "check_no_gms_signin.shim.sh"
|
||||
|
||||
|
||||
def _template(name: str) -> str:
|
||||
return (TEMPLATES / name).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def is_flutter_android(repo: Path) -> bool:
|
||||
"""A pubspec.yaml beside an android/ dir, at the root or one level down."""
|
||||
if (repo / "repo_contract" / "check.py").is_file():
|
||||
return False # utils: SHIM is the real gate, never a delegate
|
||||
pubspecs = [repo / "pubspec.yaml", *repo.glob("*/pubspec.yaml")]
|
||||
return any(p.is_file() and (p.parent / "android").is_dir() for p in pubspecs)
|
||||
|
||||
|
||||
def plan_gms(repo: Path) -> list[str]:
|
||||
"""Which no-GMS-sign-in gate pieces are missing or drifted."""
|
||||
if not is_flutter_android(repo):
|
||||
return []
|
||||
todo = []
|
||||
shim = repo / SHIM
|
||||
if not shim.is_file() or shim.read_text(encoding="utf-8") != _template(
|
||||
SHIM_TEMPLATE
|
||||
):
|
||||
todo.append(str(SHIM))
|
||||
if needs_hook(repo, HOOK_ID):
|
||||
todo.append(HOOK_PIECE)
|
||||
return todo
|
||||
|
||||
|
||||
def install_gms(repo: Path) -> list[str]:
|
||||
"""Write every missing or drifted no-GMS-sign-in piece. Returns them."""
|
||||
todo = plan_gms(repo)
|
||||
if str(SHIM) in todo:
|
||||
target = repo / SHIM
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(_template(SHIM_TEMPLATE), encoding="utf-8")
|
||||
target.chmod(0o755)
|
||||
if HOOK_PIECE in todo:
|
||||
write_hook(repo, _template("no-gms-signin-hook.yaml"))
|
||||
return todo
|
||||
27
repo_contract/templates/check_no_gms_signin.shim.sh
Normal file
27
repo_contract/templates/check_no_gms_signin.shim.sh
Normal file
@ -0,0 +1,27 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ============================================================================
|
||||
# Fail if this app links Google's non-free sign-in (google_sign_in,
|
||||
# play-services-auth, googleid) instead of google_account_token.
|
||||
#
|
||||
# Thin delegate to the shared gate in ~/src/utils, which owns the package
|
||||
# list and the fix message. Copy this file to scripts/check_no_gms_signin.sh
|
||||
# in a repo; never copy the shared gate itself.
|
||||
# ============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
readonly SHARED_GATE="${UTILS_ROOT:-$HOME/src/utils}/scripts/check_no_gms_signin.sh"
|
||||
|
||||
main() {
|
||||
if [[ ! -x "$SHARED_GATE" ]]; then
|
||||
echo "Error: shared no-gms-signin gate not found at $SHARED_GATE" >&2
|
||||
echo " Clone github.com/kuhyx/utils to ~/src/utils, or set" >&2
|
||||
echo " UTILS_ROOT to where it lives." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exec bash "$SHARED_GATE" "$@"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
12
repo_contract/templates/no-gms-signin-hook.yaml
Normal file
12
repo_contract/templates/no-gms-signin-hook.yaml
Normal file
@ -0,0 +1,12 @@
|
||||
# Added under `repos: - repo: local: hooks:` by install_repo_contract_gate.sh
|
||||
# for any repo with a pubspec.yaml beside an android/ dir (a Flutter app).
|
||||
# No Google non-free sign-in: google_sign_in links play-services-auth /
|
||||
# googleid into the APK; apps use utils/google_account_token instead.
|
||||
# always_run rather than `files:`: a poisoned pubspec.lock can arrive by
|
||||
# pull or merge, and a staged-only trigger would never look again.
|
||||
- id: no-gms-signin
|
||||
name: no google_sign_in / GMS auth (use google_account_token)
|
||||
entry: scripts/check_no_gms_signin.sh .
|
||||
language: system
|
||||
pass_filenames: false
|
||||
always_run: true
|
||||
77
repo_contract/tests/test_install_gms.py
Normal file
77
repo_contract/tests/test_install_gms.py
Normal file
@ -0,0 +1,77 @@
|
||||
"""A Flutter repo with an Android target gets the no-GMS-sign-in gate."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import stat
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from repo_contract import install as inst
|
||||
from repo_contract import install_gms as gms
|
||||
|
||||
from .conftest import write
|
||||
|
||||
|
||||
def flutter_android(root: Path, sub: str = "") -> Path:
|
||||
"""A Flutter project at `root/sub` with an android/ dir."""
|
||||
base = f"{sub}/" if sub else ""
|
||||
write(root, f"{base}pubspec.yaml", "name: x\n")
|
||||
write(root, f"{base}android/app/build.gradle.kts", "plugins {}\n")
|
||||
return root
|
||||
|
||||
|
||||
def test_no_pubspec_means_no_gate(repo: Path) -> None:
|
||||
assert gms.plan_gms(repo) == []
|
||||
assert gms.install_gms(repo) == []
|
||||
|
||||
|
||||
def test_pubspec_without_android_means_no_gate(repo: Path) -> None:
|
||||
write(repo, "pubspec.yaml", "name: x\n")
|
||||
assert gms.plan_gms(repo) == []
|
||||
|
||||
|
||||
def test_root_flutter_android_gets_shim_and_hook(repo: Path) -> None:
|
||||
flutter_android(repo)
|
||||
assert gms.plan_gms(repo) == [str(gms.SHIM), gms.HOOK_PIECE]
|
||||
assert inst.install(repo)[-2:] == [str(gms.SHIM), gms.HOOK_PIECE]
|
||||
shim = repo / gms.SHIM
|
||||
assert shim.stat().st_mode & stat.S_IXUSR
|
||||
config = (repo / inst.PRECOMMIT).read_text(encoding="utf-8")
|
||||
assert f"id: {gms.HOOK_ID}" in config
|
||||
assert "always_run: true" in config
|
||||
assert gms.plan_gms(repo) == []
|
||||
assert gms.install_gms(repo) == []
|
||||
|
||||
|
||||
def test_nested_app_dir_counts(repo: Path) -> None:
|
||||
flutter_android(repo, "app")
|
||||
assert gms.plan_gms(repo) == [str(gms.SHIM), gms.HOOK_PIECE]
|
||||
|
||||
|
||||
def test_drifted_shim_is_rewritten_hook_kept(repo: Path) -> None:
|
||||
flutter_android(repo)
|
||||
gms.install_gms(repo)
|
||||
write(repo, gms.SHIM.as_posix(), "#!/bin/bash\nexit 0\n")
|
||||
assert gms.install_gms(repo) == [str(gms.SHIM)]
|
||||
|
||||
|
||||
def test_utils_itself_is_never_given_a_shim(repo: Path) -> None:
|
||||
flutter_android(repo, "google_account_token")
|
||||
write(repo, "repo_contract/check.py")
|
||||
assert gms.plan_gms(repo) == []
|
||||
|
||||
|
||||
def test_installed_shim_delegates_to_the_shared_gate(repo: Path) -> None:
|
||||
flutter_android(repo)
|
||||
write(repo, "pubspec.lock", "packages:\n google_sign_in_android:\n")
|
||||
gms.install_gms(repo)
|
||||
utils_root = Path(__file__).resolve().parents[2]
|
||||
proc = subprocess.run(
|
||||
["bash", str(repo / gms.SHIM), str(repo)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env={"UTILS_ROOT": str(utils_root), "PATH": "/usr/bin:/bin"},
|
||||
)
|
||||
assert proc.returncode == 1
|
||||
assert "google_account_token" in proc.stderr
|
||||
193
scripts/check_no_gms_signin.sh
Executable file
193
scripts/check_no_gms_signin.sh
Executable file
@ -0,0 +1,193 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ============================================================================
|
||||
# Fail if a Flutter/Android app links Google's non-free sign-in stack.
|
||||
#
|
||||
# Every app signs in through kuhy's FOSS plugin google_account_token
|
||||
# (utils/google_account_token, Android AccountManager). google_sign_in pulls
|
||||
# google_sign_in_android, which links play-services-auth / googleid into the
|
||||
# APK. Nine apps were migrated off it on 2026-11-02 and four more on
|
||||
# 2026-11-10; nothing else stops a later session from re-adding it.
|
||||
#
|
||||
# Lockfile mode (default) reads pubspec.lock package keys, plus the gradle
|
||||
# files under each lock's sibling android/ dir. Web-only pieces
|
||||
# (google_sign_in_web, google_sign_in_platform_interface and their transitive
|
||||
# google_identity_services_web) are allowed while google_sign_in_android is
|
||||
# absent: kartka's web build uses them for the GIS button and they never
|
||||
# reach an APK. APK mode looks for the GMS auth library marker files in the
|
||||
# built APK, the ground truth when a dependency arrives some other way.
|
||||
#
|
||||
# Usage: check_no_gms_signin.sh [pubspec.lock | dir ...]
|
||||
# check_no_gms_signin.sh --apk app.apk
|
||||
# A dir is searched for pubspec.lock files (build/, .dart_tool/ pruned).
|
||||
# With no arguments, checks ./pubspec.lock (passes if there is none).
|
||||
# Exit: 0 clean | 1 non-free sign-in found | 2 usage error
|
||||
# ============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_NAME="$(basename "$0")"
|
||||
readonly SCRIPT_NAME
|
||||
readonly BANNED=(google_sign_in google_sign_in_android google_sign_in_ios)
|
||||
readonly WEB_ONLY=(google_sign_in_web google_sign_in_platform_interface
|
||||
google_identity_services_web)
|
||||
# Maven coordinates (and split-form catalog names) of the GMS sign-in stack.
|
||||
# Anchored so com.google.gms:google-services (Firebase) and
|
||||
# play-services-auth-api-phone do not trip it.
|
||||
readonly GRADLE_PATTERN='com\.google\.android\.gms:play-services-(auth|identity-credentials)([^-[:alnum:]]|$)|com\.google\.android\.libraries\.identity\.googleid|androidx\.credentials:credentials-play-services-auth|name[[:space:]]*=[[:space:]]*"(play-services-auth|play-services-identity-credentials|credentials-play-services-auth|googleid)"'
|
||||
# APK mode: library marker files AGP copies to the APK root (or META-INF),
|
||||
# one per linked artifact. Dex strings are not used: play-services-base and
|
||||
# -basement reference GoogleSignInAccount without linking play-services-auth.
|
||||
# play-services-fido (passkeys) is deliberately allowed.
|
||||
readonly APK_MARKERS=(
|
||||
googleid.properties
|
||||
play-services-auth.properties
|
||||
play-services-auth-base.properties
|
||||
play-services-auth-api-phone.properties
|
||||
play-services-auth-blockstore.properties
|
||||
play-services-identity-credentials.properties
|
||||
META-INF/androidx.credentials_credentials-play-services-auth.version
|
||||
)
|
||||
readonly PRUNE=(-name build -o -name .dart_tool -o -name .git -o -name .gradle -o -name .claude
|
||||
-o -name node_modules)
|
||||
|
||||
usage() {
|
||||
echo "Usage: $SCRIPT_NAME [pubspec.lock | dir ...]"
|
||||
echo " $SCRIPT_NAME --apk app.apk"
|
||||
echo " Fails when an app links Google's non-free sign-in (GMS auth)."
|
||||
exit 0
|
||||
}
|
||||
|
||||
fix_hint() {
|
||||
echo " Use google_account_token instead (FOSS, AccountManager):" >&2
|
||||
echo " google_account_token:" >&2
|
||||
echo " git:" >&2
|
||||
echo " url: https://github.com/kuhyx/utils" >&2
|
||||
echo " ref: google_account_token-v0.1.0" >&2
|
||||
echo " path: google_account_token" >&2
|
||||
echo " See utils/google_account_token/README.md." >&2
|
||||
}
|
||||
|
||||
# Package keys of a pubspec.lock: the 2-space keys under `packages:`.
|
||||
lock_packages() {
|
||||
awk '
|
||||
/^packages:[[:space:]]*$/ { in_packages = 1; next }
|
||||
/^[^[:space:]]/ { in_packages = 0; next }
|
||||
in_packages && /^ [A-Za-z0-9_]+:[[:space:]]*$/ {
|
||||
sub(/^ /, ""); sub(/:.*/, ""); print
|
||||
}
|
||||
' "$1"
|
||||
}
|
||||
|
||||
# Prints the offending packages of one lock, one per line.
|
||||
lock_offenders() {
|
||||
local -A present=()
|
||||
local name
|
||||
while IFS= read -r name; do
|
||||
present["$name"]=1
|
||||
done < <(lock_packages "$1")
|
||||
for name in "${BANNED[@]}"; do
|
||||
[[ -n "${present[$name]:-}" ]] && echo "$name"
|
||||
done
|
||||
if [[ -n "${present[google_sign_in_android]:-}" ]]; then
|
||||
for name in "${WEB_ONLY[@]}"; do
|
||||
[[ -n "${present[$name]:-}" ]] && echo "$name"
|
||||
done
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# Prints file:line:text for every non-comment GMS sign-in coordinate under
|
||||
# the lock's sibling android/ dir.
|
||||
gradle_offenders() {
|
||||
local android_dir="$1/android" file
|
||||
[[ -d "$android_dir" ]] || return 0
|
||||
while IFS= read -r -d '' file; do
|
||||
grep -nE "$GRADLE_PATTERN" "$file" \
|
||||
| grep -vE '^[0-9]+:[[:space:]]*(//|#|\*|/\*)' \
|
||||
| sed "s|^|$file:|" || true
|
||||
done < <(find "$android_dir" \( "${PRUNE[@]}" \) -prune -o -type f \
|
||||
\( -name 'build.gradle*' -o -name '*.versions.toml' \) -print0)
|
||||
}
|
||||
|
||||
indent() {
|
||||
local line
|
||||
while IFS= read -r line; do
|
||||
echo " $line"
|
||||
done
|
||||
}
|
||||
|
||||
check_lock() {
|
||||
local lock="$1" offenders gradle
|
||||
offenders="$(lock_offenders "$lock")"
|
||||
gradle="$(gradle_offenders "$(dirname "$lock")")"
|
||||
[[ -z "$offenders" && -z "$gradle" ]] && return 0
|
||||
if [[ -n "$offenders" ]]; then
|
||||
echo "Error: $lock links Google's non-free sign-in:" >&2
|
||||
indent <<<"$offenders" >&2
|
||||
fi
|
||||
if [[ -n "$gradle" ]]; then
|
||||
echo "Error: gradle names the GMS sign-in stack:" >&2
|
||||
indent <<<"$gradle" >&2
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
check_apk() {
|
||||
local apk="$1" entries found=()
|
||||
local marker
|
||||
[[ -f "$apk" ]] || { echo "Error: no such APK: $apk" >&2; exit 2; }
|
||||
entries="$(unzip -Z1 "$apk")"
|
||||
for marker in "${APK_MARKERS[@]}"; do
|
||||
grep -qxF -- "$marker" <<<"$entries" && found+=("$marker")
|
||||
done
|
||||
[[ "${#found[@]}" -eq 0 ]] && return 0
|
||||
echo "Error: $apk bundles Google's non-free sign-in libraries:" >&2
|
||||
printf ' %s\n' "${found[@]}" >&2
|
||||
fix_hint
|
||||
return 1
|
||||
}
|
||||
|
||||
# Expands the arguments into pubspec.lock paths, NUL-separated.
|
||||
collect_locks() {
|
||||
local arg
|
||||
for arg in "$@"; do
|
||||
if [[ -d "$arg" ]]; then
|
||||
find "$arg" \( "${PRUNE[@]}" \) -prune -o -type f \
|
||||
-name pubspec.lock -print0
|
||||
else
|
||||
printf '%s\0' "$arg"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
main() {
|
||||
case "${1:-}" in
|
||||
-h|--help) usage ;;
|
||||
--apk)
|
||||
[[ $# -eq 2 ]] || { echo "Usage: $SCRIPT_NAME --apk app.apk" >&2; exit 2; }
|
||||
check_apk "$2"
|
||||
return
|
||||
;;
|
||||
esac
|
||||
if [[ $# -eq 0 ]]; then
|
||||
[[ -f pubspec.lock ]] || return 0
|
||||
set -- pubspec.lock
|
||||
fi
|
||||
|
||||
local -a locks=()
|
||||
local arg lock failed=0
|
||||
# Validated up front: collect_locks runs in a process substitution, whose
|
||||
# exit status would be lost, and a typo'd path must not pass silently.
|
||||
for arg in "$@"; do
|
||||
[[ -e "$arg" ]] || { echo "Error: no such file or directory: $arg" >&2; exit 2; }
|
||||
done
|
||||
mapfile -d '' locks < <(collect_locks "$@")
|
||||
for lock in "${locks[@]}"; do
|
||||
check_lock "$lock" || failed=1
|
||||
done
|
||||
[[ "$failed" -eq 0 ]] || fix_hint
|
||||
return "$failed"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@ -9,6 +9,7 @@
|
||||
# when the repo has .dep-freshness-no-workflow), Commands section. A website
|
||||
# (Vite/Astro/index.html build, or [agent_ready] in repo-contract.toml) also
|
||||
# gets the AgentReady gate: config table, shim, hook, agent-ready workflow.
|
||||
# A Flutter app with android/ also gets the no-GMS-sign-in gate (shim + hook).
|
||||
#
|
||||
# Usage: scripts/install_repo_contract_gate.sh <repo> [--check] [--no-bootstrap]
|
||||
# Exit: 0 contract satisfied | 1 violations left (e.g. <FILL IN>) | 2 not a git repo
|
||||
|
||||
195
scripts/tests/test_check_no_gms_signin.py
Normal file
195
scripts/tests/test_check_no_gms_signin.py
Normal file
@ -0,0 +1,195 @@
|
||||
"""check_no_gms_signin.sh: no app may link Google's non-free sign-in stack.
|
||||
|
||||
Each test writes a throwaway Flutter-shaped project (pubspec.lock, optional
|
||||
android/ gradle files) or a fake APK built with zipfile, runs the real
|
||||
script, and asserts on the exit code and the message.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT = Path(__file__).resolve().parent.parent / "check_no_gms_signin.sh"
|
||||
|
||||
# The package keys of kuhy's real kartka lock (web-only GIS button).
|
||||
KARTKA_KEYS = (
|
||||
"google_account_token",
|
||||
"google_identity_services_web",
|
||||
"google_sign_in_platform_interface",
|
||||
"google_sign_in_web",
|
||||
)
|
||||
|
||||
|
||||
def _lock(root: Path, *packages: str) -> Path:
|
||||
"""Write a pubspec.lock under `root` with the given package keys."""
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
body = ["# Generated by pub", "packages:"]
|
||||
for name in ("collection", *packages):
|
||||
body += [
|
||||
f" {name}:",
|
||||
' dependency: "direct main"',
|
||||
" description:",
|
||||
f" name: {name}",
|
||||
" source: hosted",
|
||||
' version: "1.0.0"',
|
||||
]
|
||||
body += ["sdks:", ' dart: ">=3.0.0 <4.0.0"', ""]
|
||||
lock = root / "pubspec.lock"
|
||||
lock.write_text("\n".join(body), encoding="utf-8")
|
||||
return lock
|
||||
|
||||
|
||||
def _run(*args: str | Path) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
["bash", str(SCRIPT), *map(str, args)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
def test_clean_lock_passes(tmp_path: Path) -> None:
|
||||
lock = _lock(tmp_path, "google_account_token", "http")
|
||||
assert _run(lock).returncode == 0
|
||||
|
||||
|
||||
def test_google_sign_in_android_fails_and_names_the_fix(tmp_path: Path) -> None:
|
||||
lock = _lock(tmp_path, "google_sign_in_android")
|
||||
proc = _run(lock)
|
||||
assert proc.returncode == 1
|
||||
assert str(lock) in proc.stderr
|
||||
assert "google_sign_in_android" in proc.stderr
|
||||
assert "google_account_token-v0.1.0" in proc.stderr
|
||||
assert "google_account_token/README.md" in proc.stderr
|
||||
|
||||
|
||||
def test_app_facing_google_sign_in_fails(tmp_path: Path) -> None:
|
||||
proc = _run(_lock(tmp_path, "google_sign_in"))
|
||||
assert proc.returncode == 1
|
||||
assert "google_sign_in " in proc.stderr or "google_sign_in\n" in proc.stderr
|
||||
|
||||
|
||||
def test_kartka_style_web_only_passes(tmp_path: Path) -> None:
|
||||
assert _run(_lock(tmp_path, *KARTKA_KEYS)).returncode == 0
|
||||
|
||||
|
||||
def test_web_pieces_fail_once_android_is_linked(tmp_path: Path) -> None:
|
||||
proc = _run(_lock(tmp_path, *KARTKA_KEYS, "google_sign_in_android"))
|
||||
assert proc.returncode == 1
|
||||
assert "google_sign_in_web" in proc.stderr
|
||||
|
||||
|
||||
def test_gradle_play_services_auth_fails(tmp_path: Path) -> None:
|
||||
lock = _lock(tmp_path)
|
||||
gradle = tmp_path / "android" / "app" / "build.gradle.kts"
|
||||
gradle.parent.mkdir(parents=True)
|
||||
gradle.write_text(
|
||||
'dependencies {\n implementation("com.google.gms:google-services:4")\n'
|
||||
' implementation("com.google.android.gms:play-services-auth:21.3.0")\n}\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
proc = _run(lock)
|
||||
assert proc.returncode == 1
|
||||
assert str(gradle) in proc.stderr
|
||||
assert "play-services-auth" in proc.stderr
|
||||
|
||||
|
||||
def test_versions_toml_googleid_fails(tmp_path: Path) -> None:
|
||||
lock = _lock(tmp_path)
|
||||
toml = tmp_path / "android" / "gradle" / "libs.versions.toml"
|
||||
toml.parent.mkdir(parents=True)
|
||||
toml.write_text(
|
||||
'googleid = "com.google.android.libraries.identity.googleid:googleid:1.1"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert _run(lock).returncode == 1
|
||||
|
||||
|
||||
def test_firebase_google_services_plugin_passes(tmp_path: Path) -> None:
|
||||
lock = _lock(tmp_path)
|
||||
gradle = tmp_path / "android" / "build.gradle"
|
||||
gradle.parent.mkdir(parents=True)
|
||||
gradle.write_text(
|
||||
"// play-services-auth was removed 2026-11-02\n"
|
||||
"classpath 'com.google.gms:google-services:4.4.2'\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert _run(lock).returncode == 0
|
||||
|
||||
|
||||
def test_directory_mode_finds_nested_locks_and_prunes_build(tmp_path: Path) -> None:
|
||||
_lock(tmp_path / "app", "google_account_token")
|
||||
_lock(tmp_path / "app" / "build" / "x", "google_sign_in_android")
|
||||
_lock(tmp_path / ".dart_tool" / "y", "google_sign_in_android")
|
||||
# Agent worktrees are other checkouts, judged in their own repo.
|
||||
_lock(tmp_path / ".claude" / "worktrees" / "z", "google_sign_in_android")
|
||||
assert _run(tmp_path).returncode == 0
|
||||
_lock(tmp_path / "other", "google_sign_in_ios")
|
||||
proc = _run(tmp_path)
|
||||
assert proc.returncode == 1
|
||||
assert "google_sign_in_ios" in proc.stderr
|
||||
|
||||
|
||||
def test_missing_explicit_file_is_a_usage_error(tmp_path: Path) -> None:
|
||||
assert _run(tmp_path / "nope.lock").returncode == 2
|
||||
|
||||
|
||||
CREDENTIALS_MARKER = "META-INF/androidx.credentials_credentials-play-services-auth.version"
|
||||
DEX_REF = b"\x00Lcom/google/android/gms/auth/api/signin/GoogleSignInAccount;\x00" * 2
|
||||
|
||||
|
||||
def _apk(path: Path, dex: bytes = b"", markers: tuple[str, ...] = ()) -> Path:
|
||||
"""Fake APK: dex strings plus library marker files AGP copies in."""
|
||||
with zipfile.ZipFile(path, "w") as apk:
|
||||
apk.writestr("AndroidManifest.xml", b"\x03\x00")
|
||||
apk.writestr("classes.dex", b"dex\n035\x00" + dex)
|
||||
apk.writestr("classes2.dex", b"dex\n035\x00Lkotlin/Unit;\x00")
|
||||
for name in markers:
|
||||
apk.writestr(name, b"version=1")
|
||||
return path
|
||||
|
||||
|
||||
def test_apk_with_only_dex_references_passes(tmp_path: Path) -> None:
|
||||
"""play-services-base references GoogleSignInAccount without linking auth."""
|
||||
markers = (
|
||||
"play-services-base.properties",
|
||||
"play-services-basement.properties",
|
||||
"play-services-tasks.properties",
|
||||
"play-services-fido.properties",
|
||||
)
|
||||
proc = _run("--apk", _apk(tmp_path / "ok.apk", DEX_REF, markers))
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
|
||||
|
||||
def test_apk_without_markers_passes(tmp_path: Path) -> None:
|
||||
assert _run("--apk", _apk(tmp_path / "clean.apk")).returncode == 0
|
||||
|
||||
|
||||
def test_apk_banned_markers_fail_and_are_named(tmp_path: Path) -> None:
|
||||
banned = (
|
||||
"play-services-auth.properties",
|
||||
"googleid.properties",
|
||||
"play-services-auth-base.properties",
|
||||
"play-services-auth-api-phone.properties",
|
||||
"play-services-auth-blockstore.properties",
|
||||
"play-services-identity-credentials.properties",
|
||||
CREDENTIALS_MARKER,
|
||||
)
|
||||
for marker in banned:
|
||||
apk = _apk(tmp_path / "bad.apk", DEX_REF, ("play-services-base.properties", marker))
|
||||
proc = _run("--apk", apk)
|
||||
assert proc.returncode == 1, marker
|
||||
assert marker in proc.stderr
|
||||
assert "google_account_token" in proc.stderr
|
||||
|
||||
|
||||
def test_apk_nested_marker_name_is_not_a_root_marker(tmp_path: Path) -> None:
|
||||
"""Only APK-root files count; a same-named file in a subdir is not AGP's."""
|
||||
apk = _apk(tmp_path / "nested.apk", markers=("assets/play-services-auth.properties",))
|
||||
assert _run("--apk", apk).returncode == 0
|
||||
|
||||
|
||||
def test_apk_missing_file_is_a_usage_error(tmp_path: Path) -> None:
|
||||
assert _run("--apk", tmp_path / "nope.apk").returncode == 2
|
||||
@ -70,22 +70,35 @@ refused (past day, already sick, not sick, a free day, sync unavailable),
|
||||
## 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
|
||||
Tk -- calendar, a six-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):
|
||||
|
||||
No words inside the window (textgate): dates are ISO numerals (`2026-11`,
|
||||
`2026-11-27`), each locker is a drawn pictogram (dumbbell = workout, `</>` =
|
||||
LeetCode, open book, apple = diet, alarm clock, target = focus), switches are
|
||||
knob-right-on-accent / knob-left-on-dark, buttons are check (mark / save),
|
||||
red bin (clear) and cross (cancel). A refusal is an icon in the status slot:
|
||||
padlock (past), no-entry (free day), warning (cannot check, or a refused
|
||||
write; the detail goes to the log), hourglass (a sync is still running).
|
||||
Icons are Canvas geometry, not font glyphs: the venv's Tk uses core X fonts,
|
||||
where non-ASCII glyphs render as nothing. The title bar keeps "Sick days"
|
||||
(an app's name, textgate settled decision 4). The guard is
|
||||
`tests/test_gui_no_words.py`: no letter on any widget, in any state.
|
||||
|
||||
```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 refusal (or a click mid-sync) shows in the panel.
|
||||
Normal day: six switches, all off (off = stands down), `actions.mark`; sick
|
||||
day: its switches, `actions.save`, `actions.clear`. Past/free/uncheckable
|
||||
days show their icon and offer no write; a refusal (or a click mid-sync)
|
||||
shows in the panel's `status` slot, named by `panel.reason`.
|
||||
`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.
|
||||
mark / a changed save (`is_sick=True`) or clear (`is_sick=False`), `None`
|
||||
on an unchanged save (nothing written). The panel never destroys itself.
|
||||
|
||||
## API
|
||||
|
||||
|
||||
@ -120,6 +120,9 @@ disable = []
|
||||
min-public-methods = 0
|
||||
max-module-lines = 1000
|
||||
max-attributes = 10
|
||||
# A tk.Canvas subclass (gui/_controls.py) has 9 ancestors by construction:
|
||||
# Canvas mixes in XView and YView on top of Widget's chain.
|
||||
max-parents = 9
|
||||
|
||||
[tool.pylint.typecheck]
|
||||
generated-members = [
|
||||
|
||||
@ -1,29 +1,33 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""The month grid: Monday-first, one button per day, prev/next month arrows.
|
||||
"""The month grid: Monday-first, one button per day, prev/next month chevrons.
|
||||
|
||||
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.
|
||||
with the window; this only reports the date. No words: the month reads
|
||||
``2026-11`` and the weekday row is dots, with rings on Saturday and Sunday.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tkinter as tk
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
from sickdays._day import to_iso
|
||||
from sickdays.gui import _text
|
||||
from sickdays.gui import _dates
|
||||
from sickdays.gui import _tokens as tok
|
||||
from sickdays.gui import _widgets as ui
|
||||
from sickdays.gui._controls import IconButton, IconSlot
|
||||
|
||||
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}"
|
||||
#: The weekday marks over the grid, in px.
|
||||
_MARK_PX: Final = 10
|
||||
#: The keep-a-locker dot inside a sick day's cell, in px.
|
||||
_DOT_PX: Final = 8
|
||||
|
||||
|
||||
def _cell_colours(*, sick: bool, past: bool, workday: bool) -> tuple[str, str]:
|
||||
@ -56,7 +60,7 @@ class MonthCalendar(tk.Frame):
|
||||
self._is_sick = is_sick
|
||||
self._has_dot = has_dot
|
||||
self._on_day = on_day
|
||||
self._first, self._last = _text.month_bounds(today)
|
||||
self._first, self._last = _dates.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")
|
||||
@ -66,7 +70,7 @@ class MonthCalendar(tk.Frame):
|
||||
|
||||
def step(self, months: int) -> None:
|
||||
"""Move ``months`` months (within the phone's range) and redraw."""
|
||||
target = _text.shift_month(self.month, months)
|
||||
target = _dates.shift_month(self.month, months)
|
||||
if self._first <= target <= self._last:
|
||||
self.month = target
|
||||
self.redraw()
|
||||
@ -77,11 +81,12 @@ class MonthCalendar(tk.Frame):
|
||||
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 column in range(7):
|
||||
mark = "ring" if _dates.is_weekend_column(column) else "dot"
|
||||
IconSlot(self._grid, size=_MARK_PX).show(mark, tok.MUTED_ON_DARK).grid(
|
||||
row=0, column=column, pady=(0, tok.XS)
|
||||
)
|
||||
for row, week in enumerate(_text.month_weeks(self.month), start=1):
|
||||
for row, week in enumerate(_dates.month_weeks(self.month), start=1):
|
||||
for column, day in enumerate(week):
|
||||
if day is not None:
|
||||
self._cell(day).grid(
|
||||
@ -89,20 +94,20 @@ class MonthCalendar(tk.Frame):
|
||||
)
|
||||
|
||||
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 = IconButton(self._header, "prev", lambda: self.step(-1), name="prev")
|
||||
forward = IconButton(self._header, "next", lambda: self.step(1), name="next")
|
||||
back.set_enabled(self.month > self._first)
|
||||
forward.set_enabled(self.month < self._last)
|
||||
back.pack(side="left")
|
||||
forward.pack(side="right")
|
||||
ui.label(self._header, _text.month_title(self.month), "subtitle").pack(
|
||||
ui.label(self._header, _dates.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))
|
||||
fill, text = _cell_colours(sick=sick, past=past, workday=_dates.is_workday(day))
|
||||
cell = tk.Button(
|
||||
self._grid,
|
||||
name=f"day-{to_iso(day)}",
|
||||
@ -124,7 +129,6 @@ class MonthCalendar(tk.Frame):
|
||||
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")
|
||||
dot = IconSlot(cell, name="dot", size=_DOT_PX).show("dot", text)
|
||||
dot.place(relx=1.0, x=-2, y=2, anchor="ne")
|
||||
return cell
|
||||
|
||||
171
sickdays/sickdays/gui/_controls.py
Normal file
171
sickdays/sickdays/gui/_controls.py
Normal file
@ -0,0 +1,171 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""Word-free controls: an icon button, an on/off switch and an icon slot.
|
||||
|
||||
Canvases, because a Tk Button or Checkbutton can only show its state as a
|
||||
word or a font glyph (see :mod:`sickdays.gui._icons`). Each keeps Tk's own
|
||||
``state`` option and offers ``invoke()``, so callers and tests drive them
|
||||
like the widgets they replace. Every state has two channels: shape (icon,
|
||||
knob position) plus colour.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tkinter as tk
|
||||
from typing import TYPE_CHECKING, Final, Literal
|
||||
|
||||
from sickdays.gui import _tokens as tok
|
||||
from sickdays.gui._icons import TAG, draw
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
ButtonKind = Literal["primary", "danger", "plain"]
|
||||
|
||||
#: (fill, icon colour) per button kind; danger keeps its red on a dark fill.
|
||||
_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),
|
||||
}
|
||||
ICON_PX: Final = 20
|
||||
_TRACK_W: Final = 44
|
||||
_TRACK_H: Final = 24
|
||||
|
||||
|
||||
def _enabled(widget: tk.Canvas) -> bool:
|
||||
return str(widget.cget("state")) != "disabled"
|
||||
|
||||
|
||||
class IconButton(tk.Canvas):
|
||||
"""A flat button showing ``icon``; ``invoke()`` runs ``command`` unless disabled."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: tk.Misc,
|
||||
icon: str,
|
||||
command: Callable[[], object],
|
||||
*,
|
||||
name: str,
|
||||
kind: ButtonKind = "plain",
|
||||
) -> None:
|
||||
"""Draw the button; a click anywhere on it invokes it."""
|
||||
fill, self._colour = _BUTTON_COLOURS[kind]
|
||||
super().__init__(
|
||||
parent,
|
||||
name=name,
|
||||
width=ICON_PX + 2 * tok.MD,
|
||||
height=ICON_PX + 2 * tok.SM,
|
||||
bg=fill,
|
||||
highlightthickness=0,
|
||||
borderwidth=0,
|
||||
cursor="hand2",
|
||||
)
|
||||
#: Which pictogram this button shows.
|
||||
self.icon = icon
|
||||
self._command = command
|
||||
self.bind("<Button-1>", lambda _event: self.invoke())
|
||||
self._paint()
|
||||
|
||||
def set_enabled(self, enabled: bool) -> None: # noqa: FBT001
|
||||
"""Grey the icon out and ignore clicks while disabled."""
|
||||
self.configure(
|
||||
state="normal" if enabled else "disabled",
|
||||
cursor="hand2" if enabled else "arrow",
|
||||
)
|
||||
self._paint()
|
||||
|
||||
def invoke(self) -> None:
|
||||
"""Run the command, as a click would."""
|
||||
if _enabled(self):
|
||||
self._command()
|
||||
|
||||
def _paint(self) -> None:
|
||||
self.delete(TAG)
|
||||
colour = self._colour if _enabled(self) else tok.MUTED_ON_DARK
|
||||
draw(self, self.icon, size=ICON_PX, colour=colour, at=(tok.MD, tok.SM))
|
||||
|
||||
|
||||
class Toggle(tk.Canvas):
|
||||
"""An on/off switch for ``variable``: knob right on accent, left on dark."""
|
||||
|
||||
def __init__(self, parent: tk.Misc, variable: tk.BooleanVar, *, name: str) -> None:
|
||||
"""Draw the switch in ``variable``'s current state."""
|
||||
super().__init__(
|
||||
parent,
|
||||
name=name,
|
||||
width=_TRACK_W,
|
||||
height=_TRACK_H,
|
||||
bg=parent.cget("bg"),
|
||||
highlightthickness=0,
|
||||
borderwidth=0,
|
||||
cursor="hand2",
|
||||
)
|
||||
self._variable = variable
|
||||
self.bind("<Button-1>", lambda _event: self.invoke())
|
||||
self.paint()
|
||||
|
||||
def set_enabled(self, enabled: bool) -> None: # noqa: FBT001
|
||||
"""Fade the switch and ignore clicks while disabled."""
|
||||
self.configure(
|
||||
state="normal" if enabled else "disabled",
|
||||
cursor="hand2" if enabled else "arrow",
|
||||
)
|
||||
self.paint()
|
||||
|
||||
def invoke(self) -> None:
|
||||
"""Flip the switch, as a click would."""
|
||||
if _enabled(self):
|
||||
self._variable.set(not self._variable.get())
|
||||
self.paint()
|
||||
|
||||
def is_on(self) -> bool:
|
||||
"""Whether the locker this switch stands for still applies."""
|
||||
return bool(self._variable.get())
|
||||
|
||||
def paint(self) -> None:
|
||||
"""Redraw the track and knob from the variable."""
|
||||
self.delete(TAG)
|
||||
on = self.is_on()
|
||||
track, knob = (
|
||||
(tok.ACCENT, tok.ON_FILL) if on else (tok.INK_RAISED_2, tok.MUTED_ON_DARK)
|
||||
)
|
||||
if not _enabled(self):
|
||||
track = tok.blend(track, tok.INK, tok.PAST_ALPHA)
|
||||
knob = tok.blend(knob, tok.INK, tok.PAST_ALPHA)
|
||||
h, w = _TRACK_H, _TRACK_W
|
||||
self.create_oval(0, 0, h, h, fill=track, outline=track, tags=TAG)
|
||||
self.create_oval(w - h, 0, w, h, fill=track, outline=track, tags=TAG)
|
||||
self.create_rectangle(
|
||||
h // 2, 0, w - h // 2, h, fill=track, outline=track, tags=TAG
|
||||
)
|
||||
left = w - h + 3 if on else 3
|
||||
self.create_oval(
|
||||
left, 3, left + h - 6, h - 3, fill=knob, outline=knob, tags=TAG
|
||||
)
|
||||
|
||||
|
||||
class IconSlot(tk.Canvas):
|
||||
"""One icon, or nothing: a status mark, a locker's pictogram, a marker."""
|
||||
|
||||
def __init__(self, parent: tk.Misc, *, name: str = "", size: int = ICON_PX) -> None:
|
||||
"""An empty slot ``size`` px square on ``parent``'s background."""
|
||||
super().__init__(
|
||||
parent,
|
||||
name=name,
|
||||
width=size,
|
||||
height=size,
|
||||
bg=parent.cget("bg"),
|
||||
highlightthickness=0,
|
||||
borderwidth=0,
|
||||
)
|
||||
self._size = size
|
||||
#: The pictogram shown, or ``None`` when the slot is empty.
|
||||
self.icon: str | None = None
|
||||
|
||||
def show(self, icon: str | None, colour: str = tok.TEXT_ON_DARK) -> IconSlot:
|
||||
"""Show ``icon`` in ``colour`` (``None`` clears the slot)."""
|
||||
self.delete(TAG)
|
||||
self.icon = icon
|
||||
if icon is not None:
|
||||
draw(self, icon, size=self._size, colour=colour)
|
||||
return self
|
||||
86
sickdays/sickdays/gui/_dates.py
Normal file
86
sickdays/sickdays/gui/_dates.py
Normal file
@ -0,0 +1,86 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""Calendar arithmetic and the free-day check for the picker -- no Tk in here.
|
||||
|
||||
The picker shows no words (kuhy's textgate rule): dates are ISO numerals and
|
||||
a refusal is a reason *code* the widgets turn into an icon. The library's
|
||||
own message for an unavailable check goes to the log, not the screen.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import calendar
|
||||
from datetime import date
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Final, Literal
|
||||
|
||||
from sickdays._conflict import free_day_predicate
|
||||
from sickdays._constants import LOCKERS
|
||||
from sickdays._errors import ConflictCheckUnavailableError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterable
|
||||
|
||||
_LOG = logging.getLogger(__name__)
|
||||
|
||||
#: Why a day cannot be marked sick: it is a free day, or that cannot be checked.
|
||||
Block = Literal["free", "unavailable"]
|
||||
|
||||
#: Tue/Wed/Thu, shaded like the phone's grid (wake-alarm's ``isWorkday``).
|
||||
_WORKDAYS: Final = frozenset({1, 2, 3})
|
||||
#: Sat/Sun, marked with a ring instead of a dot over the grid.
|
||||
_WEEKEND: Final = frozenset({5, 6})
|
||||
|
||||
|
||||
def kept_in_order(kept: Iterable[str]) -> tuple[str, ...]:
|
||||
"""The kept lockers in ``sickdays.LOCKERS`` order, not the set's."""
|
||||
chosen = set(kept)
|
||||
return tuple(locker for locker in LOCKERS if locker in chosen)
|
||||
|
||||
|
||||
def free_day_block(day: date) -> Block | None:
|
||||
"""Why ``day`` cannot be marked sick for the free-day pool, or None.
|
||||
|
||||
"Could not check" refuses too: it must never count as "not a free day".
|
||||
"""
|
||||
try:
|
||||
is_free = free_day_predicate()
|
||||
except ConflictCheckUnavailableError as exc:
|
||||
_LOG.warning("free-day check unavailable: %s", exc)
|
||||
return "unavailable"
|
||||
return "free" if is_free(day) else None
|
||||
|
||||
|
||||
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 is_weekend_column(column: int) -> bool:
|
||||
"""Whether grid ``column`` (0 = Monday) is Saturday or Sunday."""
|
||||
return column in _WEEKEND
|
||||
|
||||
|
||||
def month_title(month: date) -> str:
|
||||
"""``"2026-10"``: the month as numerals, never a month name."""
|
||||
return f"{month.year}-{month.month:02d}"
|
||||
|
||||
|
||||
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)
|
||||
235
sickdays/sickdays/gui/_icons.py
Normal file
235
sickdays/sickdays/gui/_icons.py
Normal file
@ -0,0 +1,235 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""Pictograms drawn as Canvas geometry, so the picker needs no words.
|
||||
|
||||
Drawn rather than typed: the venv's Tk falls back to core X fonts, where
|
||||
every non-ASCII glyph (angle quotes, check marks) renders as nothing; the
|
||||
old month arrows were blank buttons. Each icon is plotted on a unit square,
|
||||
scaled to ``size`` pixels, and tagged ``icon`` so a repaint can drop it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
from sickdays._constants import (
|
||||
LOCKER_ALARM,
|
||||
LOCKER_BOOK,
|
||||
LOCKER_DIET,
|
||||
LOCKER_FOCUS,
|
||||
LOCKER_LEETCODE,
|
||||
LOCKER_WORKOUT,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
import tkinter as tk
|
||||
|
||||
TAG: Final = "icon"
|
||||
|
||||
|
||||
class Pen:
|
||||
"""Draws unit-square coordinates onto ``canvas`` at ``(x, y)``, ``size`` wide."""
|
||||
|
||||
def __init__(
|
||||
self, canvas: tk.Canvas, *, size: int, colour: str, at: tuple[int, int]
|
||||
) -> None:
|
||||
"""Remember where and how large to draw."""
|
||||
self._canvas = canvas
|
||||
self._size = size
|
||||
self._colour = colour
|
||||
self._x, self._y = at
|
||||
self.width = max(2, round(size / 10))
|
||||
|
||||
def _xy(self, points: tuple[float, ...]) -> list[float]:
|
||||
return [
|
||||
(self._y if i % 2 else self._x) + value * self._size
|
||||
for i, value in enumerate(points)
|
||||
]
|
||||
|
||||
def line(self, *points: float) -> None:
|
||||
"""A stroke through ``points`` (x0, y0, x1, y1, ...)."""
|
||||
self._canvas.create_line(
|
||||
*self._xy(points),
|
||||
fill=self._colour,
|
||||
width=self.width,
|
||||
capstyle="round",
|
||||
joinstyle="round",
|
||||
tags=TAG,
|
||||
)
|
||||
|
||||
def oval(self, *box: float, filled: bool = False) -> None:
|
||||
"""An ellipse in ``box`` (x0, y0, x1, y1), outlined or filled."""
|
||||
self._canvas.create_oval(
|
||||
*self._xy(box),
|
||||
outline=self._colour,
|
||||
fill=self._colour if filled else "",
|
||||
width=self.width,
|
||||
tags=TAG,
|
||||
)
|
||||
|
||||
def shape(self, *points: float, filled: bool = False) -> None:
|
||||
"""A closed polygon through ``points``, outlined or filled."""
|
||||
self._canvas.create_polygon(
|
||||
*self._xy(points),
|
||||
outline=self._colour,
|
||||
fill=self._colour if filled else "",
|
||||
width=self.width,
|
||||
joinstyle="round",
|
||||
tags=TAG,
|
||||
)
|
||||
|
||||
def arch(self, *box: float) -> None:
|
||||
"""The top half of the ellipse in ``box`` (a padlock's shackle)."""
|
||||
self._canvas.create_arc(
|
||||
*self._xy(box),
|
||||
start=0,
|
||||
extent=180,
|
||||
style="arc",
|
||||
outline=self._colour,
|
||||
width=self.width,
|
||||
tags=TAG,
|
||||
)
|
||||
|
||||
|
||||
def _check(pen: Pen) -> None:
|
||||
pen.line(0.2, 0.55, 0.42, 0.76, 0.82, 0.28)
|
||||
|
||||
|
||||
def _cross(pen: Pen) -> None:
|
||||
pen.line(0.25, 0.25, 0.75, 0.75)
|
||||
pen.line(0.75, 0.25, 0.25, 0.75)
|
||||
|
||||
|
||||
def _trash(pen: Pen) -> None:
|
||||
pen.line(0.18, 0.26, 0.82, 0.26)
|
||||
pen.line(0.4, 0.26, 0.42, 0.14, 0.58, 0.14, 0.6, 0.26)
|
||||
pen.shape(0.27, 0.34, 0.73, 0.34, 0.68, 0.88, 0.32, 0.88)
|
||||
|
||||
|
||||
def _prev(pen: Pen) -> None:
|
||||
pen.line(0.62, 0.22, 0.36, 0.5, 0.62, 0.78)
|
||||
|
||||
|
||||
def _next(pen: Pen) -> None:
|
||||
pen.line(0.38, 0.22, 0.64, 0.5, 0.38, 0.78)
|
||||
|
||||
|
||||
def _workout(pen: Pen) -> None:
|
||||
pen.line(0.2, 0.5, 0.8, 0.5)
|
||||
pen.shape(0.17, 0.28, 0.29, 0.28, 0.29, 0.72, 0.17, 0.72, filled=True)
|
||||
pen.shape(0.71, 0.28, 0.83, 0.28, 0.83, 0.72, 0.71, 0.72, filled=True)
|
||||
pen.line(0.08, 0.5, 0.17, 0.5)
|
||||
pen.line(0.83, 0.5, 0.92, 0.5)
|
||||
|
||||
|
||||
def _leetcode(pen: Pen) -> None:
|
||||
pen.line(0.36, 0.28, 0.12, 0.5, 0.36, 0.72)
|
||||
pen.line(0.64, 0.28, 0.88, 0.5, 0.64, 0.72)
|
||||
pen.line(0.57, 0.2, 0.43, 0.8)
|
||||
|
||||
|
||||
def _book(pen: Pen) -> None:
|
||||
pen.shape(0.5, 0.3, 0.1, 0.2, 0.1, 0.76, 0.5, 0.86)
|
||||
pen.shape(0.5, 0.3, 0.9, 0.2, 0.9, 0.76, 0.5, 0.86)
|
||||
|
||||
|
||||
def _diet(pen: Pen) -> None:
|
||||
pen.oval(0.2, 0.34, 0.8, 0.9, filled=True)
|
||||
pen.line(0.5, 0.34, 0.54, 0.14)
|
||||
pen.oval(0.56, 0.12, 0.78, 0.26, filled=True)
|
||||
|
||||
|
||||
def _alarm(pen: Pen) -> None:
|
||||
pen.oval(0.18, 0.24, 0.82, 0.88)
|
||||
pen.line(0.5, 0.38, 0.5, 0.56, 0.63, 0.64)
|
||||
pen.oval(0.08, 0.1, 0.3, 0.3, filled=True)
|
||||
pen.oval(0.7, 0.1, 0.92, 0.3, filled=True)
|
||||
|
||||
|
||||
def _focus(pen: Pen) -> None:
|
||||
pen.oval(0.1, 0.1, 0.9, 0.9)
|
||||
pen.oval(0.3, 0.3, 0.7, 0.7)
|
||||
pen.oval(0.44, 0.44, 0.56, 0.56, filled=True)
|
||||
|
||||
|
||||
def _padlock(pen: Pen) -> None:
|
||||
pen.arch(0.3, 0.12, 0.7, 0.62)
|
||||
pen.shape(0.22, 0.44, 0.78, 0.44, 0.78, 0.88, 0.22, 0.88, filled=True)
|
||||
|
||||
|
||||
def _no_entry(pen: Pen) -> None:
|
||||
pen.oval(0.14, 0.14, 0.86, 0.86)
|
||||
pen.line(0.28, 0.28, 0.72, 0.72)
|
||||
|
||||
|
||||
def _warning(pen: Pen) -> None:
|
||||
pen.shape(0.5, 0.12, 0.9, 0.86, 0.1, 0.86)
|
||||
pen.line(0.5, 0.38, 0.5, 0.6)
|
||||
pen.oval(0.47, 0.71, 0.53, 0.77, filled=True)
|
||||
|
||||
|
||||
def _hourglass(pen: Pen) -> None:
|
||||
pen.line(0.22, 0.12, 0.78, 0.12)
|
||||
pen.line(0.22, 0.88, 0.78, 0.88)
|
||||
pen.shape(0.28, 0.12, 0.72, 0.12, 0.28, 0.88, 0.72, 0.88)
|
||||
|
||||
|
||||
def _sick(pen: Pen) -> None:
|
||||
pen.shape(0.15, 0.15, 0.85, 0.15, 0.85, 0.85, 0.15, 0.85, filled=True)
|
||||
|
||||
|
||||
def _normal(pen: Pen) -> None:
|
||||
pen.shape(0.15, 0.15, 0.85, 0.15, 0.85, 0.85, 0.15, 0.85)
|
||||
|
||||
|
||||
def _dot(pen: Pen) -> None:
|
||||
pen.oval(0.3, 0.3, 0.7, 0.7, filled=True)
|
||||
|
||||
|
||||
def _ring(pen: Pen) -> None:
|
||||
pen.oval(0.25, 0.25, 0.75, 0.75)
|
||||
|
||||
|
||||
ICONS: Final[dict[str, Callable[[Pen], None]]] = {
|
||||
"check": _check,
|
||||
"cross": _cross,
|
||||
"trash": _trash,
|
||||
"prev": _prev,
|
||||
"next": _next,
|
||||
"workout": _workout,
|
||||
"leetcode": _leetcode,
|
||||
"book": _book,
|
||||
"diet": _diet,
|
||||
"alarm": _alarm,
|
||||
"focus": _focus,
|
||||
"padlock": _padlock,
|
||||
"no_entry": _no_entry,
|
||||
"warning": _warning,
|
||||
"hourglass": _hourglass,
|
||||
"sick": _sick,
|
||||
"normal": _normal,
|
||||
"dot": _dot,
|
||||
"ring": _ring,
|
||||
}
|
||||
|
||||
#: The pictogram each locker is shown as, in place of its name.
|
||||
LOCKER_ICONS: Final = {
|
||||
LOCKER_WORKOUT: "workout",
|
||||
LOCKER_LEETCODE: "leetcode",
|
||||
LOCKER_BOOK: "book",
|
||||
LOCKER_DIET: "diet",
|
||||
LOCKER_ALARM: "alarm",
|
||||
LOCKER_FOCUS: "focus",
|
||||
}
|
||||
|
||||
|
||||
def draw(
|
||||
canvas: tk.Canvas,
|
||||
icon: str,
|
||||
*,
|
||||
size: int,
|
||||
colour: str,
|
||||
at: tuple[int, int] = (0, 0),
|
||||
) -> None:
|
||||
"""Draw ``icon`` on ``canvas`` in a ``size`` px square whose corner is ``at``."""
|
||||
ICONS[icon](Pen(canvas, size=size, colour=colour, at=at))
|
||||
@ -1,5 +1,5 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""KeepPanel: the five switches for one day, and the buttons that write them.
|
||||
"""KeepPanel: a switch per locker 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,
|
||||
@ -7,29 +7,34 @@ 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.
|
||||
-- the phone's ``SickDaySheet``. A normal day offers "mark sick" (check); a
|
||||
sick day offers "save" (check, a keep-only edit) and "clear" (red bin). A
|
||||
past day (padlock), a free day (no-entry sign) or a free-day pool that cannot
|
||||
be checked (warning) shows its icon and offers no write. A refused write is
|
||||
shown in the panel as an icon, never raised: an exception in a Tk callback
|
||||
only reaches stderr, which nobody sees under a lock. The panel shows no
|
||||
words (kuhy's textgate rule): a locker is its pictogram, the day its ISO date.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import tkinter as tk
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
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._day import to_iso, 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 _dates
|
||||
from sickdays.gui import _tokens as tok
|
||||
from sickdays.gui import _widgets as ui
|
||||
from sickdays.gui._controls import IconButton, IconSlot, Toggle
|
||||
from sickdays.gui._icons import LOCKER_ICONS
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
@ -39,6 +44,17 @@ if TYPE_CHECKING:
|
||||
from sickdays._paths import Paths
|
||||
|
||||
|
||||
_LOG = logging.getLogger(__name__)
|
||||
|
||||
#: (icon, colour) for each reason the panel shows, keyed by that reason.
|
||||
STATUS_ICONS: Final = {
|
||||
"past": ("padlock", tok.MUTED_ON_DARK),
|
||||
"free": ("no_entry", tok.DANGER),
|
||||
"unavailable": ("warning", tok.DANGER),
|
||||
"refused": ("warning", tok.DANGER),
|
||||
"busy": ("hourglass", tok.DANGER),
|
||||
}
|
||||
|
||||
#: Held by every background sync, and tried by every write. ``sync()`` reads
|
||||
#: the pool, waits on the network, then writes its merged copy back, so a
|
||||
#: write landing in between would be silently overwritten. A write that finds
|
||||
@ -73,16 +89,17 @@ class KeepPanel(tk.Frame):
|
||||
``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``).
|
||||
* ``actions.mark`` -> the new :class:`~sickdays.SickDay` (``is_sick=True``);
|
||||
* ``actions.save`` with a changed set -> the stored entry (``is_sick=True``);
|
||||
* ``actions.save`` with nothing changed -> ``None``: nothing written, no sync;
|
||||
* ``actions.clear`` -> the cleared entry (``is_sick=False``).
|
||||
|
||||
A refused write (:class:`~sickdays.SickDayError`, or a click while the
|
||||
previous write's sync is still running) 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.
|
||||
previous write's sync is still running) is shown in the panel's
|
||||
``status`` slot and ``on_saved`` is not called; :attr:`reason` names it
|
||||
(a :data:`STATUS_ICONS` key). ``on_cancel``, when given, adds an
|
||||
``actions.cancel`` cross that calls it. The panel never destroys itself;
|
||||
the callbacks decide what happens to it.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@ -107,12 +124,15 @@ class KeepPanel(tk.Frame):
|
||||
}
|
||||
#: The last background sync, for callers (and tests) that wait on it.
|
||||
self.sync_thread: threading.Thread | None = None
|
||||
#: Why the panel refuses, a :data:`STATUS_ICONS` key, or ``None``.
|
||||
self.reason: str | 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 = IconSlot(self, name="status")
|
||||
self._status.grid(
|
||||
row=len(LOCKERS) + 1, column=0, columnspan=2, sticky="w", pady=(tok.SM, 0)
|
||||
)
|
||||
self._status.grid(row=len(LOCKERS) + 2, column=0, columnspan=2, sticky="w")
|
||||
self._show(blocked)
|
||||
self._draw_actions(writable=blocked is None, on_cancel=on_cancel)
|
||||
|
||||
def kept(self) -> frozenset[str]:
|
||||
@ -121,49 +141,54 @@ class KeepPanel(tk.Frame):
|
||||
|
||||
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)
|
||||
return "past"
|
||||
return None if self._sick else _dates.free_day_block(self._day)
|
||||
|
||||
def _show(self, reason: str | None) -> None:
|
||||
self.reason = reason
|
||||
if reason is None:
|
||||
self._status.show(None)
|
||||
return
|
||||
icon, colour = STATUS_ICONS[reason]
|
||||
self._status.show(icon, colour)
|
||||
|
||||
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(
|
||||
head = tk.Frame(self, name="head", bg=self.cget("bg"))
|
||||
head.grid(row=0, column=0, columnspan=2, sticky="w", pady=(0, tok.SM))
|
||||
IconSlot(head, name="state").show(
|
||||
"sick" if self._sick else "normal", tok.SICK
|
||||
).pack(side="left", padx=(0, tok.SM))
|
||||
ui.label(head, to_iso(self._day), "subtitle", name="title").pack(side="left")
|
||||
for row, locker in enumerate(LOCKERS, start=1):
|
||||
IconSlot(self, name=f"icon-{locker}").show(LOCKER_ICONS[locker]).grid(
|
||||
row=row, column=0, sticky="w", pady=tok.XS
|
||||
)
|
||||
switch = ui.toggle(self, self._vars[locker], name=f"keep-{locker}")
|
||||
switch = 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")
|
||||
switch.set_enabled(False)
|
||||
|
||||
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)
|
||||
row=len(LOCKERS) + 2, column=0, columnspan=2, sticky="we", pady=(tok.MD, 0)
|
||||
)
|
||||
buttons: list[tk.Button] = []
|
||||
buttons: list[IconButton] = []
|
||||
if writable and self._sick:
|
||||
buttons.append(
|
||||
ui.button(actions, "Save", self._save, kind="primary", name="save")
|
||||
IconButton(actions, "check", self._save, kind="primary", name="save")
|
||||
)
|
||||
buttons.append(
|
||||
ui.button(
|
||||
actions, "Clear sick day", self._clear, kind="danger", name="clear"
|
||||
)
|
||||
IconButton(actions, "trash", self._clear, kind="danger", name="clear")
|
||||
)
|
||||
elif writable:
|
||||
buttons.append(
|
||||
ui.button(actions, "Mark sick", self._mark, kind="primary", name="mark")
|
||||
IconButton(actions, "check", self._mark, kind="primary", name="mark")
|
||||
)
|
||||
if on_cancel is not None:
|
||||
buttons.append(ui.button(actions, "Cancel", on_cancel, name="cancel"))
|
||||
buttons.append(IconButton(actions, "cross", on_cancel, name="cancel"))
|
||||
for button in buttons:
|
||||
button.pack(side="left", padx=(0, tok.SM))
|
||||
|
||||
@ -187,12 +212,13 @@ class KeepPanel(tk.Frame):
|
||||
# Non-blocking on purpose (no ``with``): waiting would freeze Tk for
|
||||
# the whole network round trip.
|
||||
if not _SYNC_LOCK.acquire(blocking=False): # pylint: disable=consider-using-with
|
||||
self._status.configure(text=_text.BUSY_NOTE, fg=tok.DANGER)
|
||||
self._show("busy")
|
||||
return
|
||||
try:
|
||||
entry = action()
|
||||
except SickDayError as exc:
|
||||
self._status.configure(text=str(exc), fg=tok.DANGER)
|
||||
_LOG.warning("sick-day write refused: %s", exc)
|
||||
self._show("refused")
|
||||
return
|
||||
finally:
|
||||
_SYNC_LOCK.release()
|
||||
|
||||
@ -1,125 +0,0 @@
|
||||
# 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_FOCUS,
|
||||
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",
|
||||
LOCKER_FOCUS: "Focus",
|
||||
}
|
||||
|
||||
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."
|
||||
BUSY_NOTE: Final = "Syncing \u2014 try again in a moment."
|
||||
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)
|
||||
@ -1,33 +1,20 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""Token-styled Tk building blocks shared by the panel and the window.
|
||||
"""Token-styled Tk labels for the numerals the picker shows (dates, months).
|
||||
|
||||
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.
|
||||
Every colour comes from :mod:`sickdays.gui._tokens`. Buttons, switches and
|
||||
icons are word-free Canvases in :mod:`sickdays.gui._controls`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tkinter as tk
|
||||
from tkinter import font as tkfont
|
||||
from typing import TYPE_CHECKING, Final, Literal
|
||||
from typing import Final
|
||||
|
||||
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."""
|
||||
@ -42,10 +29,7 @@ def label(
|
||||
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.
|
||||
"""
|
||||
"""A label on ``parent``'s own background; titles and subtitles are bold."""
|
||||
return tk.Label(
|
||||
parent,
|
||||
name=name,
|
||||
@ -55,71 +39,4 @@ def label(
|
||||
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
|
||||
|
||||
@ -2,26 +2,33 @@
|
||||
"""``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.
|
||||
the clicked day, the sick days still ahead underneath (each an ISO date and
|
||||
the pictograms of the lockers it keeps). The window class is ``sickdays``
|
||||
(Tk reports ``WM_CLASS "sickdays", "Sickdays"``) so an i3 ``for_window``
|
||||
rule can float it; ``Escape`` closes it.
|
||||
|
||||
No words inside the window (kuhy's textgate rule). The title bar keeps the
|
||||
app's name: an OS-level app name is a proper noun (textgate settled
|
||||
decision 4), the same as ``sickdays.desktop``'s ``Name=``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tkinter as tk
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
from sickdays._api import is_sick_day, load
|
||||
from sickdays._day import today
|
||||
from sickdays._day import to_iso, 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 _dates
|
||||
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
|
||||
from sickdays.gui._controls import IconSlot
|
||||
from sickdays.gui._icons import LOCKER_ICONS
|
||||
from sickdays.gui._panel import STATUS_ICONS, KeepPanel
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datetime import date
|
||||
@ -30,19 +37,18 @@ if TYPE_CHECKING:
|
||||
from sickdays._paths import Paths
|
||||
|
||||
WINDOW_CLASS = "sickdays"
|
||||
WINDOW_TITLE = "Sick days"
|
||||
#: A kept locker's pictogram in the list of days ahead, in px.
|
||||
_KEPT_PX: Final = 16
|
||||
|
||||
|
||||
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"
|
||||
)
|
||||
def saved_icon(entry: SickDay | None) -> str | None:
|
||||
"""The status mark after the panel closed with ``entry``.
|
||||
|
||||
A check for any write (the grid and the list show what it was); nothing
|
||||
when the panel was saved unchanged.
|
||||
"""
|
||||
return None if entry is None else "check"
|
||||
|
||||
|
||||
class SickDaysApp:
|
||||
@ -52,7 +58,7 @@ class SickDaysApp:
|
||||
"""Style ``root`` and draw the current pool."""
|
||||
self._root = root
|
||||
self._paths = resolve_paths(paths)
|
||||
root.title("Sick days")
|
||||
root.title(WINDOW_TITLE)
|
||||
root.configure(bg=tok.INK, padx=tok.LG, pady=tok.LG)
|
||||
root.bind("<Escape>", self.close)
|
||||
left = tk.Frame(root, bg=tok.INK)
|
||||
@ -60,13 +66,9 @@ class SickDaysApp:
|
||||
#: 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))
|
||||
#: The last outcome's mark: a check, or the panel's refusal icons.
|
||||
self.status = IconSlot(left, name="status")
|
||||
self.status.pack(anchor="w")
|
||||
log = self._paths.log
|
||||
self.calendar = MonthCalendar(
|
||||
left,
|
||||
@ -75,8 +77,8 @@ class SickDaysApp:
|
||||
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(
|
||||
self.calendar.pack(anchor="w", pady=(tok.SM, 0))
|
||||
IconSlot(left, name="ahead").show("sick", tok.SICK).pack(
|
||||
anchor="w", pady=(tok.LG, tok.SM)
|
||||
)
|
||||
self._list = tk.Frame(left, name="upcoming", bg=tok.INK)
|
||||
@ -88,25 +90,25 @@ class SickDaysApp:
|
||||
self._root.destroy()
|
||||
|
||||
def select(self, day: date) -> None:
|
||||
"""Open the panel for ``day``, or refuse a free day in the status line."""
|
||||
"""Open the panel for ``day``, or refuse a free day in the status mark."""
|
||||
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)
|
||||
block = _dates.free_day_block(day)
|
||||
if block is not None:
|
||||
self.status.show(*STATUS_ICONS[block])
|
||||
return
|
||||
self._set_status("", failed=False)
|
||||
self.status.show(None)
|
||||
KeepPanel(
|
||||
self.host,
|
||||
day,
|
||||
on_saved=lambda entry: self._saved(entry, day),
|
||||
on_saved=self._saved,
|
||||
on_cancel=self._close_panel,
|
||||
paths=self._paths,
|
||||
).pack(anchor="n")
|
||||
|
||||
def _saved(self, entry: SickDay | None, day: date) -> None:
|
||||
def _saved(self, entry: SickDay | None) -> None:
|
||||
self._close_panel()
|
||||
self._set_status(saved_message(entry, day), failed=False)
|
||||
self.status.show(saved_icon(entry), tok.SUCCESS)
|
||||
self.calendar.redraw()
|
||||
self._draw_upcoming()
|
||||
|
||||
@ -114,29 +116,20 @@ class SickDaysApp:
|
||||
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))
|
||||
for entry in sick_entries(load(self._paths.log)):
|
||||
if entry.day < reference:
|
||||
continue
|
||||
row = tk.Frame(self._list, name=f"day-{to_iso(entry.day)}", bg=tok.INK)
|
||||
row.pack(anchor="w", pady=(0, tok.XS))
|
||||
ui.label(row, to_iso(entry.day), "body").pack(side="left")
|
||||
for locker in _dates.kept_in_order(entry.keep):
|
||||
IconSlot(row, name=f"kept-{locker}", size=_KEPT_PX).show(
|
||||
LOCKER_ICONS[locker], tok.MUTED_ON_DARK
|
||||
).pack(side="left", padx=(tok.SM, 0))
|
||||
|
||||
|
||||
def run(*, paths: Paths | None = None) -> int:
|
||||
|
||||
@ -61,7 +61,7 @@ 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._dates.free_day_predicate", lambda: _never_free)
|
||||
monkeypatch.setattr("sickdays.gui._panel.sync_quietly", _no_sync)
|
||||
|
||||
|
||||
|
||||
70
sickdays/sickdays/tests/test_gui_controls.py
Normal file
70
sickdays/sickdays/tests/test_gui_controls.py
Normal file
@ -0,0 +1,70 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""The word-free controls and the drawn icons, against a real Tk."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tkinter as tk
|
||||
|
||||
import pytest
|
||||
|
||||
from sickdays._constants import LOCKERS
|
||||
from sickdays.gui import _tokens as tok
|
||||
from sickdays.gui._controls import IconButton, IconSlot, Toggle
|
||||
from sickdays.gui._icons import ICONS, LOCKER_ICONS, TAG
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def shown(tk_root: tk.Tk) -> tk.Tk:
|
||||
"""The root mapped, so real clicks reach the canvases."""
|
||||
tk_root.deiconify()
|
||||
tk_root.update()
|
||||
return tk_root
|
||||
|
||||
|
||||
def _click(widget: tk.Canvas) -> None:
|
||||
widget.pack()
|
||||
widget.update()
|
||||
widget.event_generate("<Button-1>", x=2, y=2)
|
||||
widget.update()
|
||||
|
||||
|
||||
def test_every_icon_draws_something(tk_root: tk.Tk) -> None:
|
||||
for icon in ICONS:
|
||||
slot = IconSlot(tk_root, size=32).show(icon, tok.DANGER)
|
||||
assert slot.icon == icon
|
||||
assert slot.find_withtag(TAG), icon
|
||||
assert slot.show(None).icon is None
|
||||
assert slot.find_withtag(TAG) == ()
|
||||
|
||||
|
||||
def test_every_locker_has_an_icon() -> None:
|
||||
assert set(LOCKER_ICONS) == set(LOCKERS)
|
||||
assert set(LOCKER_ICONS.values()) <= set(ICONS)
|
||||
|
||||
|
||||
def test_a_click_invokes_the_button_unless_disabled(shown: tk.Tk) -> None:
|
||||
calls: list[int] = []
|
||||
button = IconButton(shown, "check", lambda: calls.append(1), name="ok")
|
||||
button.set_enabled(False)
|
||||
assert str(button.cget("cursor")) == "arrow"
|
||||
_click(button)
|
||||
button.invoke()
|
||||
assert calls == []
|
||||
button.set_enabled(True)
|
||||
_click(button)
|
||||
assert calls == [1]
|
||||
|
||||
|
||||
def test_a_click_flips_the_switch_unless_disabled(shown: tk.Tk) -> None:
|
||||
on = tk.BooleanVar(shown, value=False)
|
||||
switch = Toggle(shown, on, name="switch")
|
||||
_click(switch)
|
||||
assert switch.is_on()
|
||||
assert on.get()
|
||||
switch.set_enabled(False)
|
||||
_click(switch)
|
||||
switch.invoke()
|
||||
assert switch.is_on()
|
||||
switch.set_enabled(True)
|
||||
switch.invoke()
|
||||
assert not switch.is_on()
|
||||
87
sickdays/sickdays/tests/test_gui_dates.py
Normal file
87
sickdays/sickdays/tests/test_gui_dates.py
Normal file
@ -0,0 +1,87 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""The picker's calendar arithmetic, free-day check and tokens -- no display."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sickdays._errors import ConflictCheckUnavailableError
|
||||
from sickdays.gui import _dates
|
||||
from sickdays.gui import _tokens as tok
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import pytest
|
||||
|
||||
DAY = date(2026, 10, 20)
|
||||
|
||||
|
||||
def test_kept_lockers_follow_locker_order_not_the_alphabet() -> None:
|
||||
assert _dates.kept_in_order({"alarm", "book", "workout"}) == (
|
||||
"workout",
|
||||
"book",
|
||||
"alarm",
|
||||
)
|
||||
assert _dates.kept_in_order(frozenset()) == ()
|
||||
|
||||
|
||||
def test_free_day_block(
|
||||
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
assert _dates.free_day_block(DAY) is None
|
||||
monkeypatch.setattr(
|
||||
"sickdays.gui._dates.free_day_predicate", lambda: lambda _d: True
|
||||
)
|
||||
assert _dates.free_day_block(DAY) == "free"
|
||||
|
||||
def unavailable() -> None:
|
||||
msg = "freedays is not installed"
|
||||
raise ConflictCheckUnavailableError(msg)
|
||||
|
||||
monkeypatch.setattr("sickdays.gui._dates.free_day_predicate", unavailable)
|
||||
with caplog.at_level(logging.WARNING):
|
||||
assert _dates.free_day_block(DAY) == "unavailable"
|
||||
assert "freedays is not installed" in caplog.text
|
||||
|
||||
|
||||
def test_month_grid_is_monday_first() -> None:
|
||||
weeks = _dates.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 _dates.month_title(date(2026, 10, 1)) == "2026-10"
|
||||
assert _dates.month_title(date(2027, 3, 1)) == "2027-03"
|
||||
assert [_dates.is_weekend_column(c) for c in range(7)] == [
|
||||
False,
|
||||
False,
|
||||
False,
|
||||
False,
|
||||
False,
|
||||
True,
|
||||
True,
|
||||
]
|
||||
|
||||
|
||||
def test_month_arithmetic_and_range() -> None:
|
||||
assert _dates.shift_month(date(2026, 12, 1), 1) == date(2027, 1, 1)
|
||||
assert _dates.shift_month(date(2026, 1, 1), -1) == date(2025, 12, 1)
|
||||
assert _dates.month_bounds(DAY) == (date(2026, 1, 1), date(2027, 12, 1))
|
||||
|
||||
|
||||
def test_workdays_are_tuesday_to_thursday() -> None:
|
||||
assert [_dates.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
|
||||
113
sickdays/sickdays/tests/test_gui_no_words.py
Normal file
113
sickdays/sickdays/tests/test_gui_no_words.py
Normal file
@ -0,0 +1,113 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""The textgate guard: no letter on any widget of the picker, in any state.
|
||||
|
||||
Everything this window shows is chrome except numerals and ISO dates (day
|
||||
numbers, ``2026-11``, ``2026-11-13``), so the guard bans every letter, not
|
||||
just words. The window title is the app's proper noun (textgate settled
|
||||
decision 4) and is checked on its own.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
import re
|
||||
import tkinter as tk
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sickdays._api import add_sick_day
|
||||
from sickdays._day import today
|
||||
from sickdays._errors import ConflictCheckUnavailableError
|
||||
from sickdays.gui import KeepPanel, SickDaysApp, _panel
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import pytest
|
||||
|
||||
FUTURE = today() + timedelta(days=3)
|
||||
LETTER = re.compile(r"[^\W\d_]")
|
||||
|
||||
|
||||
def _shown(widget: tk.Misc) -> list[str]:
|
||||
"""Every string ``widget`` and its descendants paint."""
|
||||
found: list[str] = []
|
||||
for child in widget.winfo_children():
|
||||
if isinstance(child, tk.Canvas):
|
||||
# Raw Tcl: typeshed types Canvas.type()/itemcget() too loosely.
|
||||
path = str(child)
|
||||
found.extend(
|
||||
str(child.tk.call(path, "itemcget", item, "-text"))
|
||||
for item in child.find_all()
|
||||
if str(child.tk.call(path, "type", item)) == "text"
|
||||
)
|
||||
elif isinstance(child, (tk.Label, tk.Button, tk.Checkbutton)):
|
||||
found.append(str(child.cget("text")))
|
||||
found.extend(_shown(child))
|
||||
return found
|
||||
|
||||
|
||||
def _assert_no_letters(widget: tk.Misc) -> None:
|
||||
shown = _shown(widget)
|
||||
assert shown, "the guard walked no widget"
|
||||
worded = [text for text in shown if LETTER.search(text)]
|
||||
assert worded == []
|
||||
|
||||
|
||||
def test_the_window_title_is_the_app_name(tk_root: tk.Tk) -> None:
|
||||
SickDaysApp(tk_root)
|
||||
assert tk_root.title() == "Sick days"
|
||||
|
||||
|
||||
def test_the_window_shows_no_letters(tk_root: tk.Tk) -> None:
|
||||
add_sick_day(FUTURE, keep=["alarm", "workout"])
|
||||
app = SickDaysApp(tk_root)
|
||||
_assert_no_letters(tk_root)
|
||||
app.select(FUTURE + timedelta(days=1))
|
||||
_assert_no_letters(tk_root)
|
||||
|
||||
|
||||
def test_the_window_after_a_refusal_shows_no_letters(
|
||||
tk_root: tk.Tk, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
"sickdays.gui._dates.free_day_predicate", lambda: lambda _d: True
|
||||
)
|
||||
SickDaysApp(tk_root).select(FUTURE)
|
||||
_assert_no_letters(tk_root)
|
||||
|
||||
|
||||
def test_the_window_after_a_save_shows_no_letters(tk_root: tk.Tk) -> None:
|
||||
app = SickDaysApp(tk_root)
|
||||
app.select(FUTURE)
|
||||
(panel,) = app.host.winfo_children()
|
||||
panel.nametowidget("actions.mark").invoke()
|
||||
assert isinstance(panel, KeepPanel)
|
||||
assert panel.sync_thread is not None
|
||||
panel.sync_thread.join(timeout=5)
|
||||
_assert_no_letters(tk_root)
|
||||
|
||||
|
||||
def test_every_panel_state_shows_no_letters(
|
||||
tk_root: tk.Tk, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
add_sick_day(FUTURE, keep=["book"])
|
||||
for day in (FUTURE, FUTURE + timedelta(days=1), today() - timedelta(days=1)):
|
||||
_assert_no_letters(
|
||||
KeepPanel(tk_root, day, on_saved=lambda _e: None, on_cancel=lambda: None)
|
||||
)
|
||||
|
||||
def unavailable() -> None:
|
||||
msg = "cannot check the free-day pool"
|
||||
raise ConflictCheckUnavailableError(msg)
|
||||
|
||||
monkeypatch.setattr("sickdays.gui._dates.free_day_predicate", unavailable)
|
||||
_assert_no_letters(KeepPanel(tk_root, FUTURE + timedelta(days=2), on_saved=print))
|
||||
|
||||
|
||||
def test_a_refused_or_busy_write_shows_no_letters(tk_root: tk.Tk) -> None:
|
||||
refused = KeepPanel(tk_root, FUTURE + timedelta(days=4), on_saved=print)
|
||||
add_sick_day(FUTURE + timedelta(days=4))
|
||||
refused.nametowidget("actions.mark").invoke()
|
||||
_assert_no_letters(refused)
|
||||
busy = KeepPanel(tk_root, FUTURE + timedelta(days=5), on_saved=print)
|
||||
with _panel._SYNC_LOCK:
|
||||
busy.nametowidget("actions.mark").invoke()
|
||||
_assert_no_letters(busy)
|
||||
@ -1,26 +1,27 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""KeepPanel: the five switches for one day, against a real (unmapped) Tk."""
|
||||
"""KeepPanel: the six switches for one day, against a real (unmapped) Tk."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
import threading
|
||||
import tkinter as tk
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
|
||||
from sickdays._api import add_sick_day, is_sick_day, load
|
||||
from sickdays._day import today
|
||||
from sickdays._day import to_iso, today
|
||||
from sickdays._errors import ConflictCheckUnavailableError
|
||||
from sickdays._keep_api import kept_lockers
|
||||
from sickdays._paths import Paths
|
||||
from sickdays._store import write_pool
|
||||
from sickdays.gui import KeepPanel, _text
|
||||
from sickdays.gui import _tokens as tok
|
||||
from sickdays.gui import KeepPanel
|
||||
from sickdays.gui._controls import IconButton, IconSlot, Toggle
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
import tkinter as tk
|
||||
|
||||
from sickdays._model import SickDay
|
||||
|
||||
@ -40,20 +41,22 @@ def synced(monkeypatch: pytest.MonkeyPatch) -> list[Paths | None]:
|
||||
return calls
|
||||
|
||||
|
||||
def _button(panel: KeepPanel, name: str) -> tk.Button:
|
||||
def _button(panel: KeepPanel, name: str) -> IconButton:
|
||||
widget = panel.nametowidget(f"actions.{name}")
|
||||
assert isinstance(widget, tk.Button)
|
||||
assert isinstance(widget, IconButton)
|
||||
return widget
|
||||
|
||||
|
||||
def _toggle(panel: KeepPanel, locker: str) -> tk.Checkbutton:
|
||||
def _toggle(panel: KeepPanel, locker: str) -> Toggle:
|
||||
widget = panel.nametowidget(f"keep-{locker}")
|
||||
assert isinstance(widget, tk.Checkbutton)
|
||||
assert isinstance(widget, Toggle)
|
||||
return widget
|
||||
|
||||
|
||||
def _status(panel: KeepPanel) -> str:
|
||||
return str(panel.nametowidget("status").cget("text"))
|
||||
def _status(panel: KeepPanel) -> str | None:
|
||||
slot = panel.nametowidget("status")
|
||||
assert isinstance(slot, IconSlot)
|
||||
return slot.icon
|
||||
|
||||
|
||||
def _actions(panel: KeepPanel) -> set[str]:
|
||||
@ -72,11 +75,11 @@ def test_mark_sick_keeping_leetcode(tk_root: tk.Tk, synced: list[Paths | None])
|
||||
assert panel.kept() == frozenset()
|
||||
assert _actions(panel) == {"mark"}
|
||||
toggle = _toggle(panel, "leetcode")
|
||||
assert toggle.cget("text") == "Off"
|
||||
assert not toggle.is_on()
|
||||
toggle.invoke()
|
||||
assert toggle.cget("text") == "On"
|
||||
assert str(toggle.cget("bg")).upper() == tok.ACCENT
|
||||
assert str(toggle.cget("fg")).upper() == tok.ON_FILL
|
||||
assert toggle.is_on()
|
||||
assert _button(panel, "mark").icon == "check"
|
||||
assert _status(panel) is None
|
||||
_button(panel, "mark").invoke()
|
||||
_join(panel)
|
||||
assert is_sick_day(FUTURE)
|
||||
@ -93,8 +96,14 @@ def test_an_existing_sick_day_opens_with_its_switches(tk_root: tk.Tk) -> None:
|
||||
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)}"
|
||||
assert panel.nametowidget("head.title").cget("text") == to_iso(FUTURE)
|
||||
state = panel.nametowidget("head.state")
|
||||
assert isinstance(state, IconSlot)
|
||||
assert state.icon == "sick"
|
||||
assert _button(panel, "clear").icon == "trash"
|
||||
icon = panel.nametowidget("icon-workout")
|
||||
assert isinstance(icon, IconSlot)
|
||||
assert icon.icon == "workout"
|
||||
|
||||
|
||||
def test_save_replaces_the_kept_set(tk_root: tk.Tk, synced: list[Paths | None]) -> None:
|
||||
@ -139,20 +148,27 @@ def test_clear_makes_the_day_normal(tk_root: tk.Tk, synced: list[Paths | None])
|
||||
|
||||
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 panel.reason == "past"
|
||||
assert _status(panel) == "padlock"
|
||||
assert _actions(panel) == set()
|
||||
assert str(_toggle(panel, "diet").cget("state")) == "disabled"
|
||||
diet = _toggle(panel, "diet")
|
||||
assert str(diet.cget("state")) == "disabled"
|
||||
diet.invoke()
|
||||
assert not diet.is_on()
|
||||
|
||||
|
||||
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
|
||||
"sickdays.gui._dates.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 panel.reason == "free"
|
||||
assert _status(panel) == "no_entry"
|
||||
state = panel.nametowidget("head.state")
|
||||
assert isinstance(state, IconSlot)
|
||||
assert state.icon == "normal"
|
||||
assert _actions(panel) == set()
|
||||
|
||||
|
||||
@ -163,20 +179,24 @@ def test_an_uncheckable_free_pool_refuses(
|
||||
msg = "cannot check the free-day pool"
|
||||
raise ConflictCheckUnavailableError(msg)
|
||||
|
||||
monkeypatch.setattr("sickdays.gui._text.free_day_predicate", unavailable)
|
||||
monkeypatch.setattr("sickdays.gui._dates.free_day_predicate", unavailable)
|
||||
panel = KeepPanel(tk_root, FUTURE, on_saved=lambda _e: None)
|
||||
assert _status(panel) == "cannot check the free-day pool"
|
||||
assert panel.reason == "unavailable"
|
||||
assert _status(panel) == "warning"
|
||||
assert _actions(panel) == set()
|
||||
|
||||
|
||||
def test_a_refused_write_is_shown_not_raised(
|
||||
tk_root: tk.Tk, synced: list[Paths | None]
|
||||
tk_root: tk.Tk, synced: list[Paths | None], caplog: pytest.LogCaptureFixture
|
||||
) -> 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)
|
||||
with caplog.at_level(logging.WARNING):
|
||||
_button(panel, "mark").invoke()
|
||||
assert panel.reason == "refused"
|
||||
assert _status(panel) == "warning"
|
||||
assert "already a sick day" in caplog.text
|
||||
assert saved == []
|
||||
assert synced == []
|
||||
|
||||
@ -189,7 +209,9 @@ def test_cancel_calls_back(tk_root: tk.Tk) -> None:
|
||||
on_saved=lambda _e: None,
|
||||
on_cancel=lambda: cancelled.append(True),
|
||||
)
|
||||
_button(panel, "cancel").invoke()
|
||||
cancel = _button(panel, "cancel")
|
||||
assert cancel.icon == "cross"
|
||||
cancel.invoke()
|
||||
assert cancelled == [True]
|
||||
|
||||
|
||||
@ -242,4 +264,5 @@ def test_a_write_during_a_sync_is_refused_not_lost(
|
||||
for entry in saved:
|
||||
assert entry is not None
|
||||
assert is_sick_day(entry.day), f"{entry.day} was written, then lost"
|
||||
assert _status(second) == _text.BUSY_NOTE
|
||||
assert second.reason == "busy"
|
||||
assert _status(second) == "hourglass"
|
||||
|
||||
@ -1,86 +0,0 @@
|
||||
# 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",
|
||||
"Focus",
|
||||
]
|
||||
|
||||
|
||||
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
|
||||
@ -10,8 +10,9 @@ 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 KeepPanel, SickDaysApp, _dates, _window
|
||||
from sickdays.gui import _tokens as tok
|
||||
from sickdays.gui._controls import IconButton, IconSlot
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import pytest
|
||||
@ -19,13 +20,19 @@ if TYPE_CHECKING:
|
||||
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 _upcoming(app: SickDaysApp) -> dict[str, list[str]]:
|
||||
"""Each listed day's ISO date -> the locker icons it keeps."""
|
||||
rows = app.calendar.master.nametowidget("upcoming").winfo_children()
|
||||
listed: dict[str, list[str]] = {}
|
||||
for row in rows:
|
||||
icons = [
|
||||
str(slot.icon)
|
||||
for slot in row.winfo_children()
|
||||
if isinstance(slot, IconSlot)
|
||||
]
|
||||
(date_label,) = [c for c in row.winfo_children() if isinstance(c, tk.Label)]
|
||||
listed[str(date_label.cget("text"))] = icons
|
||||
return listed
|
||||
|
||||
|
||||
def _cell(app: SickDaysApp, day: date) -> tk.Button:
|
||||
@ -40,14 +47,17 @@ def _panel(app: SickDaysApp) -> KeepPanel:
|
||||
return panel
|
||||
|
||||
|
||||
def test_an_empty_pool_says_none_ahead(tk_root: tk.Tk) -> None:
|
||||
def test_an_empty_pool_lists_nothing_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 _upcoming(app) == {}
|
||||
assert app.status.icon is None
|
||||
assert app.host.winfo_children() == []
|
||||
|
||||
|
||||
def test_mark_a_day_from_the_calendar(tk_root: tk.Tk) -> None:
|
||||
later = FUTURE + timedelta(days=1)
|
||||
add_sick_day(later)
|
||||
app = SickDaysApp(tk_root)
|
||||
app.calendar.month = FUTURE.replace(day=1)
|
||||
app.calendar.redraw()
|
||||
@ -58,10 +68,8 @@ def test_mark_a_day_from_the_calendar(tk_root: tk.Tk) -> None:
|
||||
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)
|
||||
assert app.status.icon == "check"
|
||||
assert _upcoming(app) == {to_iso(FUTURE): ["leetcode"], to_iso(later): []}
|
||||
cell = _cell(app, FUTURE)
|
||||
assert str(cell.cget("bg")).upper() == tok.SICK
|
||||
assert cell.nametowidget("dot").winfo_exists()
|
||||
@ -78,13 +86,12 @@ 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
|
||||
"sickdays.gui._dates.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
|
||||
assert app.status.icon == "no_entry"
|
||||
|
||||
|
||||
def test_a_sick_day_on_a_free_day_still_opens(
|
||||
@ -92,7 +99,7 @@ def test_a_sick_day_on_a_free_day_still_opens(
|
||||
) -> None:
|
||||
add_sick_day(FUTURE)
|
||||
monkeypatch.setattr(
|
||||
"sickdays.gui._text.free_day_predicate", lambda: lambda _d: True
|
||||
"sickdays.gui._dates.free_day_predicate", lambda: lambda _d: True
|
||||
)
|
||||
app = SickDaysApp(tk_root)
|
||||
app.select(FUTURE)
|
||||
@ -102,19 +109,31 @@ def test_a_sick_day_on_a_free_day_still_opens(
|
||||
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
|
||||
past = today() - timedelta(days=1)
|
||||
add_sick_day(past, now=past)
|
||||
assert _upcoming(SickDaysApp(tk_root)) == {
|
||||
to_iso(FUTURE): ["workout", "alarm"],
|
||||
to_iso(FUTURE + timedelta(days=1)): [],
|
||||
}
|
||||
|
||||
|
||||
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_saved_icon() -> None:
|
||||
assert _window.saved_icon(None) is None
|
||||
assert _window.saved_icon(SickDay(FUTURE, is_sick=True)) == "check"
|
||||
assert _window.saved_icon(SickDay(FUTURE, is_sick=False)) == "check"
|
||||
|
||||
|
||||
def test_the_month_reads_as_numerals_with_weekday_marks(tk_root: tk.Tk) -> None:
|
||||
app = SickDaysApp(tk_root)
|
||||
title = app.calendar.nametowidget("header").winfo_children()[-1]
|
||||
assert isinstance(title, tk.Label)
|
||||
assert title.cget("text") == _dates.month_title(today().replace(day=1))
|
||||
marks = [
|
||||
child.icon
|
||||
for child in app.calendar.nametowidget("days").winfo_children()
|
||||
if isinstance(child, IconSlot)
|
||||
]
|
||||
assert marks == ["dot"] * 5 + ["ring"] * 2
|
||||
|
||||
|
||||
def test_past_days_are_inert_and_faded(tk_root: tk.Tk) -> None:
|
||||
@ -128,7 +147,7 @@ def test_past_days_are_inert_and_faded(tk_root: tk.Tk) -> None:
|
||||
|
||||
def test_month_arrows_stop_at_the_phones_range(tk_root: tk.Tk) -> None:
|
||||
app = SickDaysApp(tk_root)
|
||||
first, last = _text.month_bounds(today())
|
||||
first, last = _dates.month_bounds(today())
|
||||
for _ in range(30):
|
||||
app.calendar.step(1)
|
||||
assert app.calendar.month == last
|
||||
@ -136,6 +155,8 @@ def test_month_arrows_stop_at_the_phones_range(tk_root: tk.Tk) -> None:
|
||||
app.calendar.step(-1)
|
||||
assert app.calendar.month == first
|
||||
back = app.calendar.nametowidget("header.prev")
|
||||
assert isinstance(back, IconButton)
|
||||
assert back.icon == "prev"
|
||||
assert str(back.cget("state")) == "disabled"
|
||||
|
||||
|
||||
|
||||
@ -5,7 +5,7 @@ description: >-
|
||||
injected closures -- no keystore or platform plugin access lives in this
|
||||
package itself.
|
||||
publish_to: 'none'
|
||||
version: 0.6.2
|
||||
version: 0.6.3
|
||||
|
||||
environment:
|
||||
sdk: ^3.12.2
|
||||
@ -19,7 +19,7 @@ dependencies:
|
||||
data_transfer_flutter:
|
||||
git:
|
||||
url: https://github.com/kuhyx/utils
|
||||
ref: data_transfer_flutter-v0.2.1
|
||||
ref: data_transfer_flutter-v0.2.2
|
||||
path: data_transfer_flutter
|
||||
flutter:
|
||||
sdk: flutter
|
||||
|
||||
Loading…
Reference in New Issue
Block a user