steam-backlog-enforcer/steam_backlog_enforcer/tests/test_mcp.py
Krzysztof kuhy Rudnicki 05e070ca27 feat(enforcer): allow two concurrent manual picks
The enforcer allowed exactly one game: `current_app_id` was the single
"allowed" id threaded through the uninstall guard, the process killer, the
library hider and the auto-installer. Picking a second game manually meant
the first was uninstalled and hidden.

Manual picks are now a set, capped by `Config.max_manual_picks` (default 2).
Every active pick stays installed, visible and un-killed; the lock releases
only when all of them are finished or past their own 14-day deadline.

- `State.manual_picks` holds `{app_id, game_name, started_at}` entries. The
  legacy single-slot `manual_pick_*` fields are still read on load, migrated
  into the list and cleared, so a live lock survives the upgrade.
- `_actions.allowed_app_ids` / `allowed_games` are the single source of truth
  for "may exist". `uninstall_other_games`, `hide_other_games` (incl. its
  injected JS), `enforce_allowed_game` and `_guard_installed_games` now take
  that set instead of one app id; every call site passes it.
- `pick-manual` joins `_MANUAL_LOCK_EXEMPT_COMMANDS` so a second game can be
  added while the first holds the lock — the cap is what limits it, not the
  lock check. Its post-pick cascade operates on the whole allowed set, so
  adding a pick never tears down an earlier one.
- `abandon-pick <app_id>` drops only that pick; a survivor inherits the
  assignment and keeps the lock. Grace and deadline are per pick.
- `cmd_done` is unchanged: it finishes `current_app_id` and auto-picks a
  replacement, so two games stay queued. A finished pick leaves the active
  set automatically via `finished_app_ids`.

Deployment note, learned the hard way during development: the daemon holds
the allowed set in code, so it MUST be restarted after upgrading. A
pre-upgrade daemon knows only `current_app_id` and uninstalled the other pick
as "unauthorized" within seconds of the second pick being made.

Verified live: legacy KCD2 lock migrated on load; a second pick (Vampire
Survivors) was accepted while the first was locked, with both kept by the
uninstall pass; a third was refused at the cap; abandoning one pick left the
other locked and handed it the assignment; the restarted daemon reports
"KEEPING allowed game" for both. 1060 tests pass at 100% coverage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 17:28:23 +02:00

288 lines
9.9 KiB
Python

