feedBack/tests/test_progression.py
Sin 4148b0e72e Purge external-format terminology from code, tests and docs
Reword comments/docstrings/strings and rename identifiers that referenced
the external game and its file formats:

- format-id "psarc" -> "archive"; local vars psarc_path -> song_path,
  psarc_base -> tone_base
- lyrics provenance value "sng" -> "notechart" (legacy "sng" still accepted)
- highway_3d fret-ghost scope value "rocksmith" -> "chords" (invalid/legacy
  values fall back to the default, preserving behaviour)
- neutralise references in prose, test names/data, .gitattributes and docs

No functional change beyond the renamed identifiers; all Python compiles.
2026-06-16 19:36:53 +01:00

643 lines
24 KiB
Python

"""Tests for lib/progression.py — the pure progression engine (spec 010)."""
import json
from datetime import datetime
from pathlib import Path
import pytest
from progression import (
active_challenges,
evaluate_event,
goal_matches_event,
instrument_for_arrangement,
load_content,
mastery_rank,
path_max_level,
period_keys,
period_resets_at,
select_quests,
threshold_goal_met,
wallet_balance,
)
REPO_ROOT = Path(__file__).resolve().parents[1]
BUNDLED_CONTENT = REPO_ROOT / "data" / "progression"
# ---------------------------------------------------------------------------
# Content loading
# ---------------------------------------------------------------------------
def test_bundled_content_loads_clean():
content, warnings = load_content(BUNDLED_CONTENT)
assert warnings == []
assert set(content["paths"]) == {"guitar", "bass", "drums", "keys"}
assert content["challenge_index"]
assert content["quests"]["daily"]["count"] == 3
assert content["quests"]["weekly"]["count"] == 2
assert content["quests"]["daily"]["pool"]
assert content["shop"]
# Every indexed challenge id is namespaced under its path.
for cid, entry in content["challenge_index"].items():
assert cid.startswith(entry["path_id"] + ".")
def _write_content(root: Path, paths=None, quests=None, shop=None):
(root / "paths").mkdir(parents=True, exist_ok=True)
for name, data in (paths or {}).items():
(root / "paths" / f"{name}.json").write_text(
data if isinstance(data, str) else json.dumps(data)
)
(root / "quests.json").write_text(json.dumps(quests if quests is not None else {}))
(root / "shop.json").write_text(json.dumps(shop if shop is not None else {}))
def _minimal_path(pid="guitar", level=1, required=1, challenges=None):
return {
"id": pid,
"name": pid.title(),
"levels": [
{
"level": level,
"required": required,
"challenges": challenges
or [
{
"id": f"{pid}.l{level}.c1",
"title": "C1",
"goal": {"type": "song_completed", "target": 1},
}
],
}
],
}
def test_load_content_skips_bad_json_file(tmp_path):
_write_content(tmp_path, paths={"guitar": _minimal_path()})
(tmp_path / "paths" / "broken.json").write_text("{not json")
content, warnings = load_content(tmp_path)
assert "guitar" in content["paths"]
assert any("broken.json" in w for w in warnings)
def test_load_content_rejects_unknown_goal_type(tmp_path):
bad = _minimal_path(
challenges=[
{"id": "guitar.l1.bad", "goal": {"type": "moonwalk", "target": 1}},
{"id": "guitar.l1.ok", "goal": {"type": "song_completed", "target": 1}},
]
)
_write_content(tmp_path, paths={"guitar": bad})
content, warnings = load_content(tmp_path)
ids = list(content["challenge_index"])
assert ids == ["guitar.l1.ok"]
assert any("unknown goal type" in w for w in warnings)
def test_load_content_rejects_duplicate_challenge_ids(tmp_path):
p = _minimal_path(
challenges=[
{"id": "guitar.l1.dup", "goal": {"type": "song_completed", "target": 1}},
{"id": "guitar.l1.dup", "goal": {"type": "minigame_run", "target": 1}},
]
)
_write_content(tmp_path, paths={"guitar": p})
content, warnings = load_content(tmp_path)
assert list(content["challenge_index"]) == ["guitar.l1.dup"]
assert content["challenge_index"]["guitar.l1.dup"]["challenge"]["goal"]["type"] == (
"song_completed"
)
assert any("duplicate challenge id" in w for w in warnings)
def test_load_content_clamps_required_and_warns_on_level_gap(tmp_path):
p = _minimal_path(required=5)
p["levels"].append(
{
"level": 3, # gap: level 2 missing
"required": 1,
"challenges": [
{"id": "guitar.l3.c1", "goal": {"type": "minigame_run", "target": 1}}
],
}
)
_write_content(tmp_path, paths={"guitar": p})
content, warnings = load_content(tmp_path)
assert content["paths"]["guitar"]["levels"][0]["required"] == 1 # clamped
assert any("clamped" in w for w in warnings)
assert any("numbering gap" in w for w in warnings)
def test_load_content_validates_quests_and_shop(tmp_path):
_write_content(
tmp_path,
paths={"guitar": _minimal_path()},
quests={
"daily": {
"count": 2,
"pool": [
{"id": "d.ok", "reward_db": 10, "goal": {"type": "minigame_run", "target": 1}},
{"id": "d.neg", "reward_db": -5, "goal": {"type": "minigame_run", "target": 1}},
{"id": "d.badgoal", "reward_db": 10, "goal": {"type": "nope", "target": 1}},
],
}
},
shop={
"items": [
{"id": "theme.ok", "slot": "theme", "cost": 100, "payload": {"colors": {}}},
{"id": "hat.bad", "slot": "hat", "cost": 100, "payload": {}},
{"id": "theme.negcost", "slot": "theme", "cost": -1, "payload": {}},
]
},
)
content, warnings = load_content(tmp_path)
assert list(content["quests"]["daily"]["pool"]) == ["d.ok"]
assert any("missing" in w and "weekly" in w for w in warnings)
assert list(content["shop"]) == ["theme.ok"]
assert any("unknown slot" in w for w in warnings)
assert any("cost" in w for w in warnings)
assert any("reward_db" in w for w in warnings)
def test_load_content_missing_root_is_nonfatal(tmp_path):
content, warnings = load_content(tmp_path / "nope")
assert content["paths"] == {}
assert warnings
# ---------------------------------------------------------------------------
# Instrument attribution
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"entry,expected",
[
({"type": "lead"}, "guitar"),
({"type": "rhythm"}, "guitar"),
({"type": "combo"}, "guitar"),
({"type": "bass"}, "bass"),
({"type": "drums"}, "drums"),
({"name": "Bass"}, "bass"),
({"name": "Drum Kit"}, "drums"),
({"name": "Percussion"}, "drums"),
({"name": "Vocals"}, "vocals"),
({"name": "Lead"}, "guitar"),
({"type": "piano"}, "keys"),
({"type": "keys"}, "keys"),
({"name": "Keys"}, "keys"),
({"name": "Piano"}, "keys"),
({"name": "Keyboard"}, "keys"),
({"name": "Synth Lead"}, "keys"),
# bass name check wins before the keys word-match
({"name": "Bass Synth"}, "bass"),
# word-boundary: "keys" inside another word is not keys
({"name": "Monkeys Medley"}, "guitar"),
({}, "guitar"),
(None, "guitar"),
# name overrides generic guitar type (legacy archive keys arrangements)
({"type": "lead", "name": "Keys"}, "keys"),
({"type": "combo", "name": "Piano"}, "keys"),
({"type": "lead", "name": "Bass"}, "bass"),
],
)
def test_instrument_for_arrangement(entry, expected):
assert instrument_for_arrangement(entry) == expected
# ---------------------------------------------------------------------------
# Quest periods
# ---------------------------------------------------------------------------
def test_period_keys():
keys = period_keys(datetime(2026, 6, 12, 15, 30))
assert keys == {"daily": "2026-06-12", "weekly": "2026-W24"}
def test_period_keys_iso_year_boundary():
# 2024-12-30 is a Monday belonging to ISO week 1 of 2025.
keys = period_keys(datetime(2024, 12, 30, 8, 0))
assert keys["daily"] == "2024-12-30"
assert keys["weekly"] == "2025-W01"
def test_period_resets_at_daily():
resets = period_resets_at("daily", datetime(2026, 6, 12, 23, 59))
assert resets == datetime(2026, 6, 13, 0, 0)
def test_period_resets_at_weekly():
# 2026-06-12 is a Friday; next Monday is 2026-06-15.
assert period_resets_at("weekly", datetime(2026, 6, 12, 10, 0)) == datetime(2026, 6, 15)
# From a Monday the period runs a full week.
assert period_resets_at("weekly", datetime(2026, 6, 15, 0, 0)) == datetime(2026, 6, 22)
def test_period_resets_at_rejects_unknown():
with pytest.raises(ValueError):
period_resets_at("hourly", datetime(2026, 6, 12))
def test_select_quests_deterministic():
pool = [f"q{i}" for i in range(10)]
first = select_quests(pool, "daily", "2026-06-12", 3)
assert first == select_quests(list(reversed(pool)), "daily", "2026-06-12", 3)
assert len(first) == 3
assert first == sorted(first)
assert set(first) <= set(pool)
def test_select_quests_varies_across_periods():
pool = [f"q{i}" for i in range(10)]
selections = {
tuple(select_quests(pool, "daily", f"2026-06-{day:02d}", 3))
for day in range(1, 15)
}
assert len(selections) > 1
def test_select_quests_small_pool_returns_all():
assert select_quests(["b", "a"], "weekly", "2026-W24", 5) == ["a", "b"]
# ---------------------------------------------------------------------------
# Goal matching
# ---------------------------------------------------------------------------
def _song_event(**payload):
base = {"filename": "song.archive", "instrument": "guitar", "accuracy": 0.9, "score": 1000}
base.update(payload)
return {"type": "song_completed", "payload": base}
def test_goal_matches_song_accuracy_boundary():
goal = {"type": "song_completed", "min_accuracy": 0.8, "target": 1}
assert goal_matches_event(goal, _song_event(accuracy=0.8))
assert not goal_matches_event(goal, _song_event(accuracy=0.799))
assert not goal_matches_event(goal, _song_event(accuracy=None))
def test_goal_matches_song_filters():
assert not goal_matches_event(
{"type": "song_completed", "instrument": "bass", "target": 1}, _song_event()
)
assert goal_matches_event(
{"type": "song_completed", "instrument": "guitar", "target": 1}, _song_event()
)
assert goal_matches_event(
{"type": "song_completed", "filename": "song.archive", "target": 1}, _song_event()
)
assert not goal_matches_event(
{"type": "song_completed", "filename": "other.archive", "target": 1}, _song_event()
)
assert not goal_matches_event(
{"type": "song_completed", "min_score": 2000, "target": 1}, _song_event()
)
def test_goal_matches_songs_played_total():
goal = {"type": "songs_played_total", "target": 3}
assert goal_matches_event(goal, _song_event(instrument="bass", accuracy=0.1))
assert not goal_matches_event(goal, {"type": "minigame_run", "payload": {}})
def test_diagnostic_plays_do_not_count_as_regular_songs():
# The calibration sloppak must not feed song challenges/quests — finishing
# onboarding at 100% yields rank 1 exactly, not rank 1 + first-challenge
# completions (it IS a perfect guitar play, after all).
diag = _song_event(filename="diagnostics-builtin/diag.sloppak",
is_diagnostic=True, accuracy=1.0)
assert not goal_matches_event(
{"type": "song_completed", "instrument": "guitar", "target": 1}, diag)
assert not goal_matches_event(
{"type": "song_completed", "min_accuracy": 0.8, "target": 1}, diag)
assert not goal_matches_event({"type": "songs_played_total", "target": 3}, diag)
# …unless a goal explicitly targets the diagnostic by filename.
assert goal_matches_event(
{"type": "song_completed", "filename": "diagnostics-builtin/diag.sloppak", "target": 1},
diag)
def test_diagnostic_play_still_completes_calibration_but_no_challenges():
outcome = evaluate_event(
_song_event(is_diagnostic=True, accuracy=1.0),
_content(),
_snapshot(calibration_status="pending"),
)
assert outcome["calibration_completed"] is True
assert outcome["challenges"] == []
assert outcome["level_ups"] == []
def test_goal_matches_minigame():
event = {"type": "minigame_run", "payload": {"game_id": "chord_sprint", "score": 500}}
assert goal_matches_event({"type": "minigame_run", "target": 1}, event)
assert goal_matches_event(
{"type": "minigame_run", "game_id": "chord_sprint", "min_score": 500, "target": 1}, event
)
assert not goal_matches_event(
{"type": "minigame_run", "game_id": "flappy_bend", "target": 1}, event
)
assert not goal_matches_event(
{"type": "minigame_run", "min_score": 501, "target": 1}, event
)
def test_goal_matches_quest_completed():
event = {"type": "quest_completed", "payload": {"period_type": "daily", "quest_id": "d.x"}}
assert goal_matches_event({"type": "quest_completed", "target": 3}, event)
assert goal_matches_event({"type": "quest_completed", "period": "daily", "target": 3}, event)
assert not goal_matches_event(
{"type": "quest_completed", "period": "weekly", "target": 3}, event
)
def test_threshold_goals():
assert threshold_goal_met({"type": "streak_reached", "days": 7}, {"streak": 7})
assert not threshold_goal_met({"type": "streak_reached", "days": 7}, {"streak": 6})
assert threshold_goal_met({"type": "db_earned", "amount": 100}, {"xp_total": 100})
assert not threshold_goal_met({"type": "db_earned", "amount": 100}, {"xp_total": 99})
assert not threshold_goal_met({"type": "song_completed", "target": 1}, {})
# ---------------------------------------------------------------------------
# evaluate_event
# ---------------------------------------------------------------------------
def _content(levels=None, daily_pool=None, weekly_pool=None):
"""Small in-memory content fixture."""
levels = levels or [
{
"level": 1,
"required": 2,
"challenges": [
{
"id": "guitar.l1.any",
"title": "Any",
"description": "",
"goal": {"type": "song_completed", "instrument": "guitar", "target": 1},
},
{
"id": "guitar.l1.acc",
"title": "Acc",
"description": "",
"goal": {
"type": "song_completed",
"instrument": "guitar",
"min_accuracy": 0.8,
"target": 1,
},
},
{
"id": "guitar.l1.three",
"title": "Three",
"description": "",
"goal": {"type": "song_completed", "target": 3},
},
],
},
{
"level": 2,
"required": 1,
"challenges": [
{
"id": "guitar.l2.distinct",
"title": "Distinct",
"description": "",
"goal": {"type": "song_completed", "distinct": True, "target": 2},
}
],
},
]
paths = {"guitar": {"id": "guitar", "name": "Guitar", "icon": "", "order": 1, "levels": levels}}
index = {}
for entry in levels:
for ch in entry["challenges"]:
index[ch["id"]] = {"path_id": "guitar", "level": entry["level"], "challenge": ch}
return {
"paths": paths,
"challenge_index": index,
"quests": {
"daily": {"count": 1, "pool": {q["id"]: q for q in (daily_pool or [])}},
"weekly": {"count": 1, "pool": {q["id"]: q for q in (weekly_pool or [])}},
},
"shop": {},
}
def _snapshot(**overrides):
snap = {
"calibration_status": "completed",
"paths": {"guitar": 0},
"challenges": {},
"quests": [],
"streak": 0,
"xp_total": 0,
}
snap.update(overrides)
return snap
def test_evaluate_event_increments_and_completes_challenges():
outcome = evaluate_event(_song_event(accuracy=0.95), _content(), _snapshot())
by_id = {c["challenge_id"]: c for c in outcome["challenges"]}
assert by_id["guitar.l1.any"]["completed"] is True
assert by_id["guitar.l1.acc"]["completed"] is True
assert by_id["guitar.l1.three"]["count"] == 1
assert by_id["guitar.l1.three"]["completed"] is False
# Two completions in one event meet required=2 -> level up.
assert outcome["level_ups"] == [{"path_id": "guitar", "new_level": 1}]
def test_evaluate_event_no_levelup_below_required():
outcome = evaluate_event(_song_event(accuracy=0.5), _content(), _snapshot())
by_id = {c["challenge_id"]: c for c in outcome["challenges"]}
assert by_id["guitar.l1.any"]["completed"] is True
assert "guitar.l1.acc" not in by_id
assert outcome["level_ups"] == []
def test_evaluate_event_counts_prior_completions_toward_levelup():
snapshot = _snapshot(challenges={"guitar.l1.acc": {"count": 1, "completed": True}})
outcome = evaluate_event(_song_event(accuracy=0.5), _content(), snapshot)
assert outcome["level_ups"] == [{"path_id": "guitar", "new_level": 1}]
def test_evaluate_event_distinct_dedupes_replays():
content = _content()
snapshot = _snapshot(paths={"guitar": 1}) # working level-2 set
first = evaluate_event(_song_event(filename="a.archive"), content, snapshot)
ch = first["challenges"][0]
assert ch["count"] == 1 and not ch["completed"]
assert ch["detail"] == {"seen": ["a.archive"]}
snapshot["challenges"] = {
"guitar.l2.distinct": {"count": 1, "completed": False, "detail": ch["detail"]}
}
replay = evaluate_event(_song_event(filename="a.archive"), content, snapshot)
assert replay["challenges"] == [] # same song again: no advance
other = evaluate_event(_song_event(filename="b.archive"), content, snapshot)
ch2 = other["challenges"][0]
assert ch2["count"] == 2 and ch2["completed"]
assert other["level_ups"] == [{"path_id": "guitar", "new_level": 2}]
def test_evaluate_event_default_counts_replays():
# guitar.l1.three has no distinct flag: the same song three times completes it.
content = _content()
snapshot = _snapshot(challenges={"guitar.l1.three": {"count": 2, "completed": False}})
outcome = evaluate_event(_song_event(filename="same.archive", accuracy=0.1), content, snapshot)
by_id = {c["challenge_id"]: c for c in outcome["challenges"]}
assert by_id["guitar.l1.three"]["count"] == 3
assert by_id["guitar.l1.three"]["completed"] is True
def test_evaluate_event_threshold_challenges_complete_on_any_event():
levels = [
{
"level": 1,
"required": 1,
"challenges": [
{
"id": "guitar.l1.streak",
"title": "Streak",
"description": "",
"goal": {"type": "streak_reached", "days": 3},
}
],
}
]
content = _content(levels=levels)
quiet = evaluate_event({"type": "minigame_run", "payload": {}}, content, _snapshot(streak=2))
assert quiet["challenges"] == []
outcome = evaluate_event({"type": "minigame_run", "payload": {}}, content, _snapshot(streak=3))
assert outcome["challenges"][0]["completed"] is True
assert outcome["level_ups"] == [{"path_id": "guitar", "new_level": 1}]
def test_evaluate_event_quests():
daily = [
{
"id": "d.two",
"title": "Two songs",
"description": "",
"reward_db": 50,
"goal": {"type": "song_completed", "target": 2},
}
]
content = _content(daily_pool=daily)
quests = [
{"period_type": "daily", "quest_id": "d.two", "count": 0, "completed": False,
"reward_db": 50, "detail": None},
{"period_type": "daily", "quest_id": "d.gone", "count": 0, "completed": False,
"reward_db": 10, "detail": None}, # no longer in pool: ignored
]
snapshot = _snapshot(quests=quests)
first = evaluate_event(_song_event(), content, snapshot)
assert len(first["quests"]) == 1
q = first["quests"][0]
assert (q["quest_id"], q["count"], q["completed"]) == ("d.two", 1, False)
snapshot["quests"][0]["count"] = 1
second = evaluate_event(_song_event(), content, snapshot)
q = second["quests"][0]
assert q["completed"] is True and q["reward_db"] == 50
def test_evaluate_event_quest_completion_feeds_challenges():
levels = [
{
"level": 1,
"required": 1,
"challenges": [
{
"id": "guitar.l1.quests",
"title": "Quests",
"description": "",
"goal": {"type": "quest_completed", "period": "daily", "target": 1},
}
],
}
]
content = _content(levels=levels)
event = {"type": "quest_completed", "payload": {"period_type": "daily", "quest_id": "d.x"}}
outcome = evaluate_event(event, content, _snapshot())
assert outcome["challenges"][0]["completed"] is True
assert outcome["level_ups"] == [{"path_id": "guitar", "new_level": 1}]
def test_evaluate_event_calibration():
content = _content()
diag = _song_event(is_diagnostic=True, accuracy=1.0)
assert evaluate_event(diag, content, _snapshot(calibration_status="pending"))[
"calibration_completed"
]
# A skipped player can still upgrade to completed.
assert evaluate_event(diag, content, _snapshot(calibration_status="skipped"))[
"calibration_completed"
]
assert not evaluate_event(diag, content, _snapshot(calibration_status="completed"))[
"calibration_completed"
]
near_miss = _song_event(is_diagnostic=True, accuracy=0.99)
assert not evaluate_event(near_miss, content, _snapshot(calibration_status="pending"))[
"calibration_completed"
]
not_diag = _song_event(accuracy=1.0)
assert not evaluate_event(not_diag, content, _snapshot(calibration_status="pending"))[
"calibration_completed"
]
def test_evaluate_event_ignores_unselected_paths():
outcome = evaluate_event(_song_event(), _content(), _snapshot(paths={}))
assert outcome["challenges"] == []
assert outcome["level_ups"] == []
def test_evaluate_event_at_max_level_is_quiet():
outcome = evaluate_event(_song_event(), _content(), _snapshot(paths={"guitar": 2}))
assert outcome["challenges"] == []
assert outcome["level_ups"] == []
# ---------------------------------------------------------------------------
# Rank / wallet / helpers
# ---------------------------------------------------------------------------
def test_mastery_rank():
assert mastery_rank("pending", {}) == 0
assert mastery_rank(None, {}) == 0
assert mastery_rank("skipped", {}) == 1
assert mastery_rank("completed", {}) == 1
assert mastery_rank("completed", {"guitar": 2, "bass": 1}) == 4
assert mastery_rank("pending", {"guitar": 2}) == 2 # paths count even pre-calibration
def test_wallet_balance():
assert wallet_balance(1000, 250) == 750
assert wallet_balance(100, 500) == 0 # source reset below spent: clamp
assert wallet_balance(None, None) == 0
def test_active_challenges_and_max_level():
content = _content()
assert [c["id"] for c in active_challenges(content, "guitar", 0)] == [
"guitar.l1.any",
"guitar.l1.acc",
"guitar.l1.three",
]
assert [c["id"] for c in active_challenges(content, "guitar", 1)] == ["guitar.l2.distinct"]
assert active_challenges(content, "guitar", 2) == [] # max level
assert active_challenges(content, "nope", 0) == []
assert path_max_level(content, "guitar") == 2
assert path_max_level(content, "nope") == 0