diff --git a/steam_backlog_enforcer/_friction.py b/steam_backlog_enforcer/_friction.py index f5ce2a2..193e3ca 100644 --- a/steam_backlog_enforcer/_friction.py +++ b/steam_backlog_enforcer/_friction.py @@ -14,6 +14,8 @@ from __future__ import annotations from dataclasses import dataclass from typing import Final +from steam_backlog_enforcer._store_window import DEFAULT_WINDOW_MINUTES + @dataclass(frozen=True) class Friction: @@ -30,13 +32,19 @@ class Friction: GAMING_RESET_COUNTDOWN_SECONDS: Final = 300 +_STORE_WINDOW_PHRASE: Final = "unblock the store for {minutes} minutes" + FRICTION: Final[dict[str, Friction]] = { "gaming-reset": Friction( "reset today's gaming budget", GAMING_RESET_COUNTDOWN_SECONDS ), "block-gaming": Friction("block all gaming for {days} days"), - "unblock": Friction("unblock the store for {minutes} minutes"), - "buy-dlc": Friction("unblock the store for {minutes} minutes"), + "unblock": Friction(_STORE_WINDOW_PHRASE), + # buy-dlc takes no params: it is always the default-length window, and the + # daemon checks it as an ``unblock`` of that length. So its phrase is that + # exact sentence with the number already in it, not a ``{minutes}`` slot + # that nothing could fill. + "buy-dlc": Friction(_STORE_WINDOW_PHRASE.format(minutes=DEFAULT_WINDOW_MINUTES)), "gaming-unblock": Friction("force release playtime mounts"), "abandon-pick": Friction("abandon {game_name}"), "pick-manual": Friction("lock in {game_name}"), diff --git a/steam_backlog_enforcer/tests/test_command_catalog.py b/steam_backlog_enforcer/tests/test_command_catalog.py index bf9f3c9..ea09602 100644 --- a/steam_backlog_enforcer/tests/test_command_catalog.py +++ b/steam_backlog_enforcer/tests/test_command_catalog.py @@ -2,10 +2,14 @@ from __future__ import annotations +from string import Formatter from unittest.mock import patch from steam_backlog_enforcer import _command_catalog, _command_locks -from steam_backlog_enforcer._friction import Friction +from steam_backlog_enforcer._command_params import PARAMS +from steam_backlog_enforcer._ctl_context import require_phrase +from steam_backlog_enforcer._friction import FRICTION, Friction, expected_phrase +from steam_backlog_enforcer._web_privileged import BUY_DLC_MINUTES from steam_backlog_enforcer.config import Config, State from steam_backlog_enforcer.jobs._specs import JobFlags from steam_backlog_enforcer.main import _shared @@ -115,3 +119,28 @@ class TestCommandLocks: _command_locks._MANUAL_LOCK_EXEMPT_COMMANDS ) assert "gaming-reset" not in _command_locks._TOTAL_BLOCK_EXEMPT_COMMANDS + + +class TestPhrasesAreFillable: + """Every phrase the UI shows can be completed and is the one checked.""" + + def test_every_template_field_has_a_source(self) -> None: + # Mirrors fillPhrase in web/src/commands/catalog.ts: a field is a + # param, ``game_name`` from an ``app_id`` param, or ``count`` from + # the installed list. Anything else leaves Run disabled forever. + # The same two tables ``_spec`` builds each CommandSpec from. + for name in _command_catalog.CATALOG: + friction = FRICTION.get(name) + if friction is None: + continue + params = {p.name for p in PARAMS.get(name, ())} + derived = {"count"} | ({"game_name"} if "app_id" in params else set()) + template = friction.phrase_template + fields = {f for _, f, _, _ in Formatter().parse(template) if f} + assert fields <= params | derived, name + + def test_buy_dlc_phrase_is_the_unblock_the_daemon_checks(self) -> None: + shown = expected_phrase("buy-dlc") + assert shown == expected_phrase("unblock", minutes=BUY_DLC_MINUTES) + assert shown is not None + require_phrase("unblock", shown, minutes=BUY_DLC_MINUTES)