"""Tests for the MCP server tools in ``_mcp``.
Tools are patched at the ``_mcp`` module namespace (where the helpers are
imported), keeping each tool's own logic under test while isolating the
already-tested leaf functions.
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from unittest.mock import patch
from steam_backlog_enforcer import _mcp
from steam_backlog_enforcer.config import State
from steam_backlog_enforcer.steam_api import GameInfo
def _game(app_id: int, hours: float) -> GameInfo:
return GameInfo(
app_id=app_id,
name=f"g{app_id}",
total_achievements=10,
unlocked_achievements=1,
playtime_minutes=0,
completionist_hours=hours,
)
class TestReadTools:
def test_get_dataset(self) -> None:
with (
patch.object(_mcp, "State") as state,
patch.object(_mcp, "build_web_dataset", return_value="DS") as bwd,
patch.object(_mcp, "dataset_to_payload", return_value={"x": 1}) as dtp,
):
assert _mcp.get_dataset() == {"x": 1}
bwd.assert_called_once_with(state.load.return_value)
dtp.assert_called_once_with("DS")
def test_get_status(self) -> None:
with (
patch.object(_mcp, "State") as state,
patch.object(_mcp, "status_payload", return_value={"ok": 1}) as sp,
):
assert _mcp.get_status() == {"ok": 1}
sp.assert_called_once_with(state.load.return_value)
def test_get_stats_subsets_dataset(self) -> None:
payload = {
"default_summary": {"qualifying": 3},
"pace_vs_hltb": None,
"games": ["ignored"],
}
with (
patch.object(_mcp, "State"),
patch.object(_mcp, "build_web_dataset"),
patch.object(_mcp, "dataset_to_payload", return_value=payload),
):
out = _mcp.get_stats()
assert out == {"default_summary": {"qualifying": 3}, "pace_vs_hltb": None}
class TestListBacklog:
def test_no_snapshot_returns_note(self) -> None:
with patch.object(_mcp, "load_snapshot", return_value=None):
out = _mcp.list_backlog()
assert out["total"] == 0
assert out["games"] == []
assert "note" in out
def test_sorts_shortest_first_excludes_complete_and_caps(self) -> None:
snap = [
{
"app_id": 440,
"name": "TF2",
"total_achievements": 100,
"unlocked_achievements": 10,
"completionist_hours": 50.0,
},
# complete → excluded
{
"app_id": 620,
"name": "Portal 2",
"total_achievements": 50,
"unlocked_achievements": 50,
"completionist_hours": 20.0,
},
{
"app_id": 70,
"name": "HL",
"total_achievements": 10,
"unlocked_achievements": 1,
"completionist_hours": 12.0,
},
# unknown hours → sorted last
{
"app_id": 30,
"name": "NoHrs",
"total_achievements": 10,
"unlocked_achievements": 0,
"completionist_hours": -1,
},
]
with patch.object(_mcp, "load_snapshot", return_value=snap):
out = _mcp.list_backlog(limit=2)
assert out["total"] == 3
assert out["returned"] == 2
assert [g["app_id"] for g in out["games"]] == [70, 440]
assert out["games"][0]["completion_pct"] == 10.0
def test_negative_limit_returns_none(self) -> None:
snap = [
{
"app_id": 70,
"name": "HL",
"total_achievements": 10,
"unlocked_achievements": 1,
}
]
with patch.object(_mcp, "load_snapshot", return_value=snap):
out = _mcp.list_backlog(limit=-5)
assert out["returned"] == 0
assert out["games"] == []
class TestResolveAndSort:
def test_resolve_found(self) -> None:
with patch.object(
_mcp, "load_snapshot", return_value=[{"app_id": 440, "name": "TF2"}]
):
assert _mcp._resolve_game_name(440) == "TF2"
def test_resolve_missing(self) -> None:
with patch.object(
_mcp, "load_snapshot", return_value=[{"app_id": 1, "name": "X"}]
):
assert _mcp._resolve_game_name(440) is None
def test_resolve_no_snapshot(self) -> None:
with patch.object(_mcp, "load_snapshot", return_value=None):
assert _mcp._resolve_game_name(440) is None
def test_sort_key_branches(self) -> None:
assert _mcp._backlog_sort_key(_game(1, 5.0)) == (0, 5.0)
assert _mcp._backlog_sort_key(_game(2, -1)) == (1, 0.0)
class TestPickManualGate:
def test_not_found(self) -> None:
with patch.object(_mcp, "load_snapshot", return_value=[]):
out = _mcp.pick_manual(440)
assert out["ok"] is False
assert "not found" in out["reason"]
def test_preview_does_not_mutate(self) -> None:
with (
patch.object(
_mcp, "load_snapshot", return_value=[{"app_id": 440, "name": "TF2"}]
),
patch.object(_mcp, "apply_manual_pick") as amp,
):
out = _mcp.pick_manual(440)
assert out["preview"] is True
assert out["game_name"] == "TF2"
amp.assert_not_called()
def test_confirm_applies(self) -> None:
with (
patch.object(
_mcp, "load_snapshot", return_value=[{"app_id": 440, "name": "TF2"}]
),
patch.object(_mcp, "State") as state,
patch.object(_mcp, "Config") as config,
patch.object(_mcp, "apply_manual_pick", return_value=None) as amp,
):
out = _mcp.pick_manual(440, confirm=True)
assert out["applied"] is True
assert out["app_id"] == 440
amp.assert_called_once_with(
state.load.return_value,
440,
"TF2",
max_picks=config.load.return_value.max_manual_picks,
)
class TestAbandonPickGate:
"""The MCP escape hatch mirrors the CLI grace rules, state-only."""
def _state(self, *, days_ago: float = 1.0, app_id: int = 440) -> State:
started = (datetime.now(timezone.utc) - timedelta(days=days_ago)).isoformat()
return State(
manual_picks=[
{"app_id": app_id, "game_name": "TF2", "started_at": started}
],
current_app_id=app_id,
current_game_name="TF2",
)
def test_no_active_pick(self) -> None:
with patch.object(_mcp.State, "load", return_value=State()):
out = _mcp.abandon_pick(440)
assert out["ok"] is False
assert "No manual pick" in out["reason"]
def test_wrong_app_id(self) -> None:
with patch.object(_mcp.State, "load", return_value=self._state()):
out = _mcp.abandon_pick(999)
assert out["ok"] is False
assert "not one of the active manual picks" in out["reason"]
def test_expired_grace(self) -> None:
state = self._state(days_ago=_mcp.MANUAL_GRACE_DAYS + 1)
with patch.object(_mcp.State, "load", return_value=state):
out = _mcp.abandon_pick(440)
assert out["ok"] is False
assert "grace period has expired" in out["reason"]
def test_preview_does_not_mutate(self) -> None:
with (
patch.object(_mcp.State, "load", return_value=self._state()),
patch.object(_mcp, "abandon_manual_pick") as amp,
):
out = _mcp.abandon_pick(440)
assert out["preview"] is True
assert out["game_name"] == "TF2"
assert out["grace_days_left"] > 0
amp.assert_not_called()
def test_confirm_applies(self) -> None:
state = self._state()
with (
patch.object(_mcp.State, "load", return_value=state),
patch.object(_mcp, "abandon_manual_pick") as amp,
):
out = _mcp.abandon_pick(440, confirm=True)
assert out["applied"] is True
assert out["app_id"] == 440
amp.assert_called_once_with(state, 440)
def test_refused_at_cap(self) -> None:
with (
patch.object(
_mcp, "load_snapshot", return_value=[{"app_id": 440, "name": "TF2"}]
),
patch.object(_mcp, "State"),
patch.object(_mcp, "Config"),
patch.object(_mcp, "apply_manual_pick", return_value="cap reached"),
):
out = _mcp.pick_manual(440, confirm=True)
assert out["ok"] is False
assert out["reason"] == "cap reached"
class TestBlockGamingGate:
def test_invalid_days(self) -> None:
out = _mcp.block_gaming(0)
assert out["ok"] is False
def test_preview(self) -> None:
out = _mcp.block_gaming(3)
assert out["preview"] is True
assert out["requires_root"] is True
def test_confirm_success(self) -> None:
with patch.object(_mcp, "start_total_block", return_value=True):
out = _mcp.block_gaming(3, confirm=True)
assert out["applied"] is True
assert out["days"] == 3
def test_confirm_unprivileged_returns_gracefully(self) -> None:
with patch.object(_mcp, "start_total_block", return_value=False):
out = _mcp.block_gaming(3, confirm=True)
assert out["ok"] is False
assert "privileges" in out["reason"]
def test_confirm_oserror_returns_gracefully(self) -> None:
with patch.object(_mcp, "start_total_block", side_effect=OSError("boom")):
out = _mcp.block_gaming(3, confirm=True)
assert out["ok"] is False
assert "privileges" in out["reason"]
def test_main_runs_stdio_server() -> None:
with patch.object(_mcp.mcp, "run") as run:
_mcp.main()
run.assert_called_once_with()