feedBack/tests/test_progression_api.py
Bret Mogilefsky af2949677a
rename: slopsmith → feedBack, byron → got-feedBack (#537)
* Update GitHub repo references from feedback* to feedBack*

* rename: slopsmith -> feedBack, byron -> got-feedBack

Renames across the entire codebase:
- slopsmith/Slopsmith/SLOPSMITH/SlopSmith -> feedBack/FeedBack/FEEDBACK/FeedBack
- byron/Byron/Byrongamatos -> got-feedBack/got-feedBack/got-feedBack
- /home/byron/ -> /opt/got-feedBack/
- byron@ougsoft.com -> hi@got-feedBack.org
- github.com/byrongamatos/ -> github.com/got-feedback/
- com.byron. -> com.got-feedback.
- SLOPSMITH_ env vars -> FEEDBACK_ with backward-compat fallback
- Protocol/storage strings migrated with read-old/write-new pattern
- window.slopsmith JS API -> window.feedBack (canonical) + backward-compat alias

Refs: #rename-slopsmith

* rename: complete regen against current main + fix backward-compat alias

Regenerated the slopsmith->feedBack / byron->got-feedBack rename on top of
current main (3 commits had landed since the branch: #572/#554/#574),
resolving the four content conflicts in favour of main's newer content
(autoplay/auto-exit, accuracy-badge, Virtuoso re-home, feedpak badge).

Completion fixes on top of the mechanical rename:
- Re-apply rename to post-branch content the original rename never saw:
  window.slopsmith(.Tour) consumers in lessons.js / notifications.js /
  onboarding-tour.js, and the matching JS + python tests (autoplay_exit,
  progression_*, test_feedpak_extension FEEDBACK_* env vars). The test env
  vars now match server.py (which reads FEEDBACK_SYNC_STARTUP /
  FEEDBACK_SKIP_STARTUP_TASKS), so the sync-startup test exercises the real
  path again.
- Restore the window.slopsmith backward-compat alias dropped during conflict
  resolution, and move the bus aliases to AFTER the _feedBackExisting merge
  block so they reference the fully-assembled object (also fixes the
  loop_api.test.js API-surface regex, which the original PR latently broke).
- Drop the stray empty data/web_library.db (runtime DB lives in CONFIG_DIR)
  and gitignore it.
- Fix stale tone-source test: feed[dB]ack -> fee[dB]ack to match shipped
  source labels.

Verified locally (org CI billing-blocked): JS 819/819 pass; pytest 1669
passed / 1683 collected with 0 import errors; zero residual slopsmith/byron
except the two intentional window.slopsmith aliases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* rename: implement advertised backward-compat + prune dead community plugins

Address gaps where PR #537's "Backward compatibility" section was advertised
but not implemented, and clean up the community plugin list.

Env vars (FEEDBACK_* canonical, legacy SLOPSMITH_* honoured):
- New lib/env_compat.py (getenv_compat / env_flag_compat) + tests. server.py
  (_env_flag + all FEEDBACK_* reads), diagnostics_hardware, gp2midi and
  tailwind_rebuild now resolve the legacy alias, so existing SLOPSMITH_UI /
  SLOPSMITH_PLUGINS_DIR / etc. deployments keep working.
- Fix the rename collapsing plugins/__init__.py and minigames/routes.py from
  `FEEDBACK_PLUGINS_DIR or SLOPSMITH_PLUGINS_DIR` into a redundant
  `FEEDBACK_ or FEEDBACK_` (the fallback was silently lost).

Storage (app.js update-channel):
- Read feedBack-update-channel, fall back to legacy slopsmith-update-channel,
  and clear the legacy key on write — so a user's update-channel preference
  survives the rename instead of resetting to "stable".

Community plugin list (README): the rename rewrote third-party repo URLs we
don't own. Probed every one; their owners never renamed, so:
- Restore the 13 live community plugins to their real slopsmith-* names.
- Prune 6 that are 404 to the public (topkoa splitscreen/stems, OmikronApex
  tuner, Jafz2001 nam-rig-builder, DeathlySin song-preview, Erikcb91 shuffle).
- Fix a pre-existing Guitar Theory clone-command typo (nam-tone -> guitar-theory).

Verified: env_compat 7/7, JS 819/819, pytest 1690 collected / 0 import errors,
rename-sensitive + startup suites green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: byrongamatos <xasiklas@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 11:03:01 +02:00

375 lines
17 KiB
Python

"""Tests for the progression + shop endpoints (spec 010) and the
/api/stats → progression event wiring."""
import importlib
import json
import sys
import pytest
from fastapi.testclient import TestClient
def _write_fixture_content(root):
"""Small deterministic content bundle (single-quest pools so rotation is
fixed regardless of date)."""
(root / "paths").mkdir(parents=True)
(root / "paths" / "guitar.json").write_text(json.dumps({
"id": "guitar", "name": "Guitar", "icon": "guitar", "order": 1,
"levels": [
{"level": 1, "required": 1, "challenges": [
{"id": "guitar.l1.c1", "title": "Clean Run",
"description": "80%+ accuracy on a guitar song.",
"goal": {"type": "song_completed", "instrument": "guitar",
"min_accuracy": 0.8, "target": 1}},
]},
{"level": 2, "required": 1, "challenges": [
{"id": "guitar.l2.c1", "title": "Daily Pair",
"description": "Complete 2 daily quests.",
"goal": {"type": "quest_completed", "period": "daily", "target": 2}},
]},
],
}))
(root / "paths" / "bass.json").write_text(json.dumps({
"id": "bass", "name": "Bass", "icon": "bass", "order": 2,
"levels": [
{"level": 1, "required": 1, "challenges": [
{"id": "bass.l1.c1", "title": "First Groove",
"description": "Finish a bass song.",
"goal": {"type": "song_completed", "instrument": "bass", "target": 1}},
]},
],
}))
(root / "quests.json").write_text(json.dumps({
"daily": {"count": 1, "pool": [
{"id": "d.one", "title": "Quick Set", "description": "Finish a song.",
"reward_db": 50, "goal": {"type": "song_completed", "target": 1}},
]},
"weekly": {"count": 1, "pool": [
{"id": "w.mini", "title": "Arcade Pair", "description": "2 rounds.",
"reward_db": 100, "goal": {"type": "minigame_run", "target": 2}},
]},
}))
(root / "shop.json").write_text(json.dumps({
"items": [
{"id": "theme.test", "slot": "theme", "name": "Test Theme",
"description": "", "cost": 100, "payload": {"colors": {"bg": "#000000"}}},
{"id": "frame.test", "slot": "avatar_frame", "name": "Test Frame",
"description": "", "cost": 50, "payload": {"frame_style": "box-shadow: 0 0 0 1px red"}},
],
}))
@pytest.fixture()
def server(tmp_path, monkeypatch, isolate_logging):
content_dir = tmp_path / "progression-content"
_write_fixture_content(content_dir)
monkeypatch.setenv("CONFIG_DIR", str(tmp_path / "config"))
monkeypatch.setenv("FEEDBACK_PROGRESSION_DATA", str(content_dir))
monkeypatch.setenv("FEEDBACK_SKIP_STARTUP_TASKS", "1")
sys.modules.pop("server", None)
srv = importlib.import_module("server")
try:
yield srv
finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None:
conn.close()
sys.modules.pop("server", None)
@pytest.fixture()
def client(server):
return TestClient(server.app)
def _scored_play(client, filename="song.archive", accuracy=0.9, score=900, arrangement=0):
return client.post("/api/stats", json={
"filename": filename, "arrangement": arrangement,
"score": score, "accuracy": accuracy,
})
# ── Overview / onboarding ─────────────────────────────────────────────────────
def test_fresh_overview(client):
r = client.get("/api/progression")
assert r.status_code == 200
data = r.json()
assert data["mastery_rank"] == 0
assert data["onboarding"]["calibration_status"] == "pending"
assert data["onboarding"]["diagnostic_filename"].startswith("diagnostics-builtin/")
assert data["paths"] == []
assert [p["id"] for p in data["available_paths"]] == ["guitar", "bass"]
# Current quest periods are lazily instantiated on read.
assert [q["id"] for q in data["quests"]["daily"]["quests"]] == ["d.one"]
assert [q["id"] for q in data["quests"]["weekly"]["quests"]] == ["w.mini"]
assert data["quests"]["daily"]["resets_at"] > data["quests"]["daily"]["period_key"]
assert data["wallet"] == {"balance": 0, "lifetime_db": 0, "spent": 0}
def test_add_paths_validates_and_is_idempotent(client):
assert client.post("/api/progression/paths", json={"add": []}).status_code == 400
assert client.post("/api/progression/paths", json={"add": "guitar"}).status_code == 400
assert client.post("/api/progression/paths", json={"add": ["keytar"]}).status_code == 400
r = client.post("/api/progression/paths", json={"add": ["guitar"]})
assert r.status_code == 200
paths = r.json()["paths"]
assert [p["id"] for p in paths] == ["guitar"]
assert paths[0]["level"] == 0
assert paths[0]["max_level"] == 2
assert paths[0]["next"]["level"] == 1
assert [c["id"] for c in paths[0]["next"]["challenges"]] == ["guitar.l1.c1"]
assert [p["id"] for p in r.json()["available_paths"]] == ["bass"]
# Re-adding never resets the level.
r2 = client.post("/api/progression/paths", json={"add": ["guitar"]})
assert [p["id"] for p in r2.json()["paths"]] == ["guitar"]
def test_skip_calibration_reaches_rank_one(client):
# Spec invariant: skipping requires at least one selected path first.
assert client.post("/api/progression/onboarding", json={"action": "skip"}).status_code == 400
client.post("/api/progression/paths", json={"add": ["guitar"]})
r = client.post("/api/progression/onboarding", json={"action": "skip"})
assert r.status_code == 200
assert r.json()["mastery_rank"] == 1
assert r.json()["onboarding"]["calibration_status"] == "skipped"
# Idempotent; bad action rejected.
assert client.post("/api/progression/onboarding", json={"action": "skip"}).json()[
"onboarding"]["calibration_status"] == "skipped"
assert client.post("/api/progression/onboarding", json={"action": "reset"}).status_code == 400
def test_skip_allowed_when_content_defines_no_paths(client, server, monkeypatch):
# Robustness carve-out: broken/empty content must never brick onboarding.
content = {**server._get_progression_content(), "paths": {}}
monkeypatch.setattr(server, "_progression_content", content)
assert client.post("/api/progression/onboarding", json={"action": "skip"}).status_code == 200
# ── Scored-play wiring ────────────────────────────────────────────────────────
def test_scored_play_advances_challenge_quest_and_levels(client, server):
client.post("/api/progression/paths", json={"add": ["guitar"]})
client.post("/api/progression/onboarding", json={"action": "skip"})
r = _scored_play(client, accuracy=0.9, score=900)
assert r.status_code == 200
summary = r.json()["progression"]
assert summary is not None
assert [c["id"] for c in summary["challenges_completed"]] == ["guitar.l1.c1"]
assert summary["level_ups"] == [{"path_id": "guitar", "new_level": 1}]
assert [q["id"] for q in summary["quests_completed"]] == ["d.one"]
assert summary["mastery_rank"] == 2 # skip (1) + guitar level 1
overview = client.get("/api/progression").json()
assert overview["mastery_rank"] == 2
guitar = overview["paths"][0]
assert guitar["level"] == 1
# Now working the level-2 set; the d.one completion already counted 1/2
# via the quest_completed re-entry.
assert guitar["next"]["level"] == 2
assert guitar["next"]["challenges"][0]["count"] == 1
# Quest reward landed in the unified store under source "quests".
from xp import xp_for_run
assert overview["wallet"]["lifetime_db"] == xp_for_run(900) + 50
row = server.meta_db.conn.execute(
"SELECT xp FROM xp_sources WHERE source = 'quests'").fetchone()
assert row[0] == 50
def test_low_accuracy_play_does_not_complete_gated_challenge(client):
client.post("/api/progression/paths", json={"add": ["guitar"]})
r = _scored_play(client, accuracy=0.5, score=100)
summary = r.json()["progression"]
assert summary["challenges_completed"] == []
assert summary["level_ups"] == []
# The unfiltered daily quest still advances (and completes at target 1).
assert [q["id"] for q in summary["quests_completed"]] == ["d.one"]
def test_diagnostic_at_100_completes_calibration(client, server):
diag = server._builtin_diagnostic_filename()
# A near-miss leaves calibration pending.
_scored_play(client, filename=diag, accuracy=0.97, score=500)
assert client.get("/api/progression").json()["onboarding"]["calibration_status"] == "pending"
_scored_play(client, filename=diag, accuracy=1.0, score=500)
data = client.get("/api/progression").json()
assert data["onboarding"]["calibration_status"] == "completed"
assert data["mastery_rank"] == 1
def test_diagnostic_play_does_not_feed_challenges_or_quests(client, server):
# The calibration run is a perfect guitar play — it must yield rank 1
# EXACTLY, advancing neither the guitar path nor the daily song quest.
client.post("/api/progression/paths", json={"add": ["guitar"]})
r = _scored_play(client, filename=server._builtin_diagnostic_filename(),
accuracy=1.0, score=500)
summary = r.json()["progression"]
assert summary["calibration_completed"] is True
assert summary["challenges_completed"] == []
assert summary["level_ups"] == []
assert summary["quests_completed"] == []
assert summary["mastery_rank"] == 1
data = client.get("/api/progression").json()
assert data["mastery_rank"] == 1
assert data["paths"][0]["level"] == 0
assert all(q["count"] == 0 for q in data["quests"]["daily"]["quests"])
def test_pathless_diagnostic_run_still_completes_calibration(client, server):
"""INTENDED (spec FR-007b): the ≥1-path invariant binds the onboarding
wizard and the explicit skip action, NOT the stats plane. A 100% diagnostic
run is an earned achievement and must count even before any path is
selected (e.g. a pre-progression profile playing the diagnostic as a
hardware test) — yielding a valid pathless rank-1 state."""
_scored_play(client, filename=server._builtin_diagnostic_filename(),
accuracy=1.0, score=500)
data = client.get("/api/progression").json()
assert data["onboarding"]["calibration_status"] == "completed"
assert data["mastery_rank"] == 1
assert data["paths"] == []
def test_diagnostic_upgrades_skipped_without_rank_change(client, server):
client.post("/api/progression/paths", json={"add": ["guitar"]})
r = client.post("/api/progression/onboarding", json={"action": "skip"})
assert r.json()["onboarding"]["calibration_status"] == "skipped"
_scored_play(client, filename=server._builtin_diagnostic_filename(), accuracy=1.0, score=500)
data = client.get("/api/progression").json()
assert data["onboarding"]["calibration_status"] == "completed"
assert data["mastery_rank"] == 1
def test_progression_failure_never_drops_stat_write(client, server, monkeypatch):
def _boom(*args, **kwargs):
raise RuntimeError("engine exploded")
monkeypatch.setattr(server.meta_db, "record_progression_event", _boom)
r = _scored_play(client, accuracy=0.9, score=400)
assert r.status_code == 200
assert r.json()["stats"]["plays"] == 1
assert r.json()["progression"] is None
assert r.json()["progress"] is not None # XP/streak side-effects intact
# ── Generic event intake ──────────────────────────────────────────────────────
def test_events_endpoint_whitelist(client):
# song_completed is server-derived: rejected from external intake.
r = client.post("/api/progression/events",
json={"type": "song_completed", "payload": {"accuracy": 1.0}})
assert r.status_code == 400
assert client.post("/api/progression/events", json={"type": "nope"}).status_code == 400
def test_events_endpoint_validates_payload(client):
bad_big = {f"k{i}": i for i in range(17)}
assert client.post("/api/progression/events",
json={"type": "minigame_run", "payload": bad_big}).status_code == 400
assert client.post("/api/progression/events",
json={"type": "minigame_run", "payload": {"meta": {"nested": 1}}}).status_code == 400
assert client.post("/api/progression/events",
json={"type": "minigame_run", "payload": {"x": "y" * 257}}).status_code == 400
def test_stale_quest_completion_does_not_double_award(client, server, monkeypatch):
"""A quest completion computed from a stale snapshot (concurrent event won
the guarded UPDATE) must not pay its reward or re-enter again."""
_scored_play(client) # completes daily d.one (+50 dB)
xp_before = server.meta_db.get_xp()
real_snapshot = server.meta_db.progression_snapshot
def stale_snapshot(content, now):
snap = real_snapshot(content, now)
for quest in snap["quests"]:
if quest["quest_id"] == "d.one":
quest["completed"] = False
quest["count"] = 0
return snap
monkeypatch.setattr(server.meta_db, "progression_snapshot", stale_snapshot)
summary = server.meta_db.record_progression_event(
"song_completed",
{"filename": "y.archive", "instrument": "guitar", "accuracy": 0.5, "score": 100},
server._get_progression_content(),
)
assert all(q["id"] != "d.one" for q in summary["quests_completed"])
assert server.meta_db.get_xp() == xp_before # no double reward
def test_minigame_events_advance_weekly_quest(client):
r1 = client.post("/api/progression/events",
json={"type": "minigame_run", "payload": {"game_id": "g", "score": 10}})
assert r1.status_code == 200
assert r1.json()["progression"]["quests_completed"] == []
r2 = client.post("/api/progression/events",
json={"type": "minigame_run", "payload": {"game_id": "g", "score": 10}})
completed = r2.json()["progression"]["quests_completed"]
assert [q["id"] for q in completed] == ["w.mini"]
assert completed[0]["reward_db"] == 100
assert client.get("/api/progression").json()["wallet"]["lifetime_db"] == 100
# ── Shop ──────────────────────────────────────────────────────────────────────
def test_shop_catalog_and_purchase_flow(client, server):
catalog = client.get("/api/shop").json()
assert {i["id"] for i in catalog["items"]} == {"theme.test", "frame.test"}
assert all(not i["owned"] and not i["equipped"] for i in catalog["items"])
# Insufficient balance: rejected, nothing mutates.
r = client.post("/api/shop/buy", json={"item_id": "theme.test"})
assert r.status_code == 402
assert r.json()["wallet"]["spent"] == 0
server.meta_db.award_xp(500)
r = client.post("/api/shop/buy", json={"item_id": "theme.test"})
assert r.status_code == 200
assert r.json()["wallet"] == {"balance": 400, "lifetime_db": 500, "spent": 100}
# Double-buy → 409; unknown item → 400.
assert client.post("/api/shop/buy", json={"item_id": "theme.test"}).status_code == 409
assert client.post("/api/shop/buy", json={"item_id": "theme.nope"}).status_code == 400
# Spending never touches lifetime XP.
assert server.meta_db.get_xp() == 500
def test_shop_equip_requires_ownership(client, server):
assert client.post("/api/shop/equip",
json={"slot": "theme", "item_id": "theme.test"}).status_code == 403
assert client.post("/api/shop/equip",
json={"slot": "hat", "item_id": "theme.test"}).status_code == 400
# Slot/item mismatch is a 400 even when the item exists.
assert client.post("/api/shop/equip",
json={"slot": "avatar_frame", "item_id": "theme.test"}).status_code == 400
server.meta_db.award_xp(500)
client.post("/api/shop/buy", json={"item_id": "theme.test"})
r = client.post("/api/shop/equip", json={"slot": "theme", "item_id": "theme.test"})
assert r.status_code == 200
assert r.json()["equipped"] == {"theme": "theme.test"}
# Equipped cosmetics ride along on /api/profile (resolved payloads).
profile = client.get("/api/profile").json()
assert profile["cosmetics"]["theme"]["item_id"] == "theme.test"
assert profile["cosmetics"]["theme"]["payload"] == {"colors": {"bg": "#000000"}}
# Unequip restores the default.
r = client.post("/api/shop/equip", json={"slot": "theme", "item_id": None})
assert r.json()["equipped"] == {}
assert client.get("/api/profile").json()["cosmetics"] == {}
def test_wallet_balance_clamps_after_source_reset(client, server):
server.meta_db.award_xp(200, "minigames")
client.post("/api/shop/buy", json={"item_id": "frame.test"}) # spend 50
server.meta_db.reset_source_xp("minigames") # lifetime → 0
wallet = client.get("/api/shop").json()["wallet"]
assert wallet == {"balance": 0, "lifetime_db": 0, "spent": 50}