mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-11 12:27:45 +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:
|
Tracks:
|
||||||
- Consecutive weeks with 5+ workouts (streak counter).
|
- 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
|
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
|
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:
|
if current_week_str not in eb_weeks:
|
||||||
eb_weeks.append(current_week_str)
|
eb_weeks.append(current_week_str)
|
||||||
rewards.append(
|
rewards.append(
|
||||||
f"{prev_week_count} workouts in {prev_week_str}! "
|
f"{prev_week_count} workouts in {prev_week_str}! {streak}-week streak"
|
||||||
f"{streak}-week streak, early-bird extended to 09:00 this week"
|
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
if streak > 0:
|
if streak > 0:
|
||||||
@ -128,6 +130,9 @@ def process_week_transition(log_file: Path, state_file: Path) -> list[str]:
|
|||||||
"consecutive_5plus_weeks": streak,
|
"consecutive_5plus_weeks": streak,
|
||||||
"last_processed_iso_week": current_week_str,
|
"last_processed_iso_week": current_week_str,
|
||||||
"extended_early_bird_iso_weeks": eb_weeks,
|
"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)
|
_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", []
|
"extended_early_bird_iso_weeks", []
|
||||||
)
|
)
|
||||||
return current_week_str in eb_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 (
|
from screen_locker._extra_benefits import (
|
||||||
current_streak,
|
current_streak,
|
||||||
has_extended_early_bird,
|
has_extended_early_bird,
|
||||||
|
week_rewards,
|
||||||
)
|
)
|
||||||
from screen_locker._log_io import load_workout_log
|
from screen_locker._log_io import load_workout_log
|
||||||
from screen_locker._morning_session import has_workout_skip_today
|
from screen_locker._morning_session import has_workout_skip_today
|
||||||
@ -177,6 +178,7 @@ def gather_status(
|
|||||||
week=week,
|
week=week,
|
||||||
streak=current_streak(extra_benefits_file),
|
streak=current_streak(extra_benefits_file),
|
||||||
early_bird_extended=early_bird_extended,
|
early_bird_extended=early_bird_extended,
|
||||||
|
week_rewards=week_rewards(extra_benefits_file, today=today_local),
|
||||||
shutdown=_shutdown_projection(
|
shutdown=_shutdown_projection(
|
||||||
shutdown_config_file=shutdown_config_file,
|
shutdown_config_file=shutdown_config_file,
|
||||||
today_local=today_local,
|
today_local=today_local,
|
||||||
|
|||||||
@ -81,6 +81,41 @@ class StatusSectionsMixin:
|
|||||||
color=self._colors.palette.success,
|
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(
|
def _section_lock_explanation(
|
||||||
self, parent: tk.Widget, expl: LockExplanation
|
self, parent: tk.Widget, expl: LockExplanation
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@ -86,3 +86,4 @@ class StatusSnapshot:
|
|||||||
lock_explanation: LockExplanation
|
lock_explanation: LockExplanation
|
||||||
manual_workout_budget: ManualWorkoutBudgetStatus
|
manual_workout_budget: ManualWorkoutBudgetStatus
|
||||||
generated_at: str
|
generated_at: str
|
||||||
|
week_rewards: tuple[str, ...] = ()
|
||||||
|
|||||||
@ -115,6 +115,7 @@ class StatusWindow(
|
|||||||
self._label("Workout Status", role="title", pad="md")
|
self._label("Workout Status", role="title", pad="md")
|
||||||
self._section_today(self.container, snapshot.today)
|
self._section_today(self.container, snapshot.today)
|
||||||
self._section_week(self.container, snapshot.week)
|
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_lock_explanation(self.container, snapshot.lock_explanation)
|
||||||
self._section_temperature(self.container)
|
self._section_temperature(self.container)
|
||||||
self._section_manual_workout_budget(
|
self._section_manual_workout_budget(
|
||||||
|
|||||||
@ -106,9 +106,8 @@ class TestProcessWeekTransition:
|
|||||||
|
|
||||||
assert len(rewards) == 1
|
assert len(rewards) == 1
|
||||||
assert rewards[0].startswith("6 workouts in ")
|
assert rewards[0].startswith("6 workouts in ")
|
||||||
assert rewards[0].endswith(
|
assert rewards[0].endswith("! 1-week streak")
|
||||||
"! 1-week streak, early-bird extended to 09:00 this week"
|
assert "09:00" not in rewards[0]
|
||||||
)
|
|
||||||
assert not any("shutdown bonus" in r for r in rewards)
|
assert not any("shutdown bonus" in r for r in rewards)
|
||||||
state = json.loads(f.read_text())
|
state = json.loads(f.read_text())
|
||||||
assert state["consecutive_5plus_weeks"] == 1
|
assert state["consecutive_5plus_weeks"] == 1
|
||||||
|
|||||||
@ -10,6 +10,7 @@ from unittest.mock import patch
|
|||||||
from screen_locker._extra_benefits import (
|
from screen_locker._extra_benefits import (
|
||||||
has_extended_early_bird,
|
has_extended_early_bird,
|
||||||
process_week_transition,
|
process_week_transition,
|
||||||
|
week_rewards,
|
||||||
)
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@ -49,12 +50,7 @@ class TestBonusDroppedAtW42:
|
|||||||
):
|
):
|
||||||
rewards = process_week_transition(tmp_path / "log.json", f)
|
rewards = process_week_transition(tmp_path / "log.json", f)
|
||||||
|
|
||||||
assert rewards == [
|
assert rewards == ["5 workouts in 2026-W41! 4-week streak"]
|
||||||
(
|
|
||||||
"5 workouts in 2026-W41! 4-week streak, "
|
|
||||||
"early-bird extended to 09:00 this week"
|
|
||||||
)
|
|
||||||
]
|
|
||||||
state = json.loads(f.read_text())
|
state = json.loads(f.read_text())
|
||||||
# Sibling keys (the compensation guard) survive; only the dead map goes.
|
# Sibling keys (the compensation guard) survive; only the dead map goes.
|
||||||
assert state == {
|
assert state == {
|
||||||
@ -62,9 +58,21 @@ class TestBonusDroppedAtW42:
|
|||||||
"last_processed_iso_week": "2026-W42",
|
"last_processed_iso_week": "2026-W42",
|
||||||
"extended_early_bird_iso_weeks": ["2026-W41", "2026-W42"],
|
"extended_early_bird_iso_weeks": ["2026-W41", "2026-W42"],
|
||||||
"shutdown_bonus_granted_for": ["2026-08-24"],
|
"shutdown_bonus_granted_for": ["2026-08-24"],
|
||||||
|
"week_rewards": {"iso_week": "2026-W42", "messages": rewards},
|
||||||
}
|
}
|
||||||
w42 = datetime(2026, 10, 12, 12, 0, tzinfo=UTC)
|
w42 = datetime(2026, 10, 12, 12, 0, tzinfo=UTC)
|
||||||
assert has_extended_early_bird(f, today=w42) is True
|
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:
|
class TestHasExtendedEarlyBird:
|
||||||
|
|||||||
@ -87,6 +87,13 @@ class TestReadTools:
|
|||||||
# asdict output must be pure JSON (no datetime/dataclass leaks).
|
# asdict output must be pure JSON (no datetime/dataclass leaks).
|
||||||
json.dumps(out)
|
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:
|
def test_get_summary_combines_line_and_state(self) -> None:
|
||||||
with (
|
with (
|
||||||
patch.object(_mcp, "gather_status") as gs,
|
patch.object(_mcp, "gather_status") as gs,
|
||||||
|
|||||||
@ -121,6 +121,25 @@ class TestGatherStatus:
|
|||||||
|
|
||||||
assert snap.streak == 2
|
assert snap.streak == 2
|
||||||
assert snap.early_bird_extended is True
|
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
|
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(
|
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