steam-backlog-enforcer/steam_backlog_enforcer/tests/test_config.py
Krzysztof kuhy Rudnicki 1faf211767 Show which games make up the gaming budget, and bill osu!lazer
The budget tab answered "how much did I play" but not "what did I play":
playtime_state.json and playtime_history.json each stored one aggregate
seconds figure per gaming day, so the 14-day chart was a row of featureless
bars. Separately, qualifying_pids had exactly two sources — the SteamAppId
env var and a hardcoded 16-launcher frozenset — so osu!lazer, which sets no
SteamAppId and matches no launcher name, was never billed at all.

qualifying_pids now returns pid -> attribution key instead of a bare pid set.
The app id was already in hand and thrown away on its first line; it now
survives into PlaytimeState.per_game, alongside a user-editable
counted_processes list in config.json seeded with osu!lazer.

Two things the parts/whole relationship forces:

  - per_game only ever sums to AT MOST seconds, never equals it. backdate
    refunds the idle grace on every engaged-to-paused edge, and
    _state_or_recover synthesises a full budget with no breakdown. The
    refund is debited from last_credited_key, and the remainder is rendered
    as "Unattributed" rather than stored — one path then covers legacy days,
    focus-probe failures and drift alike.

  - counted_processes is kept apart from LAUNCHER_PROCESS_NAMES. That
    frozenset is also the total-block kill list, and it routes through
    kill_and_uninstall_launchers, which pacman -R's the owning package.
    Counted entries are killed on total block, never uninstalled.

Both storage formats stay at schema 1: load_state already filters to
dataclass fields, so a record without per_game migrates for free, and the
history reader has to accept both a bare float and an object regardless, so
a version bump would distinguish nothing.

Chart segments use the shared design system's categorical ramp (--cat-1..6),
capped at six games plus "Other" because a seventh distinguishable hue does
not exist under simulated CVD. The red over-budget fill is gone: colour now
encodes which game, and the dashed budget line alone says over or under.

Snapshot persistence moves to _snapshot.py to keep config.py under the
250-line cap. Three modules bound config paths at import time, which
bypasses the conftest redirect — they now reach them through the module.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MKFzwzwRU1JD7SFLHtQBsz
2026-08-29 18:32:03 +02:00

196 lines
7.1 KiB
Python

