mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 19:07:16 +01:00
Show the workout streak in the status window
Adds StatusSectionsMixin._section_streak, called from StatusWindow.render: the stored consecutive 5+-workout-week streak and the reward message the week transition produced, display only. A streak of 0 is stated plainly, never padded; a "Streak reset" message is warning-coloured, not green. get_status already returned the streak (it is asdict(gather_status())); StatusSnapshot gains week_rewards. The week-transition strings used to go to _logger.info only. process_week_transition now also keeps them under a week_rewards key in the existing extra_benefits_state.json (no new file), read back for the current ISO week by week_rewards(). Their "early-bird extended to 09:00" clause is dropped: early_bird_extended is read only by display code, no lock or window decision uses it (early bird is wake-alarm's signed exemption since 2026-09-20), so it is not shown in the window either. Per kuhy: no daily-streak state, no shutdown time for streaks, no freezes or grace days; no enforcement logic touched. Red first: new section/state/snapshot tests failed, then passed. On a scratch copy of the real state the window shows "7-week streak" and "6 workouts in 2026-W43! 7-week streak", no early-bird line. Removes prompts/TODO-dopamine-ux-04-screen-locker.md (done). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzxdWYdwRAtMg7ki6TKxuU
This commit is contained in:
parent
aa2776a45e
commit
ba3a8a293d
@ -1,211 +0,0 @@
|
||||
> **This file is a ready-to-use prompt.** Open this repo and say
|
||||
> "do dopamine-ux-04-screen-locker". It is self-contained -- it needs no context from
|
||||
> any other session.
|
||||
>
|
||||
> Canonical copy: `~/src/utils/dopamine-ux/04-screen-locker.md` (source of truth; this is a
|
||||
> distributed copy). Running order and cross-prompt rules: `~/src/utils/dopamine-ux/00-INDEX.md`.
|
||||
> Generated 2026-08-16 from a survey of this repo -- line numbers are accurate
|
||||
> as of that date; **anchor on the symbol names, not the line numbers.**
|
||||
|
||||
## ⛔ WHEN YOU FINISH THIS PROMPT
|
||||
|
||||
Do **both**, in the same session that completes the work:
|
||||
|
||||
1. **Delete this file** and commit that deletion together with the
|
||||
implementation:
|
||||
```bash
|
||||
git rm prompts/dopamine-ux-04-screen-locker.md
|
||||
```
|
||||
Do not leave a finished prompt lying in the repo -- a stale prompt is
|
||||
indistinguishable from a pending one, and the next session will re-run it.
|
||||
|
||||
2. **Log completion in the canonical index**, in `~/src/utils` (a *different* repo,
|
||||
so it needs its own commit):
|
||||
```bash
|
||||
cd ~/src/utils
|
||||
# append to the "Completion log" table in dopamine-ux/00-INDEX.md:
|
||||
# | 04-screen-locker.md | DONE <YYYY-MM-DD> | <impl commit sha> | <one-line note> |
|
||||
git add dopamine-ux/00-INDEX.md
|
||||
git commit -m "dopamine-ux: mark 04-screen-locker.md done"
|
||||
git push
|
||||
```
|
||||
|
||||
If you finish only part of the prompt, do **neither** -- instead edit this file
|
||||
to strike out what is done and state what remains, so the next session starts
|
||||
from the true state. Partial completion must never be recorded as DONE.
|
||||
|
||||
---
|
||||
|
||||
Surface the workout streak that screen-locker already tracks
|
||||
|
||||
## findings (3, ranked by value over effort)
|
||||
|
||||
1. **`snapshot.streak` is populated and no renderer reads it** — the cheapest
|
||||
win in the whole project. Zero new state, zero conftest change, zero deps.
|
||||
2. **Week-transition reward strings are discarded into `_logger.info`** —
|
||||
fully-formed celebratory sentences that never reach a user.
|
||||
3. **The success moment exists only on the lock screen**, for 1.5s. The status
|
||||
window — the surface visited *voluntarily* — shows only enforcement.
|
||||
|
||||
## what
|
||||
|
||||
screen-locker computes a real workout streak and celebratory reward text, then
|
||||
throws almost all of it away. Verified 2026-08-16:
|
||||
|
||||
- `StatusSnapshot` (`screen_locker/_status_types.py` ~:88-101) carries populated
|
||||
`streak` and `early_bird_extended` fields, filled by
|
||||
`_status_data.py` ~:210-214. **Grep confirms no renderer reads them.**
|
||||
- `_extra_benefits.py` `process_week_transition()` (~:82, reward strings built at
|
||||
~:130-139) produces fully-formed celebratory sentences — and
|
||||
`_startup_checks.py` ~:57-58 sends them to `_logger.info` and nowhere else.
|
||||
- The one success moment that exists is on the **lock screen**:
|
||||
`_unlock_view.py` `unlock_screen()` (~:25) paints "Great job! 💪" and
|
||||
`🔥 {streak}-week streak`, for `_UNLOCK_DELAY_MS = 1500` before routing away.
|
||||
|
||||
So the surface the user visits **voluntarily** — the status window, opened from
|
||||
the tray icon — shows enforcement and budget depletion, and no evidence of
|
||||
success at all. This is the cheapest high-value change in the whole project:
|
||||
the data already exists, is already true, and needs no new state.
|
||||
|
||||
## where
|
||||
|
||||
Repo: `~/src/screen-locker`.
|
||||
|
||||
Primary:
|
||||
- `screen_locker/_status_sections.py` — the section renderers used by the status
|
||||
window (`_section_*` functions). **177 lines; has headroom.** Add
|
||||
`_section_streak` here.
|
||||
- `screen_locker/status_view.py` — `StatusWindow`, `render()`. Call the new
|
||||
section from here. ⚠️ **246 lines — only 4 left under the 250 cap.** Expect to
|
||||
extract something, or add the call in a way that costs ~1 line.
|
||||
- `screen_locker/_status_types.py` — `StatusSnapshot` (~:88-101), the fields you
|
||||
are rendering. Read-only for this task.
|
||||
- `screen_locker/_extra_benefits.py` (179 lines) — `process_week_transition()`
|
||||
and its reward strings; `current_streak()`, `has_extended_early_bird()`.
|
||||
- `screen_locker/_startup_checks.py` ~:57-58 — where the reward strings are
|
||||
currently discarded into a log call.
|
||||
- `screen_locker/_unlock_view.py` (72 lines, lots of headroom) — the existing
|
||||
success moment, if you extend it.
|
||||
|
||||
## must
|
||||
|
||||
- **Render the already-populated fields.** Add `_section_streak` to
|
||||
`_status_sections.py` showing the weekly streak and the extended-early-bird
|
||||
state, and call it from `status_view.py`'s `render()`.
|
||||
Zero new state, zero conftest change, zero new dependency.
|
||||
- **Stop discarding the reward strings.** `process_week_transition()` returns
|
||||
human-readable celebratory sentences that currently only reach the log. Surface
|
||||
them where the user will actually see them — the status window is the obvious
|
||||
home; a milestone is worth more than a silent counter increment.
|
||||
- Match the existing positive-status styling rather than inventing one:
|
||||
`_status_sections.py` ~:73-78 already renders `"{n} above the weekly minimum!"`
|
||||
in success green, and uses `✓` marks at ~:36 and ~:57.
|
||||
- Keep the streak **true**. It counts real ISO weeks with ≥5 counted workouts
|
||||
(`_weekly_check.py`: `WEEKLY_WORKOUT_MINIMUM = 5`, `COUNTED_WORKOUT_TYPES =
|
||||
{phone_verified, runnerup_verified, manual_workout}`). Do not round, pad, or
|
||||
"encourage" the number.
|
||||
|
||||
- must not: **make any reward reduce enforcement.** This is recorded in the
|
||||
repo as a deliberate decision:
|
||||
> `_startup_checks.py` — "there is no banked 'skip a workout' credit —
|
||||
> that mechanic works against the goal of maximizing weekly workouts, so it was
|
||||
> removed."
|
||||
|
||||
Every reward here is **display only**. Do not reintroduce a
|
||||
banked skip, a streak freeze, a grace day, or any mechanic that lowers the
|
||||
floor. A dopamine loop that lets you skip the workout defeats the app.
|
||||
- must not: **grant shutdown time for a streak, a week or a milestone.** The
|
||||
weekly banked shutdown bonus (and the 4-week `_MILESTONE_INTERVAL` +1h) was
|
||||
removed on purpose (2026-10-10): every shutdown minute comes from the
|
||||
`earned_time` ladder and stops at its daily ceiling. Rewards here are display
|
||||
only — no "+Nh" weekly reward, no bonus-hours field, no new shutdown source.
|
||||
- must not: add **sound** to any enforcement or lock path. screen-locker is a
|
||||
self-restriction tool; a chime on lock is punishment audio, and a chime on
|
||||
unlock trains the wrong association. This repo stays silent. (Sound is
|
||||
opt-out-on elsewhere in the fleet; here it is simply absent.)
|
||||
- must not: add a write tool to the MCP server. `_mcp.py` ~:14-26 states it is
|
||||
read-only by design. Adding `streak` to the existing `get_status` output is
|
||||
fine; a new action tool violates a stated invariant.
|
||||
- must not: add `# noqa` (banned repo-wide) or a silent `except`
|
||||
(`scripts/check_silent_failures.py` — every handler re-raises or logs at
|
||||
warning or above).
|
||||
- must not: create a new on-disk state file unless genuinely unavoidable. If you
|
||||
do, you **must** add it to `_ISOLATED_STATE` in
|
||||
`screen_locker/tests/conftest.py` (~:134), listing *every* module that binds
|
||||
the path at import time — a missed binding lets tests write to kuhy's real
|
||||
state. Read the comment at ~:131-133 and the fixture docstring at ~:170-175.
|
||||
|
||||
- optional: a daily streak alongside the weekly one. Note this **would** be new
|
||||
state (the existing streak is consecutive-*weeks*), so it triggers the conftest
|
||||
requirement above. diet-guard's consecutive-*day* streak
|
||||
(`~/src/diet-guard/diet_guard/_daystatus.py` ~:144) is the precedent to mirror.
|
||||
|
||||
## done
|
||||
|
||||
1. Opening the status window (tray icon → click, or `screen-locker-status`) shows
|
||||
the current streak and early-bird state — with the real values
|
||||
from `extra_benefits_state.json`, not placeholders.
|
||||
2. A week-transition milestone reaches the UI instead of only `_logger.info`.
|
||||
3. `cd ~/src/screen-locker && python -m pytest` passes.
|
||||
4. `pre-commit run --files <changed files>` is clean — including the 250-line cap
|
||||
(`scripts/check_file_length.py`) and the silent-failure check.
|
||||
5. `git diff` touches no enforcement logic: no change to lock decisions, weekly
|
||||
minimums, or shutdown floors.
|
||||
|
||||
## verify
|
||||
|
||||
Desktop — this is a desktop Python app.
|
||||
|
||||
```
|
||||
cd ~/src/screen-locker
|
||||
python -m screen_locker --status # CLI: confirm the values it prints
|
||||
screen-locker-status # the Tk window: confirm they render
|
||||
```
|
||||
|
||||
Paste both outputs. The CLI already prints the streak and early-bird state
|
||||
(`_status.py`), so it is your ground truth: the window must show the **same numbers**.
|
||||
If they disagree, the renderer is wrong, not the data.
|
||||
|
||||
Current real state for comparison: `screen_locker/extra_benefits_state.json` and
|
||||
`screen_locker/log.json` (12 days of entries, 2026-07-12 → 2026-08-15).
|
||||
|
||||
## read first
|
||||
|
||||
- `screen_locker/_status_sections.py` — every existing `_section_*`. Match their
|
||||
signature and style; note the success-green precedent at ~:73-78.
|
||||
- `screen_locker/_status_types.py` ~:88-101 — the three fields you are rendering.
|
||||
- `screen_locker/_extra_benefits.py` — especially the module docstring ~:5-8
|
||||
(the enforcement invariant) and the reward strings ~:130-139.
|
||||
- `screen_locker/_unlock_view.py` — the existing celebration, for tone.
|
||||
- `screen_locker/status_view.py` — check its line count **before** editing.
|
||||
- `~/src/screen-locker/CLAUDE.md` — repo rules (no silent failures, ruff `select=ALL`).
|
||||
|
||||
## context you would otherwise rediscover
|
||||
|
||||
- **`log.json` is the single source of truth**, written only through
|
||||
`_log_mixin.py` `write_signed_entry()` (~:82), HMAC-signed via
|
||||
`gatelock.log_integrity`, deduped on `workout_id`. The Flutter app converges on
|
||||
it via `_manual_push.py`. There is no second history — a streak computed from
|
||||
it is authoritative.
|
||||
- All persistence is **JSON files, no sqlite**; paths declared in
|
||||
`screen_locker/_constants.py`. The streak store is
|
||||
`screen_locker/extra_benefits_state.json` (`consecutive_5plus_weeks`,
|
||||
`last_processed_iso_week`, `extended_early_bird_iso_weeks`; a legacy
|
||||
`weekly_shutdown_bonus_hours` map is ignored and dropped on the next write).
|
||||
- The threshold lives in `_extra_benefits.py`: `_BONUS_THRESHOLD = 5` (workouts
|
||||
per week, for the streak and the early-bird extension). There is no milestone
|
||||
constant any more.
|
||||
- **There is no config system** — no argparse, no env vars, no config file. Every
|
||||
tunable is a module-level constant. An opt-out toggle has no existing home;
|
||||
for a self-restriction tool, requiring a code edit to disable is arguably
|
||||
correct, so prefer a constant in `_constants.py` over new state.
|
||||
- `~/gatelock` is **not** a repo — it is `~/src/utils/gatelock/gatelock/`, consumed as
|
||||
a pinned git dep, and it holds no workout state.
|
||||
- Manual workouts were **not** removed: `_manual_workout.py` implements a
|
||||
rate-limited, evidence-gated subsystem (budget 2 per 7 days, 10 per 30).
|
||||
- Three UI surfaces exist: the Tk status window (`status_view.py`), a GTK tray
|
||||
icon at `~/.config/i3/scripts/screen_locker_tray.py` (outside the repo, shells
|
||||
out to the CLI, refreshes every 60s), and the lock screen itself. No i3blocks
|
||||
integration exists despite `format_summary_line` being described as one.
|
||||
|
||||
REMOVE ME AFTER FINISH
|
||||
@ -2,7 +2,10 @@
|
||||
|
||||
Tracks:
|
||||
- Consecutive weeks with 5+ workouts (streak counter).
|
||||
- ISO weeks in which the early-bird window is extended to 09:00.
|
||||
- ISO weeks in which the early-bird window is extended to 09:00. Display-only
|
||||
bookkeeping: nothing reads it to decide a lock (early bird is wake-alarm's
|
||||
signed exemption since 2026-09-20).
|
||||
- The week-transition reward messages, for the status window.
|
||||
|
||||
There is no weekly shutdown-time bonus (dropped entirely 2026-10-10, hours
|
||||
already banked for 2026-W41 included): every shutdown minute comes from the
|
||||
@ -108,8 +111,7 @@ def process_week_transition(log_file: Path, state_file: Path) -> list[str]:
|
||||
if current_week_str not in eb_weeks:
|
||||
eb_weeks.append(current_week_str)
|
||||
rewards.append(
|
||||
f"{prev_week_count} workouts in {prev_week_str}! "
|
||||
f"{streak}-week streak, early-bird extended to 09:00 this week"
|
||||
f"{prev_week_count} workouts in {prev_week_str}! {streak}-week streak"
|
||||
)
|
||||
else:
|
||||
if streak > 0:
|
||||
@ -128,6 +130,9 @@ def process_week_transition(log_file: Path, state_file: Path) -> list[str]:
|
||||
"consecutive_5plus_weeks": streak,
|
||||
"last_processed_iso_week": current_week_str,
|
||||
"extended_early_bird_iso_weeks": eb_weeks,
|
||||
# Kept for the status window (display only): the strings above
|
||||
# used to go to the log and nowhere else.
|
||||
"week_rewards": {"iso_week": current_week_str, "messages": rewards},
|
||||
}
|
||||
)
|
||||
_save_state(state_file, updated)
|
||||
@ -147,3 +152,15 @@ def has_extended_early_bird(state_file: Path, *, today: datetime | None = None)
|
||||
"extended_early_bird_iso_weeks", []
|
||||
)
|
||||
return current_week_str in eb_weeks
|
||||
|
||||
|
||||
def week_rewards(state_file: Path, *, today: datetime | None = None) -> tuple[str, ...]:
|
||||
"""The reward messages this ISO week's transition produced, for display.
|
||||
|
||||
Empty once the week has passed, or when nothing was stored.
|
||||
"""
|
||||
now = today if today is not None else datetime.now(tz=UTC).astimezone()
|
||||
stored = _load_state(state_file).get("week_rewards", {})
|
||||
if not isinstance(stored, dict) or stored.get("iso_week") != _current_iso_week(now):
|
||||
return ()
|
||||
return tuple(str(m) for m in stored.get("messages", []))
|
||||
|
||||
@ -48,6 +48,7 @@ from screen_locker._constants import (
|
||||
from screen_locker._extra_benefits import (
|
||||
current_streak,
|
||||
has_extended_early_bird,
|
||||
week_rewards,
|
||||
)
|
||||
from screen_locker._log_io import load_workout_log
|
||||
from screen_locker._morning_session import has_workout_skip_today
|
||||
@ -177,6 +178,7 @@ def gather_status(
|
||||
week=week,
|
||||
streak=current_streak(extra_benefits_file),
|
||||
early_bird_extended=early_bird_extended,
|
||||
week_rewards=week_rewards(extra_benefits_file, today=today_local),
|
||||
shutdown=_shutdown_projection(
|
||||
shutdown_config_file=shutdown_config_file,
|
||||
today_local=today_local,
|
||||
|
||||
@ -81,6 +81,41 @@ class StatusSectionsMixin:
|
||||
color=self._colors.palette.success,
|
||||
)
|
||||
|
||||
def _section_streak(
|
||||
self, parent: tk.Widget, streak: int, week_rewards: tuple[str, ...] = ()
|
||||
) -> None:
|
||||
"""Render the consecutive 5+-workout-week streak and this week's reward.
|
||||
|
||||
Display only, and exactly the stored values (``extra_benefits_state``):
|
||||
a streak of 0 is said plainly, never padded or rounded up. Nothing here
|
||||
grants time or relaxes a lock. The early-bird extension is not shown:
|
||||
it no longer changes anything (wake-alarm owns early bird).
|
||||
"""
|
||||
del parent
|
||||
self._label("Workout streak", role="body", pad="sm")
|
||||
if streak > 0:
|
||||
self._text(
|
||||
f"🔥 {streak}-week streak (5+ workouts a week)",
|
||||
role="body",
|
||||
color=self._colors.palette.success,
|
||||
)
|
||||
else:
|
||||
self._text(
|
||||
"No streak yet: 5+ workouts in a week starts one.",
|
||||
role="body",
|
||||
color=self._colors.palette.muted,
|
||||
)
|
||||
for message in week_rewards:
|
||||
# A reset is news, not a celebration: it must not be painted green.
|
||||
lost = message.startswith("Streak reset")
|
||||
self._text(
|
||||
message,
|
||||
role="body",
|
||||
color=self._colors.palette.warning
|
||||
if lost
|
||||
else self._colors.palette.success,
|
||||
)
|
||||
|
||||
def _section_lock_explanation(
|
||||
self, parent: tk.Widget, expl: LockExplanation
|
||||
) -> None:
|
||||
|
||||
@ -86,3 +86,4 @@ class StatusSnapshot:
|
||||
lock_explanation: LockExplanation
|
||||
manual_workout_budget: ManualWorkoutBudgetStatus
|
||||
generated_at: str
|
||||
week_rewards: tuple[str, ...] = ()
|
||||
|
||||
@ -115,6 +115,7 @@ class StatusWindow(
|
||||
self._label("Workout Status", role="title", pad="md")
|
||||
self._section_today(self.container, snapshot.today)
|
||||
self._section_week(self.container, snapshot.week)
|
||||
self._section_streak(self.container, snapshot.streak, snapshot.week_rewards)
|
||||
self._section_lock_explanation(self.container, snapshot.lock_explanation)
|
||||
self._section_temperature(self.container)
|
||||
self._section_manual_workout_budget(
|
||||
|
||||
@ -106,9 +106,8 @@ class TestProcessWeekTransition:
|
||||
|
||||
assert len(rewards) == 1
|
||||
assert rewards[0].startswith("6 workouts in ")
|
||||
assert rewards[0].endswith(
|
||||
"! 1-week streak, early-bird extended to 09:00 this week"
|
||||
)
|
||||
assert rewards[0].endswith("! 1-week streak")
|
||||
assert "09:00" not in rewards[0]
|
||||
assert not any("shutdown bonus" in r for r in rewards)
|
||||
state = json.loads(f.read_text())
|
||||
assert state["consecutive_5plus_weeks"] == 1
|
||||
|
||||
@ -10,6 +10,7 @@ from unittest.mock import patch
|
||||
from screen_locker._extra_benefits import (
|
||||
has_extended_early_bird,
|
||||
process_week_transition,
|
||||
week_rewards,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@ -49,12 +50,7 @@ class TestBonusDroppedAtW42:
|
||||
):
|
||||
rewards = process_week_transition(tmp_path / "log.json", f)
|
||||
|
||||
assert rewards == [
|
||||
(
|
||||
"5 workouts in 2026-W41! 4-week streak, "
|
||||
"early-bird extended to 09:00 this week"
|
||||
)
|
||||
]
|
||||
assert rewards == ["5 workouts in 2026-W41! 4-week streak"]
|
||||
state = json.loads(f.read_text())
|
||||
# Sibling keys (the compensation guard) survive; only the dead map goes.
|
||||
assert state == {
|
||||
@ -62,9 +58,21 @@ class TestBonusDroppedAtW42:
|
||||
"last_processed_iso_week": "2026-W42",
|
||||
"extended_early_bird_iso_weeks": ["2026-W41", "2026-W42"],
|
||||
"shutdown_bonus_granted_for": ["2026-08-24"],
|
||||
"week_rewards": {"iso_week": "2026-W42", "messages": rewards},
|
||||
}
|
||||
w42 = datetime(2026, 10, 12, 12, 0, tzinfo=UTC)
|
||||
assert has_extended_early_bird(f, today=w42) is True
|
||||
# The message the transition produced is kept for the status window,
|
||||
# for the week it was earned in only.
|
||||
assert week_rewards(f, today=w42) == tuple(rewards)
|
||||
assert week_rewards(f, today=datetime(2026, 10, 19, 12, 0, tzinfo=UTC)) == ()
|
||||
|
||||
def test_no_stored_rewards_reads_as_empty(self, tmp_path: Path) -> None:
|
||||
"""Missing file, or a state without the key, has nothing to show."""
|
||||
assert week_rewards(tmp_path / "absent.json") == ()
|
||||
f = tmp_path / "state.json"
|
||||
f.write_text(json.dumps({"consecutive_5plus_weeks": 2}))
|
||||
assert week_rewards(f) == ()
|
||||
|
||||
|
||||
class TestHasExtendedEarlyBird:
|
||||
|
||||
@ -87,6 +87,13 @@ class TestReadTools:
|
||||
# asdict output must be pure JSON (no datetime/dataclass leaks).
|
||||
json.dumps(out)
|
||||
|
||||
def test_get_status_carries_the_streak_fields(self) -> None:
|
||||
"""The streak and early-bird state are part of get_status (read-only)."""
|
||||
with patch.object(_mcp, "gather_status", return_value=_snapshot()):
|
||||
out = _mcp.get_status()
|
||||
assert out["streak"] == 1
|
||||
assert out["early_bird_extended"] is False
|
||||
|
||||
def test_get_summary_combines_line_and_state(self) -> None:
|
||||
with (
|
||||
patch.object(_mcp, "gather_status") as gs,
|
||||
|
||||
@ -121,6 +121,25 @@ class TestGatherStatus:
|
||||
|
||||
assert snap.streak == 2
|
||||
assert snap.early_bird_extended is True
|
||||
assert snap.week_rewards == ()
|
||||
|
||||
def test_this_weeks_transition_message_is_surfaced(self, tmp_path: Path) -> None:
|
||||
"""The reward text a week transition produced reaches the snapshot."""
|
||||
files = _files(tmp_path)
|
||||
msg = "5 workouts in 2023-W52! 3-week streak"
|
||||
files["extra_benefits_file"].write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"consecutive_5plus_weeks": 3,
|
||||
"week_rewards": {"iso_week": "2024-W01", "messages": [msg]},
|
||||
}
|
||||
)
|
||||
)
|
||||
with patch(
|
||||
"screen_locker._status_data.has_workout_skip_today", return_value=False
|
||||
):
|
||||
snap = gather_status(**files, now=_FRIDAY_NOON_UTC)
|
||||
assert snap.week_rewards == (msg,)
|
||||
assert snap.shutdown.rest_of_week[0].minutes == 20 * 60 # base, no bonus
|
||||
|
||||
def test_next_week_preview_is_speculative_rest_of_week_is_not(
|
||||
|
||||
63
screen_locker/tests/test_status_streak_section.py
Normal file
63
screen_locker/tests/test_status_streak_section.py
Normal file
@ -0,0 +1,63 @@
|
||||
"""The status window shows the workout streak and early-bird state it tracks."""
|
||||
# pylint: disable=protected-access
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from screen_locker.tests._status_view_helpers import (
|
||||
_make_window,
|
||||
_snapshot,
|
||||
_texts,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
|
||||
def _fg_of(mock_tk: MagicMock, needle: str) -> list[object]:
|
||||
return [
|
||||
c.kwargs.get("fg")
|
||||
for c in mock_tk.Label.call_args_list
|
||||
if needle in c.kwargs.get("text", "")
|
||||
]
|
||||
|
||||
|
||||
class TestSectionStreak:
|
||||
"""Tests for StatusWindow._section_streak (display only)."""
|
||||
|
||||
def test_shows_the_real_week_count_in_success_color(
|
||||
self, mock_tk: MagicMock
|
||||
) -> None:
|
||||
window = _make_window(mock_tk, _snapshot(streak=3))
|
||||
assert any("3-week streak" in t for t in _texts(mock_tk))
|
||||
assert window._colors.palette.success in _fg_of(mock_tk, "3-week streak")
|
||||
|
||||
def test_zero_streak_is_stated_plainly_not_padded(self, mock_tk: MagicMock) -> None:
|
||||
window = _make_window(mock_tk, _snapshot(streak=0))
|
||||
texts = _texts(mock_tk)
|
||||
assert any("No streak" in t for t in texts)
|
||||
assert not any("-week streak" in t for t in texts)
|
||||
assert window._colors.palette.muted in _fg_of(mock_tk, "No streak")
|
||||
|
||||
def test_early_bird_is_never_rendered(self, mock_tk: MagicMock) -> None:
|
||||
"""The 09:00 early-bird extension is gone (wake-alarm owns it)."""
|
||||
_make_window(mock_tk, _snapshot(streak=2, early_bird_extended=True))
|
||||
assert not any("arly-bird" in t for t in _texts(mock_tk))
|
||||
|
||||
def test_week_reward_reaches_the_window_in_success_color(
|
||||
self, mock_tk: MagicMock
|
||||
) -> None:
|
||||
msg = "5 workouts in 2026-W41! 4-week streak"
|
||||
window = _make_window(mock_tk, _snapshot(streak=4, week_rewards=(msg,)))
|
||||
assert msg in _texts(mock_tk)
|
||||
assert window._colors.palette.success in _fg_of(mock_tk, msg)
|
||||
|
||||
def test_no_reward_text_when_the_week_has_none(self, mock_tk: MagicMock) -> None:
|
||||
_make_window(mock_tk, _snapshot(streak=4))
|
||||
assert not any("workouts in 20" in t for t in _texts(mock_tk))
|
||||
|
||||
def test_a_streak_reset_is_not_painted_as_a_win(self, mock_tk: MagicMock) -> None:
|
||||
msg = "Streak reset (was 3 weeks of 5+ workouts)"
|
||||
window = _make_window(mock_tk, _snapshot(streak=0, week_rewards=(msg,)))
|
||||
assert window._colors.palette.warning in _fg_of(mock_tk, msg)
|
||||
Loading…
Reference in New Issue
Block a user