drop the weekly shutdown bonus entirely; every bonus stops at the shutdown ceiling
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (8) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (9) (push) Waiting to run
Tests / flutter-coverage (push) Blocked by required conditions
Tests / web-coverage (push) Waiting to run
repo-contract / repo contract (push) Waiting to run

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-10-10 08:59:50 +02:00
parent 87c50825f9
commit fc5453495c
23 changed files with 290 additions and 392 deletions

View File

@ -53,7 +53,7 @@ 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`, `bonus_hours_this_week` and `early_bird_extended` fields, filled by
`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
@ -81,8 +81,7 @@ Primary:
- `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()` ~:154,
`weekly_shutdown_bonus_hours()` ~:159, `has_extended_early_bird()` ~:171.
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
@ -91,8 +90,8 @@ Primary:
## must
- **Render the already-populated fields.** Add `_section_streak` to
`_status_sections.py` showing the weekly streak, this week's bonus hours, and
the extended-early-bird state, and call it from `status_view.py`'s `render()`.
`_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
@ -106,17 +105,20 @@ Primary:
{phone_verified, runnerup_verified, manual_workout}`). Do not round, pad, or
"encourage" the number.
- must not: **make any reward reduce enforcement.** This is recorded twice in the
- must not: **make any reward reduce enforcement.** This is recorded in the
repo as a deliberate decision:
> `_startup_checks.py` ~:88-93 — "there is no banked 'skip a workout' credit —
> `_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 in favor of a shutdown-time-only reward"
> `_extra_benefits.py` ~:5-8 — "This never reduces enforcement … it only grants
> extra comfort time on top of a floor you still have to earn each day."
> removed."
Every reward here is **display or comfort-time only**. Do not reintroduce a
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
@ -141,7 +143,7 @@ Primary:
## done
1. Opening the status window (tray icon → click, or `screen-locker-status`) shows
the current streak, bonus hours and early-bird state — with the real values
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.
@ -160,8 +162,8 @@ 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 `streak`/`bonus_hours` (`_status.py`
~:138-141), so it is your ground truth: the window must show the **same numbers**.
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
@ -188,10 +190,11 @@ Current real state for comparison: `screen_locker/extra_benefits_state.json` and
- 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`, `weekly_shutdown_bonus_hours`,
`extended_early_bird_iso_weeks`).
- Thresholds live in `_extra_benefits.py`: `_MILESTONE_INTERVAL = 4` (4-week
milestone), `_BONUS_THRESHOLD = 5` (workouts per week).
`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

View File

@ -4,12 +4,11 @@ Tracks:
- Consecutive weeks with 5+ workouts (streak counter).
- ISO weeks in which the early-bird window is extended to 09:00.
A 5+ workout week no longer banks shutdown-time hours for the following week
(dropped 2026-10-10; 2026-W41 was the last week to receive any). The
``weekly_shutdown_bonus_hours`` map is still *read* by
:func:`weekly_shutdown_bonus_hours`, so hours already banked for the current
week keep applying, and ``scripts/restore_and_bonus.py`` can still bank a
one-off compensation into it — but the week transition never writes it.
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
``earned_time`` ladder and stops at its daily ceiling. A legacy
``weekly_shutdown_bonus_hours`` map in an old state file is ignored, and
dropped at the next week transition's write.
State is persisted in ``extra_benefits_state.json`` next to this file.
"""
@ -29,6 +28,8 @@ if TYPE_CHECKING:
_logger = logging.getLogger(__name__)
_BONUS_THRESHOLD = 5 # workouts/week required to extend the streak + early-bird
# The removed weekly shutdown bonus's map; ignored on read, dropped on write.
_LEGACY_BONUS_KEY = "weekly_shutdown_bonus_hours"
def _load_state(state_file: Path) -> dict[str, Any]:
@ -116,13 +117,12 @@ def process_week_transition(log_file: Path, state_file: Path) -> list[str]:
streak = 0
# Merge onto the loaded state rather than rebuilding it from these three
# keys: the legacy ``weekly_shutdown_bonus_hours`` map (still read, and
# still written by restore_and_bonus.py) and sibling fields written by the
# recovery scripts (e.g. ``shutdown_bonus_granted_for``, the guard that
# stops a compensation bonus being granted twice) live in this file too,
# and rebuilding would drop them at the next weekly rollover — losing this
# week's banked hours and silently re-arming a double-grant.
# keys: sibling fields written by the recovery scripts (e.g.
# ``shutdown_bonus_granted_for``) live in this file too, and rebuilding
# would drop them at the next weekly rollover. Only the dead weekly-bonus
# map is dropped, so no reader can mistake it for live state.
updated = dict(state)
updated.pop(_LEGACY_BONUS_KEY, None)
updated.update(
{
"consecutive_5plus_weeks": streak,
@ -139,23 +139,6 @@ def current_streak(state_file: Path) -> int:
return int(_load_state(state_file).get("consecutive_5plus_weeks", 0))
def weekly_shutdown_bonus_hours(
state_file: Path, *, today: datetime | None = None
) -> int:
"""Return the banked shutdown-time bonus (hours) for the current ISO week.
The week transition stopped banking these after 2026-W41; what is left is
any hours already banked for this week, or a manual compensation written
by ``scripts/restore_and_bonus.py``.
"""
now = today if today is not None else datetime.now(tz=UTC).astimezone()
current_week_str = _current_iso_week(now)
bonus_hours: dict[str, int] = _load_state(state_file).get(
"weekly_shutdown_bonus_hours", {}
)
return int(bonus_hours.get(current_week_str, 0))
def has_extended_early_bird(state_file: Path, *, today: datetime | None = None) -> bool:
"""Return True if the current ISO week has an extended early-bird window (09:00)."""
now = today if today is not None else datetime.now(tz=UTC).astimezone()

View File

@ -80,21 +80,17 @@ class PokeLocker(ScreenLocker):
"""Run the new-day reset the locker's startup runs, in the same order.
Mirrors ``StartupChecksMixin._check_non_verify_exits``, not
``sync_now`` (which never resets): the week transition must be
processed BEFORE the reset, and any banked weekly bonus layered on only
when this call did the reset. If the poke stamped the day without them,
the locker's own reset later would be a no-op and that day's banked
bonus would be lost.
``sync_now`` (which never resets): the week transition (streak,
early-bird window) first, then the daily reset.
"""
for reward in process_week_transition(self.log_file, EXTRA_BENEFITS_FILE):
_logger.info("Weekly reward: %s", reward)
if reset_to_base_if_new_day(
reset_to_base_if_new_day(
SHUTDOWN_BASE_FILE,
self,
sick_day_state_file=SICK_DAY_STATE_FILE,
log_file=self.log_file,
):
self._apply_weekly_shutdown_bonus()
)
def credit_written(
self, entry: dict[str, str], prior_entries: list[dict[str, object]]

View File

@ -105,19 +105,16 @@ class StartupChecksMixin(SyncMixin):
self._record_skip("scheduled_skip_day", "Today is a scheduled skip day.")
return
# Award streak / EB-extension rewards from last week before the daily
# reset. The transition no longer banks shutdown hours; any already
# banked for this week are layered on by _apply_weekly_shutdown_bonus.
# reset. No week carries a shutdown bonus any more (2026-10-10).
for reward_msg in process_week_transition(self.log_file, EXTRA_BENEFITS_FILE):
_logger.info("Weekly reward: %s", reward_msg)
# Reset shutdown config to base (19:00) at the start of each new day,
# then layer this week's banked bonus (if any) back on the fresh base.
if reset_to_base_if_new_day(
# Reset shutdown config to the day's base plus what today already earned.
reset_to_base_if_new_day(
SHUTDOWN_BASE_FILE,
self,
sick_day_state_file=SICK_DAY_STATE_FILE,
log_file=self.log_file,
):
self._apply_weekly_shutdown_bonus()
)
# A LeetCode solve or a reading credit usually lands hours after the
# reset; this 5-minute tick turns it into the extra hour the same day.
apply_flat_bonuses_if_new(SHUTDOWN_BASE_FILE, self)
@ -160,8 +157,7 @@ class StartupChecksMixin(SyncMixin):
# Only remaining same-day skip: genuine extreme heat. Sick days go
# through the justification flow instead; there is no banked
# "skip a workout" credit — that mechanic works against the goal of
# maximizing weekly workouts, so it was removed in favor of a
# shutdown-time-only reward (see _apply_weekly_shutdown_bonus).
# maximizing weekly workouts, so it was removed.
# A logged workout outranks every rung above, whatever their order.
if refuse_to_lock_over_logged_workout(self.has_logged_today, self._record_skip):
return

View File

@ -14,7 +14,6 @@ from screen_locker._earned import hhmm
from screen_locker._extra_benefits import (
current_streak,
has_extended_early_bird,
weekly_shutdown_bonus_hours,
)
from screen_locker._log_io import load_workout_log
from screen_locker._sick_tracker import load_history
@ -41,7 +40,7 @@ def _load_extra_benefits() -> dict:
except (OSError, ValueError) as exc:
_logger.warning(
"Could not read extra-benefits state from %s: %s — the status view "
"will show a 0 streak and no bonus hours, which may be wrong",
"will show a 0 streak, which may be wrong",
EXTRA_BENEFITS_FILE,
exc,
)
@ -116,7 +115,6 @@ def run_status(locker: ScreenLocker) -> None:
print()
# Extra benefits summary
bonus_hours = weekly_shutdown_bonus_hours(EXTRA_BENEFITS_FILE)
streak = current_streak(EXTRA_BENEFITS_FILE)
eb_ext = has_extended_early_bird(EXTRA_BENEFITS_FILE)
eb_str = "Yes — until 09:00" if eb_ext else "No"
@ -138,7 +136,6 @@ def run_status(locker: ScreenLocker) -> None:
else:
heat_str = "0"
print(f" Shutdown bonus (this wk): {bonus_hours}h")
print(f" Streak (5+ wks) : {streak}")
print(f" Early-bird extended : {eb_str}")
print(f" Heat skips (month) : {heat_str}")

View File

@ -54,7 +54,6 @@ from screen_locker._constants import (
from screen_locker._extra_benefits import (
current_streak,
has_extended_early_bird,
weekly_shutdown_bonus_hours,
)
from screen_locker._log_io import load_workout_log
from screen_locker._morning_session import has_workout_skip_today
@ -203,14 +202,10 @@ def gather_status(
return StatusSnapshot(
today=days[-1],
week=week,
bonus_hours_this_week=weekly_shutdown_bonus_hours(
extra_benefits_file, today=today_local
),
streak=current_streak(extra_benefits_file),
early_bird_extended=early_bird_extended,
shutdown=_shutdown_projection(
shutdown_config_file=shutdown_config_file,
extra_benefits_file=extra_benefits_file,
today_local=today_local,
),
lock_explanation=lock_explanation,

View File

@ -14,7 +14,6 @@ from datetime import date, datetime, timedelta
from typing import TYPE_CHECKING
from screen_locker._earned import hhmm
from screen_locker._extra_benefits import weekly_shutdown_bonus_hours
from screen_locker._shutdown import read_shutdown_config
from screen_locker._shutdown_base import base_minutes
from screen_locker._status_types import ShutdownProjection, ShutdownProjectionDay
@ -31,14 +30,9 @@ _RELAXED_DAY_EXPLANATION = (
)
_WEEKDAY_LABELS = ("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")
_MON_WED_WEEKDAYS = frozenset({0, 1, 2})
# A banked weekly bonus (legacy, or a restore_and_bonus.py compensation) is in
# whole hours.
_MINUTES_PER_HOUR = 60
def _week_rows(
monday: date, bonus: int, *, speculative: bool
) -> tuple[ShutdownProjectionDay, ...]:
def _week_rows(monday: date, *, speculative: bool) -> tuple[ShutdownProjectionDay, ...]:
"""Build 7 labeled rows, Mon-Sun from ``monday``, each on its own day's base.
Each row asks ``base_minutes`` for its own date, so a registry cut dated
@ -47,7 +41,7 @@ def _week_rows(
return tuple(
ShutdownProjectionDay(
label=label,
minutes=base_minutes(monday + timedelta(days=weekday)) + bonus,
minutes=base_minutes(monday + timedelta(days=weekday)),
speculative=speculative,
)
for weekday, label in enumerate(_WEEKDAY_LABELS)
@ -57,23 +51,17 @@ def _week_rows(
def _shutdown_projection(
*,
shutdown_config_file: Path,
extra_benefits_file: Path,
today_local: datetime,
) -> ShutdownProjection:
"""Build the shutdown-time projection: tonight, rest of week, next week.
Next week is the plain per-day base: a 5+ workout week no longer banks
shutdown hours for the week after (only the early-bird extension).
Every row is the plain per-day base: no week carries a banked shutdown
bonus (dropped 2026-10-10).
"""
tonight = read_shutdown_config(shutdown_config_file)
bonus = (
weekly_shutdown_bonus_hours(extra_benefits_file, today=today_local)
* _MINUTES_PER_HOUR
)
monday = today_local.date() - timedelta(days=today_local.weekday())
rest_of_week = _week_rows(monday, bonus, speculative=False)
next_week_preview = _week_rows(monday + timedelta(weeks=1), 0, speculative=True)
rest_of_week = _week_rows(monday, speculative=False)
next_week_preview = _week_rows(monday + timedelta(weeks=1), speculative=True)
return ShutdownProjection(
tonight=tonight,

View File

@ -94,7 +94,6 @@ class StatusSnapshot:
today: DayStatus
week: WeeklySummary
bonus_hours_this_week: int
streak: int
early_bird_extended: bool
shutdown: ShutdownProjection

View File

@ -10,10 +10,9 @@ from __future__ import annotations
import logging
from screen_locker._constants import EXTRA_BENEFITS_FILE, SHUTDOWN_BASE_FILE
from screen_locker._constants import SHUTDOWN_BASE_FILE
from screen_locker._earned import span
from screen_locker._earner_units import sync_path_unit
from screen_locker._extra_benefits import weekly_shutdown_bonus_hours
from screen_locker._manual_push import push_pc_workouts
from screen_locker._manual_sync import ingest_manual_records
from screen_locker._session_sync import ingest_session_records
@ -27,9 +26,6 @@ __all__ = ["SyncMixin"]
_logger = logging.getLogger(__name__)
# A banked weekly bonus is in whole hours; the schedule takes minutes.
_MINUTES_PER_HOUR = 60
class SyncMixin:
"""Publish, ingest and credit manual workouts synced between devices."""
@ -136,14 +132,3 @@ class SyncMixin:
"Auto-filled %d RunnerUp workout(s) from TCX exports.", n_filled
)
self.workout_data = {}
def _apply_weekly_shutdown_bonus(self) -> None:
"""Layer this week's banked shutdown bonus (if any) on the fresh base.
Week transitions stopped banking hours after 2026-W41; only hours
already banked for this week or a restore_and_bonus.py compensation
remain to apply.
"""
bonus = weekly_shutdown_bonus_hours(EXTRA_BENEFITS_FILE)
if bonus > 0 and self._adjust_shutdown_time_by(bonus * _MINUTES_PER_HOUR):
_logger.info("Weekly bonus: +%dh shutdown time this week.", bonus)

View File

@ -196,7 +196,7 @@ def count_weekly_workouts(
recorded twice is credited once. Manual workouts are rate-limited
separately by the manual-workout budget
(:mod:`screen_locker._manual_workout`), not by a collapse here. Feeds both
the weekly lock minimum (:func:`has_weekly_minimum`) and the banked bonus.
the weekly lock minimum (:func:`has_weekly_minimum`) and the weekly streak.
Args:
log_file: Path to ``log.json``.

View File

@ -27,7 +27,7 @@ ISOLATED_STATE: tuple[tuple[str, tuple[str, ...]], ...] = (
(
"_constants.EXTRA_BENEFITS_FILE",
"_startup_checks.EXTRA_BENEFITS_FILE",
"_sync_mixin.EXTRA_BENEFITS_FILE",
"_poke_credit.EXTRA_BENEFITS_FILE",
"_status.EXTRA_BENEFITS_FILE",
),
),

View File

@ -134,7 +134,6 @@ def _snapshot(**overrides: object) -> StatusSnapshot:
default = StatusSnapshot(
today=_day(),
week=_week(),
bonus_hours_this_week=0,
streak=0,
early_bird_extended=False,
shutdown=_shutdown(),

View File

@ -29,8 +29,8 @@ class TestLoadState:
def test_returns_parsed_state_when_file_valid(self, tmp_path: Path) -> None:
"""Valid JSON file returns the parsed dict."""
f = tmp_path / "state.json"
f.write_text(json.dumps({"weekly_shutdown_bonus_hours": {"2026-W01": 3}}))
assert _load_state(f) == {"weekly_shutdown_bonus_hours": {"2026-W01": 3}}
f.write_text(json.dumps({"extended_early_bird_iso_weeks": ["2026-W01"]}))
assert _load_state(f) == {"extended_early_bird_iso_weeks": ["2026-W01"]}
def test_returns_empty_on_oserror(self) -> None:
"""OSError during read is caught and returns empty dict (lines 33-34)."""
@ -52,10 +52,10 @@ class TestSaveState:
def test_saves_state_to_file(self, tmp_path: Path) -> None:
"""Valid path writes JSON content (lines 39-41)."""
f = tmp_path / "state.json"
_save_state(f, {"weekly_shutdown_bonus_hours": {"2026-W01": 2}})
assert json.loads(f.read_text())["weekly_shutdown_bonus_hours"] == {
"2026-W01": 2
}
_save_state(f, {"extended_early_bird_iso_weeks": ["2026-W02"]})
assert json.loads(f.read_text())["extended_early_bird_iso_weeks"] == [
"2026-W02"
]
def test_logs_warning_on_oserror(self) -> None:
"""OSError during write is caught as warning, does not raise (lines 42-43)."""
@ -95,7 +95,6 @@ class TestProcessWeekTransition:
{
"last_processed_iso_week": self._PAST_WEEK,
"consecutive_5plus_weeks": 0,
"weekly_shutdown_bonus_hours": {},
"extended_early_bird_iso_weeks": [],
}
)
@ -113,10 +112,10 @@ class TestProcessWeekTransition:
assert not any("shutdown bonus" in r for r in rewards)
state = json.loads(f.read_text())
assert state["consecutive_5plus_weeks"] == 1
assert state["weekly_shutdown_bonus_hours"] == {}
assert "weekly_shutdown_bonus_hours" not in state
def test_4_week_streak_banks_no_milestone_hour(self, tmp_path: Path) -> None:
"""Streak reaching 4 adds no +1h; a legacy bonus map is left untouched."""
"""Streak reaching 4 adds no +1h; a legacy bonus map is dropped on write."""
f = tmp_path / "state.json"
f.write_text(
json.dumps(
@ -137,7 +136,7 @@ class TestProcessWeekTransition:
assert "4-week streak" in rewards[0]
state = json.loads(f.read_text())
assert state["consecutive_5plus_weeks"] == 4
assert state["weekly_shutdown_bonus_hours"] == {"2020-W52": 2}
assert "weekly_shutdown_bonus_hours" not in state
def test_marks_current_week_as_extended_early_bird(self, tmp_path: Path) -> None:
"""5+ workouts mark current ISO week as extended EB (line 91-92)."""

View File

@ -1,4 +1,4 @@
"""Tests for _extra_benefits (streak, legacy banked bonus, EB extension)."""
"""Tests for _extra_benefits (streak, legacy bonus key dropped, EB extension)."""
from __future__ import annotations
@ -10,37 +10,12 @@ from unittest.mock import patch
from screen_locker._extra_benefits import (
has_extended_early_bird,
process_week_transition,
weekly_shutdown_bonus_hours,
)
if TYPE_CHECKING:
from pathlib import Path
class TestWeeklyShutdownBonusHours:
"""Tests for weekly_shutdown_bonus_hours."""
def test_returns_zero_when_missing(self, tmp_path: Path) -> None:
"""No state file → 0."""
f = tmp_path / "state.json"
assert weekly_shutdown_bonus_hours(f) == 0
def test_returns_current_week_bonus(self, tmp_path: Path) -> None:
"""Returns the banked bonus for the current ISO week."""
now = datetime.now(tz=UTC).astimezone()
year, week, _ = now.isocalendar()
current_week = f"{year}-W{week:02d}"
f = tmp_path / "state.json"
f.write_text(json.dumps({"weekly_shutdown_bonus_hours": {current_week: 3}}))
assert weekly_shutdown_bonus_hours(f) == 3
def test_ignores_other_weeks(self, tmp_path: Path) -> None:
"""A bonus banked for a different ISO week is not returned."""
f = tmp_path / "state.json"
f.write_text(json.dumps({"weekly_shutdown_bonus_hours": {"2020-W01": 5}}))
assert weekly_shutdown_bonus_hours(f) == 0
class _FrozenDatetime(datetime):
"""``datetime`` whose ``now`` is pinned to Monday 2026-10-12 12:00 UTC."""
@ -52,7 +27,7 @@ class _FrozenDatetime(datetime):
class TestBonusDroppedAtW42:
"""The 2026-W41 -> W42 rollover banks nothing; W41's hours stay readable."""
"""The 2026-W41 -> W42 rollover banks nothing and drops the legacy bonus map."""
_STATE: ClassVar[dict[str, object]] = {
"consecutive_5plus_weeks": 3,
@ -81,23 +56,16 @@ class TestBonusDroppedAtW42:
)
]
state = json.loads(f.read_text())
# Sibling keys (the compensation guard) survive; only the dead map goes.
assert state == {
**self._STATE,
"consecutive_5plus_weeks": 4,
"last_processed_iso_week": "2026-W42",
"extended_early_bird_iso_weeks": ["2026-W41", "2026-W42"],
"shutdown_bonus_granted_for": ["2026-08-24"],
}
w42 = datetime(2026, 10, 12, 12, 0, tzinfo=UTC)
assert weekly_shutdown_bonus_hours(f, today=w42) == 0
assert has_extended_early_bird(f, today=w42) is True
def test_w41_banked_hours_still_apply_through_sunday(self, tmp_path: Path) -> None:
"""Hours banked before the drop keep applying for the rest of W41."""
f = tmp_path / "state.json"
f.write_text(json.dumps(self._STATE))
sunday = datetime(2026, 10, 11, 21, 0, tzinfo=UTC)
assert weekly_shutdown_bonus_hours(f, today=sunday) == 2
class TestHasExtendedEarlyBird:
"""Tests for has_extended_early_bird."""

View File

@ -58,7 +58,6 @@ def _snapshot() -> StatusSnapshot:
week=WeeklySummary(
days=(day,), counted_count=2, minimum=4, remaining=2, extra=0
),
bonus_hours_this_week=0,
streak=1,
early_bird_extended=False,
shutdown=ShutdownProjection(

View File

@ -50,7 +50,7 @@ def credit(monkeypatch: pytest.MonkeyPatch) -> MagicMock:
class TestStartTheDay:
def test_new_day_applies_the_weekly_bonus_after_the_reset(
def test_new_day_runs_the_week_transition_then_the_reset(
self, log_file: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
calls: list[str] = []
@ -63,22 +63,10 @@ class TestStartTheDay:
calls.append("reset")
return True
def bonus(_self: PokeLocker) -> None:
calls.append("bonus")
monkeypatch.setattr(_poke_credit, "process_week_transition", week)
monkeypatch.setattr(_poke_credit, "reset_to_base_if_new_day", reset)
monkeypatch.setattr(PokeLocker, "_apply_weekly_shutdown_bonus", bonus)
_poke_credit.fresh_locker().start_the_day()
assert calls == ["week", "reset", "bonus"]
def test_same_day_does_not_apply_the_weekly_bonus(
self, log_file: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
bonus = MagicMock()
monkeypatch.setattr(PokeLocker, "_apply_weekly_shutdown_bonus", bonus)
_poke_credit.fresh_locker().start_the_day()
bonus.assert_not_called()
assert calls == ["week", "reset"]
def test_credit_written_sets_the_entry_and_returns_the_result(
self, log_file: Path, credit: MagicMock

View File

@ -86,13 +86,13 @@ class TestCheckNonVerifyExitsExtras:
):
locker._check_non_verify_exits()
def test_applies_weekly_bonus_on_fresh_day_reset(
def test_fresh_day_reset_layers_no_weekly_bonus(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""reset_to_base_if_new_day True → weekly shutdown bonus is applied once."""
"""A fresh-day reset is the whole shutdown: no weekly bonus on top (2026-10-10)."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker,
@ -113,50 +113,6 @@ class TestCheckNonVerifyExitsExtras:
"screen_locker._startup_checks.process_week_transition",
return_value=[],
),
patch(
"screen_locker._sync_mixin.weekly_shutdown_bonus_hours",
return_value=2,
),
patch("screen_locker._startup_checks.is_relaxed_day", return_value=False),
patch(
"screen_locker._startup_checks.has_weekly_minimum", return_value=True
),
patch("screen_locker.screen_lock.sys.exit"),
):
locker._check_non_verify_exits()
locker._adjust_shutdown_time_by.assert_called_once_with(120)
def test_no_weekly_bonus_applied_when_not_a_fresh_day(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""reset_to_base_if_new_day False (same-day restart) → bonus not re-applied."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker,
"_scan_and_fill_week_runnerup",
MagicMock(return_value=0),
)
object.__setattr__(
locker,
"_adjust_shutdown_time_by",
MagicMock(return_value=True),
)
with (
patch(
"screen_locker._startup_checks.reset_to_base_if_new_day",
return_value=False,
),
patch(
"screen_locker._startup_checks.process_week_transition",
return_value=[],
),
patch(
"screen_locker._sync_mixin.weekly_shutdown_bonus_hours",
return_value=2,
),
patch("screen_locker._startup_checks.is_relaxed_day", return_value=False),
patch(
"screen_locker._startup_checks.has_weekly_minimum", return_value=True

View File

@ -0,0 +1,206 @@
"""Doing every task reaches the shutdown ceiling, on every day, in every state.
The invariant behind dropping the weekly banked bonus (2026-10-10): the
ladder's floor is the ceiling minus every rung, so a day on which every earner
in ``earned_time.earners_for(day)`` is done to its full units (the tutor's
four blocks included) must shut down exactly at ``shutdown_ceiling_for(day)``
-- never short of it, and never past it. Checked through screen-locker's own
paths, not a re-implementation: the daily reset's :func:`derive`, and the live
pass (first workout, further workouts, flat gates, counted gate blocks, grace
floor) over :class:`ShutdownMixin` with an in-memory config.
Every gate maturity state is covered: ``first_credits`` omitted (0.5.0
``penalty_from``), empty (every earner on its ``confirmed_on``), never paid
out / missing ledger (``None``), first paid today (penalty not started),
maturing (paid yesterday), mature, and a new tutor beside mature gates.
"""
from __future__ import annotations
from datetime import date, datetime, time, timedelta
from typing import TYPE_CHECKING, Any
from unittest.mock import patch
import earned_time
import pytest
from screen_locker import _gate_bonus, _grace_floor, _shutdown
from screen_locker._earned import ceiling, extra_minutes, first_minutes, is_gate
from screen_locker._earned import registry as registry_for
from screen_locker._gate_bonus import apply_counted_bonus
from screen_locker._shutdown import ShutdownMixin
from screen_locker._shutdown_target import DayInputs, derive
if TYPE_CHECKING:
from collections.abc import Callable, Iterator, Mapping
_FIRST = date(2026, 10, 10)
_LAST = date(2026, 12, 31)
_DAYS = tuple(_FIRST + timedelta(days=n) for n in range((_LAST - _FIRST).days + 1))
# adjust_shutdown_schedule.sh's RESTORE_CEILING: the helper clamps every
# --restore write to 23:00, so the double does too.
_HELPER_CEILING = 23 * 60
_MORNING = 5 * 60
Credits = dict[str, date | None] | None
def _credits(fill: Callable[[date], date | None] | None) -> Callable[[date], Credits]:
"""A per-day ``first_credits`` map giving every earner ``fill(day)``."""
def build(day: date) -> Credits:
if fill is None:
return None
return {e.name: fill(day) for e in registry_for(day)}
return build
def _new_tutor_beside_mature(day: date) -> Credits:
mature = {e.name: date(2026, 8, 1) for e in registry_for(day)}
return {**mature, "automation": None}
_MATURITY: Mapping[str, Callable[[date], Credits]] = {
"penalty_from": _credits(None),
"confirmed_on": lambda _day: {},
"never_paid": _credits(lambda _day: None),
"first_paid_today": _credits(lambda day: day),
"maturing": _credits(lambda day: day - timedelta(days=1)),
"mature": _credits(lambda _day: date(2026, 8, 1)),
"new_tutor": _new_tutor_beside_mature,
}
_CASES = [
pytest.param(
name, rest, workouts, first, id=f"{name}-rest{rest}-w{workouts}-{first}"
)
for name in _MATURITY
for rest in (False, True)
for workouts in (1, 2)
for first in ("none", "09:00", "22:30")
]
def _at(day: date, hhmm: str) -> float | None:
if hhmm == "none":
return None
hours, minutes = map(int, hhmm.split(":"))
return datetime.combine(day, time(hours, minutes)).astimezone().timestamp()
def _gates(day: date, *, done: bool) -> dict[str, int | bool | None]:
"""Every gate's answer: all done (full units) or none."""
return {
e.name: (e.max_units or True) if done else 0
for e in registry_for(day)
if is_gate(e)
}
def _inputs(day: date, case: tuple[str, bool, int, str], *, done: bool) -> DayInputs:
name, rest, workouts, first = case
return DayInputs(
day=day,
flat=_gates(day, done=done),
workout_credits=workouts if done else 0,
rest_day=rest,
first_done=_at(day, first) if done else None,
first_credits=_MATURITY[name](day),
)
@pytest.mark.parametrize(("maturity", "rest", "workouts", "first"), _CASES)
def test_the_reset_lands_every_all_done_day_on_the_ceiling(
maturity: str, rest: bool, workouts: int, first: str
) -> None:
"""``derive`` (the daily reset's formula) == ceiling on every day."""
case = (maturity, rest, workouts, first)
misses = [
(day.isoformat(), got, ceiling(day))
for day in _DAYS
if (got := derive(_inputs(day, case, done=True)).minutes) != ceiling(day)
]
assert misses == []
class _Host(ShutdownMixin):
"""The real add / absorb / floor paths over an in-memory config."""
def __init__(self, minutes: int) -> None:
self.config = (minutes, minutes, _MORNING)
def _read_shutdown_config(self) -> tuple[int, int, int] | None:
return self.config
def _write_shutdown_config(
self, mon_wed: int, thu_sun: int, morning_end: int, *, restore: bool = False
) -> bool:
assert restore
self.config = (
min(_HELPER_CEILING, mon_wed),
min(_HELPER_CEILING, thu_sun),
morning_end,
)
return True
def _live_pass(day: date, case: tuple[str, bool, int, str]) -> int:
"""Reset with nothing done, then every credit as the live pass applies it."""
_, rest, workouts, first = case
host = _Host(derive(_inputs(day, case, done=False)).minutes)
first_done = _at(day, first)
if first_done is not None:
host._apply_grace_floor(None, first_done=first_done)
# A rest day's first unit is already in the reset; a real workout on it
# is a further unit (_workout_credit's rest_paid rule).
further = workouts if rest else workouts - 1
if not rest:
host._adjust_shutdown_time_later()
extra = extra_minutes(earned_time.WORKOUT, day)
for _ in range(further if extra else 0):
host._adjust_shutdown_time_by(extra)
state: dict[str, Any] = {}
for gate in (e for e in registry_for(day) if is_gate(e)):
if gate.kind == "flat":
host._adjust_shutdown_time_by(first_minutes(gate, day))
continue
for units in range(1, (gate.max_units or 1) + 1):
with patch.object(_gate_bonus, "earned_units", return_value=units):
apply_counted_bonus(
state, host._adjust_shutdown_time_by, gate, day.isoformat()
)
return host.config[1]
@pytest.fixture
def frozen_day() -> Iterator[Callable[[date], Any]]:
"""Point every live path's ``today`` at the simulated day."""
def at_day(day: date) -> Any:
iso = day.isoformat()
return patch.multiple(_shutdown, today_str=lambda: iso), patch.multiple(
_grace_floor, today_str=lambda: iso, sick_on=lambda _d: False
)
return at_day
@pytest.mark.parametrize(("maturity", "rest", "workouts", "first"), _CASES)
def test_the_live_pass_lands_every_all_done_day_on_the_ceiling(
maturity: str,
rest: bool,
workouts: int,
first: str,
frozen_day: Callable[[date], Any],
) -> None:
"""Reset + every live credit, in registry order == ceiling on every day."""
case = (maturity, rest, workouts, first)
misses = []
for day in _DAYS:
shutdown_patch, grace_patch = frozen_day(day)
with shutdown_patch, grace_patch:
got = _live_pass(day, case)
_grace_floor.GRACE_STATE_FILE.unlink(missing_ok=True)
if got != ceiling(day):
misses.append((day.isoformat(), got, ceiling(day)))
assert misses == []

View File

@ -161,15 +161,14 @@ class TestRunStatusNormal:
out = capsys.readouterr().out
assert "Shutdown tonight" not in out
def test_shutdown_bonus_and_streak_shown(
def test_streak_shown_and_no_weekly_bonus_line(
self, tmp_path: Path, capsys: pytest.CaptureFixture
) -> None:
"""bonus_hours=3, streak=2, eb_ext=True → shown in output."""
"""streak=2, eb_ext=True → shown; the dropped weekly bonus is not."""
eb_file = tmp_path / "eb.json"
locker = _make_locker(tmp_path / "log.json", n_filled=0)
with (
patch("screen_locker._status.EXTRA_BENEFITS_FILE", eb_file),
patch("screen_locker._status.weekly_shutdown_bonus_hours", return_value=3),
patch("screen_locker._status.current_streak", return_value=2),
patch("screen_locker._status.has_extended_early_bird", return_value=True),
patch("screen_locker._status.count_weekly_workouts", return_value=0),
@ -177,7 +176,7 @@ class TestRunStatusNormal:
):
run_status(locker)
out = capsys.readouterr().out
assert "Shutdown bonus (this wk): 3h" in out
assert "Shutdown bonus" not in out
assert "Streak (5+ wks) : 2" in out
assert "Yes — until 09:00" in out

View File

@ -102,10 +102,10 @@ class TestGatherStatus:
assert snap.shutdown.tonight == (1320, 1380, 300)
def test_bonus_streak_and_extended_early_bird_reflected(
def test_streak_and_early_bird_reflected_legacy_bonus_ignored(
self, tmp_path: Path
) -> None:
"""Bonus streak and extended early bird reflected."""
"""Streak + early bird reflected; a legacy banked weekly bonus adds nothing."""
files = _files(tmp_path)
files["extra_benefits_file"].write_text(
json.dumps(
@ -121,10 +121,9 @@ class TestGatherStatus:
):
snap = gather_status(**files, now=_FRIDAY_NOON_UTC)
assert snap.bonus_hours_this_week == 3
assert snap.streak == 2
assert snap.early_bird_extended is True
assert snap.shutdown.rest_of_week[0].minutes == 23 * 60 # 20h base + 3h 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(
self, tmp_path: Path

View File

@ -51,32 +51,29 @@ main() {
validate_requirements
cd "$SCRIPT_DIR"
step "1/7 Test suite (must be green before touching live state)"
step "1/6 Test suite (must be green before touching live state)"
"$PY" -m pytest screen_locker/tests/ -q --no-cov
step "2/7 Lint + type gate"
step "2/6 Lint + type gate"
"$PY" -m pre_commit run --all-files
step "3/7 Sync: recover Firebase, then ingest today's workout"
step "3/6 Sync: recover Firebase, then ingest today's workout"
# --sync-only never locks, so this is safe to run while disarmed.
./run.sh --sync-only
step "4/7 Did $TARGET_DATE actually land in the log?"
step "4/6 Did $TARGET_DATE actually land in the log?"
# Gate, don't warn: if the credit is not on disk there is nothing to
# revoke a sick day against, and re-arming here would lock you out again.
"$PY" scripts/verify_workout_credited.py --date "$TARGET_DATE"
step "5/7 Restore shutdown hours + grant the bonus"
"$PY" scripts/restore_and_bonus.py --date "$TARGET_DATE"
step "6/7 Revoke the sick days that this workout disproves"
step "5/6 Revoke the sick days that this workout disproves"
"$PY" scripts/revoke_sick_day.py --date "$TARGET_DATE" --date 2026-06-12
if ((ARM)); then
step "7/7 Re-arm enforcement"
step "6/6 Re-arm enforcement"
./arm.sh --rearm
else
step "7/7 Skipped re-arm (--no-arm); enforcement stays OFF"
step "6/6 Skipped re-arm (--no-arm); enforcement stays OFF"
echo "Re-arm later with: ./arm.sh --rearm"
fi

View File

@ -30,10 +30,9 @@ import tempfile
import earned_time
from screen_locker import _earned, _grace_floor, _shutdown
from screen_locker._constants import EXTRA_BENEFITS_FILE, SHUTDOWN_CONFIG_FILE
from screen_locker._constants import SHUTDOWN_CONFIG_FILE
from screen_locker._day import today_str
from screen_locker._earned import hhmm
from screen_locker._extra_benefits import weekly_shutdown_bonus_hours
from screen_locker._rest_day import declare, is_rest_day
from screen_locker._shutdown import read_shutdown_config
from screen_locker._shutdown_target import (
@ -44,7 +43,6 @@ from screen_locker._shutdown_target import (
gather,
)
from scripts._preview_sim import (
HELPER_CEILING,
SimRunnerUp,
SimShutdown,
sick_history,
@ -166,12 +164,7 @@ def main() -> int:
)
target = gather(today, _LOG)
_report("today, real ledgers", target)
weekly = weekly_shutdown_bonus_hours(EXTRA_BENEFITS_FILE) * 60
_say(
f" + banked weekly bonus {weekly} min (layered after the reset, "
f"absorbs the grace lift) -> expected config "
f"{hhmm(min(HELPER_CEILING, max(target.earned + weekly, target.minutes)))}"
)
_say(f" expected config after the next reset: {hhmm(target.minutes)}")
return 0
day = date.fromisoformat(args.day)
_say(f" ladder in force on {day}: {ladder(day) if ladder else False}")

View File

@ -1,147 +0,0 @@
#!/usr/bin/env python3
"""Undo a sick day's shutdown penalty and grant the agreed bonus hours.
Two separate corrections, in this order:
1. **Restore the baseline.** Taking a sick day pulled the shutdown hours down
to 21/21 and stashed the real values in ``sick_day_state.json`` as
``original_mon_wed_hour`` / ``original_thu_sun_hour`` (both 22). Restoring
comes first, because otherwise a "bonus" merely returns you to baseline and
the compensation is silently swallowed.
2. **Bank the bonus.** Written into ``extra_benefits_state.json`` under
``weekly_shutdown_bonus_hours[<ISO week>]`` -- the same key
``_extra_benefits.weekly_shutdown_bonus_hours()`` reads and
``_apply_weekly_shutdown_bonus`` applies every day of the week. The locker
used this map for streak rewards until 2026-W41; since then this script is
its only writer.
Two bonus hours by default: one for each day a completed workout was not
counted (2026-06-12 and 2026-08-24).
Usage:
python3 scripts/restore_and_bonus.py --date 2026-08-24
python3 scripts/restore_and_bonus.py --date 2026-08-24 --bonus-hours 3
"""
from __future__ import annotations
import argparse
from datetime import datetime
import json
import logging
from pathlib import Path
import sys
REPO_ROOT = Path(__file__).resolve().parent.parent
SICK_DAY_STATE_FILE = REPO_ROOT / "screen_locker" / "sick_day_state.json"
EXTRA_BENEFITS_FILE = REPO_ROOT / "screen_locker" / "extra_benefits_state.json"
DEFAULT_BONUS_HOURS = 2
# Failures go to the log (stderr) as well as stdout: this script runs as one
# step of finish_2026_08_24.sh, where a bare stdout line scrolls past in a wall
# of other output, and it mutates benefit state — the exact case where a quiet
# failure looks like success.
_logger = logging.getLogger(__name__)
def _report(message: str) -> None:
"""Write one line to stdout (matches the other scripts in this directory)."""
sys.stdout.write(f"{message}\n")
def _iso_week(date_str: str) -> str:
"""Return ``YYYY-Www`` for ``date_str``, matching ``_current_iso_week``."""
parsed = datetime.strptime(date_str, "%Y-%m-%d").astimezone()
year, week, _ = parsed.isocalendar()
return f"{year}-W{week:02d}"
def _load(path: Path) -> dict:
"""Return ``path``'s JSON object, or an empty dict when absent."""
if not path.is_file():
return {}
try:
return json.loads(path.read_text())
except (OSError, json.JSONDecodeError) as exc:
_logger.warning("Could not read %s: %s — treating as empty", path, exc)
_report(f"WARN: could not read {path}: {exc} — treating as empty")
return {}
def restore_hours(state: dict) -> tuple[int, int] | None:
"""Return the (mon_wed, thu_sun) hours the sick day displaced, if any."""
mon_wed = state.get("original_mon_wed_hour")
thu_sun = state.get("original_thu_sun_hour")
if mon_wed is None or thu_sun is None:
return None
return int(mon_wed), int(thu_sun)
def main() -> int:
"""Restore the pre-sick-day shutdown hours and bank the bonus."""
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--date", required=True, help="the revoked sick day")
parser.add_argument("--bonus-hours", type=int, default=DEFAULT_BONUS_HOURS)
parser.add_argument("--sick-day-state", default=str(SICK_DAY_STATE_FILE))
parser.add_argument("--benefits-file", default=str(EXTRA_BENEFITS_FILE))
args = parser.parse_args()
sick_state = _load(Path(args.sick_day_state))
restored = restore_hours(sick_state)
if restored is None:
_report(
"No original shutdown hours recorded — the baseline is already "
"whatever the daily reset produces, so nothing to restore."
)
else:
mon_wed, thu_sun = restored
_report(
f"Baseline to restore: Mon-Wed={mon_wed}:00, Thu-Sun={thu_sun}:00 "
"(the daily reset re-applies this on the next run)"
)
benefits = _load(Path(args.benefits_file))
week = _iso_week(args.date)
bonus_map = dict(benefits.get("weekly_shutdown_bonus_hours", {}))
existing = int(bonus_map.get(week, 0))
# Idempotence guard. Banking is `existing + bonus`, so without a durable
# record of which dates were already compensated a re-run silently grants
# the hours a second time. The bonus map alone cannot answer that question:
# a week's total mixes earned streak hours with these corrections, so no
# arithmetic on it can tell "already applied" from "earned that much".
granted = list(benefits.get("shutdown_bonus_granted_for", []))
if args.date in granted:
_report(
f"Bonus for {args.date} was already banked into {week} "
f"(currently {existing}h) — not granting it twice."
)
return 0
granted.append(args.date)
benefits["shutdown_bonus_granted_for"] = granted
bonus_map[week] = existing + args.bonus_hours
benefits["weekly_shutdown_bonus_hours"] = bonus_map
try:
Path(args.benefits_file).write_text(json.dumps(benefits, indent=2) + "\n")
except OSError as exc:
_logger.exception(
"Could not write %s — the bonus was NOT banked",
args.benefits_file,
)
_report(f"FAIL: could not write {args.benefits_file}: {exc}")
return 1
_report(
f"Banked +{args.bonus_hours}h shutdown bonus for {week} "
f"(was {existing}h, now {bonus_map[week]}h) — applied every day of "
"that week on top of the restored baseline."
)
return 0
if __name__ == "__main__":
raise SystemExit(main())