"""Tests for config module."""
from __future__ import annotations
from datetime import UTC
import json
from typing import TYPE_CHECKING, Any
from unittest.mock import patch
from steam_backlog_enforcer._snapshot import load_snapshot, save_snapshot
from steam_backlog_enforcer.config import Config, State
if TYPE_CHECKING:
from pathlib import Path
class TestConfig:
"""Tests for Config dataclass."""
def test_defaults(self) -> None:
"""Test defaults."""
cfg = Config()
assert cfg.steam_api_key == ""
assert cfg.steam_id == ""
assert cfg.block_store is True
assert cfg.kill_unauthorized_games is True
assert cfg.uninstall_other_games is True
assert cfg.desktop_notifications is True
def test_save(self, tmp_path: Path) -> None:
"""Test save."""
cfg = Config(steam_api_key="abc", steam_id="123")
config_dir = tmp_path / "cfg"
config_file = config_dir / "config.json"
with (
patch("steam_backlog_enforcer.config.CONFIG_DIR", config_dir),
patch("steam_backlog_enforcer.config.CONFIG_FILE", config_file),
):
cfg.save()
data = json.loads(config_file.read_text(encoding="utf-8"))
assert data["steam_api_key"] == "abc"
assert data["steam_id"] == "123"
def test_load_existing(self, tmp_path: Path) -> None:
"""Test load existing."""
config_file = tmp_path / "config.json"
config_file.write_text(
json.dumps({"steam_api_key": "key1", "steam_id": "id1"}) + "\n",
encoding="utf-8",
)
with patch("steam_backlog_enforcer.config.CONFIG_FILE", config_file):
cfg = Config.load()
assert cfg.steam_api_key == "key1"
assert cfg.steam_id == "id1"
def test_load_missing(self, tmp_path: Path) -> None:
"""Test load missing."""
config_file = tmp_path / "nonexistent.json"
with patch("steam_backlog_enforcer.config.CONFIG_FILE", config_file):
cfg = Config.load()
assert cfg.steam_api_key == ""
def test_load_extra_fields_ignored(self, tmp_path: Path) -> None:
"""Test load extra fields ignored."""
config_file = tmp_path / "config.json"
config_file.write_text(
json.dumps({"steam_api_key": "k", "unknown_field": 42}) + "\n",
encoding="utf-8",
)
with patch("steam_backlog_enforcer.config.CONFIG_FILE", config_file):
cfg = Config.load()
assert cfg.steam_api_key == "k"
class TestState:
"""Tests for State dataclass."""
def test_defaults(self) -> None:
"""Test defaults."""
state = State()
assert state.current_app_id is None
assert state.current_game_name == ""
assert state.finished_app_ids == []
def test_save(self, tmp_path: Path) -> None:
"""Test save."""
state = State(current_app_id=100, current_game_name="TestGame")
config_dir = tmp_path / "cfg"
state_file = config_dir / "state.json"
with (
patch("steam_backlog_enforcer.config.CONFIG_DIR", config_dir),
patch("steam_backlog_enforcer.config.STATE_FILE", state_file),
):
state.save()
data = json.loads(state_file.read_text(encoding="utf-8"))
assert data["current_app_id"] == 100
assert data["current_game_name"] == "TestGame"
def test_load_existing(self, tmp_path: Path) -> None:
"""Test load existing."""
state_file = tmp_path / "state.json"
state_file.write_text(
json.dumps(
{
"current_app_id": 50,
"current_game_name": "G",
"finished_app_ids": [1, 2],
}
)
+ "\n",
encoding="utf-8",
)
with patch("steam_backlog_enforcer.config.STATE_FILE", state_file):
st = State.load()
assert st.current_app_id == 50
assert st.finished_app_ids == [1, 2]
def test_load_missing(self, tmp_path: Path) -> None:
"""Test load missing."""
state_file = tmp_path / "nonexistent.json"
with patch("steam_backlog_enforcer.config.STATE_FILE", state_file):
st = State.load()
assert st.current_app_id is None
def test_load_corrupt(self, tmp_path: Path) -> None:
"""Test load corrupt."""
state_file = tmp_path / "state.json"
state_file.write_text("not valid json{{", encoding="utf-8")
with patch("steam_backlog_enforcer.config.STATE_FILE", state_file):
st = State.load()
assert st.current_app_id is None
assert st.current_game_name == ""
def test_skip_for_days_records_iso_timestamp(self) -> None:
"""Test skip for days records iso timestamp."""
state = State()
state.skip_for_days(42, 7)
assert "42" in state.skipped_until
# Round-trip parse and check ~7 days in the future.
from datetime import datetime
expiry = datetime.fromisoformat(state.skipped_until["42"])
delta = (expiry - datetime.now(UTC)).total_seconds()
assert 6 * 86400 < delta <= 7 * 86400 + 1
def test_active_skipped_ids_returns_active(self) -> None:
"""Test active skipped ids returns active."""
from datetime import datetime, timedelta
state = State()
future = datetime.now(UTC) + timedelta(days=3)
state.skipped_until["100"] = future.isoformat()
assert state.active_skipped_ids() == {100}
# Active entry retained.
assert "100" in state.skipped_until
def test_active_skipped_ids_prunes_expired(self) -> None:
"""Test active skipped ids prunes expired."""
from datetime import datetime, timedelta
state = State()
past = datetime.now(UTC) - timedelta(days=1)
state.skipped_until["50"] = past.isoformat()
assert state.active_skipped_ids() == set()
assert "50" not in state.skipped_until
def test_active_skipped_ids_prunes_malformed(self) -> None:
"""Test active skipped ids prunes malformed."""
state = State()
state.skipped_until["77"] = "not-a-date"
assert state.active_skipped_ids() == set()
assert "77" not in state.skipped_until
class TestSnapshot:
"""Tests for snapshot save/load."""
def test_save_and_load(self, tmp_path: Path) -> None:
"""Test save and load."""
config_dir = tmp_path / "cfg"
snap_file = config_dir / "snapshot.json"
with (
patch("steam_backlog_enforcer.config.CONFIG_DIR", config_dir),
patch("steam_backlog_enforcer.config.SNAPSHOT_FILE", snap_file),
):
data: list[dict[str, Any]] = [{"app_id": 1, "name": "G1"}]
save_snapshot(data)
loaded = load_snapshot()
assert loaded == data
def test_load_none(self, tmp_path: Path) -> None:
"""Test load none."""
snap_file = tmp_path / "nonexistent.json"
with patch("steam_backlog_enforcer.config.SNAPSHOT_FILE", snap_file):
assert load_snapshot() is None