Files
feedBack/tests/test_minigames_routes.py
T
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

484 lines
17 KiB
Python

"""Tests for plugins/minigames/routes.py — run submission, leaderboard
ordering/limits, profile get/reset, and registry endpoints.
Follows the same importlib fixture pattern as test_highway_3d_routes.py so
the routes module is loaded fresh per test session without importing the
full server stack.
"""
import importlib
import importlib.util
import json
import sys
import time
from pathlib import Path
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
# ── Fixture ───────────────────────────────────────────────────────────────────
@pytest.fixture()
def setup_routes(tmp_path):
"""Load plugins/minigames/routes.py via importlib, wire it to a fresh
FastAPI app backed by tmp_path, and return (client, routes_module)."""
routes_path = (
Path(__file__).parent.parent / "plugins" / "minigames" / "routes.py"
)
spec = importlib.util.spec_from_file_location(
"minigames_routes_test_module", routes_path
)
routes = importlib.util.module_from_spec(spec)
spec.loader.exec_module(routes)
app = FastAPI()
context = {"config_dir": str(tmp_path)}
routes.setup(app, context)
client = TestClient(app, raise_server_exceptions=True)
try:
yield client, routes
finally:
client.close()
sys.modules.pop("minigames_routes_test_module", None)
# ── Helpers ───────────────────────────────────────────────────────────────────
def _submit(client, game_id="test-game", score=100, duration_ms=5000,
modifiers=None, meta=None):
payload = {
"game_id": game_id,
"score": score,
"duration_ms": duration_ms,
"modifiers": modifiers or {},
"meta": meta or {},
}
return client.post("/api/plugins/minigames/runs", json=payload)
# ── Run submission ────────────────────────────────────────────────────────────
def test_submit_run_returns_ok_and_run_id(setup_routes):
client, _ = setup_routes
r = _submit(client, score=200)
assert r.status_code == 200
body = r.json()
assert body["ok"] is True
assert isinstance(body["run_id"], int)
assert body["run_id"] >= 1
def test_submit_run_awards_xp(setup_routes):
client, routes = setup_routes
r = _submit(client, score=100)
assert r.status_code == 200
body = r.json()
expected_xp = routes.xp_for_run(100)
assert body["xp_gained"] == expected_xp
assert body["profile"]["xp"] == expected_xp
def test_submit_run_zero_score_awards_zero_xp(setup_routes):
client, _ = setup_routes
r = _submit(client, score=0)
assert r.status_code == 200
body = r.json()
assert body["xp_gained"] == 0
assert body["profile"]["xp"] == 0
def test_submit_run_negative_score_rejected(setup_routes):
client, _ = setup_routes
r = client.post(
"/api/plugins/minigames/runs",
json={"game_id": "g", "score": -1, "duration_ms": 0},
)
assert r.status_code == 422
def test_submit_multiple_runs_accumulates_xp(setup_routes):
client, routes = setup_routes
_submit(client, score=100)
_submit(client, score=400)
r = client.get("/api/plugins/minigames/profile")
assert r.status_code == 200
profile = r.json()
expected = routes.xp_for_run(100) + routes.xp_for_run(400)
assert profile["xp"] == expected
def test_submit_run_increments_totals(setup_routes):
client, _ = setup_routes
_submit(client, game_id="alpha", score=50)
_submit(client, game_id="alpha", score=150)
r = client.get("/api/plugins/minigames/profile")
assert r.status_code == 200
totals = r.json()["totals"]
assert totals["runs"] == 2
assert totals["score"] == 200
per = totals["per_game"]["alpha"]
assert per["runs"] == 2
assert per["best_score"] == 150
assert per["total_score"] == 200
def test_submit_run_cross_game_totals_isolated(setup_routes):
client, _ = setup_routes
_submit(client, game_id="game-a", score=10)
_submit(client, game_id="game-b", score=20)
r = client.get("/api/plugins/minigames/profile")
totals = r.json()["totals"]["per_game"]
assert totals["game-a"]["runs"] == 1
assert totals["game-b"]["runs"] == 1
def test_submit_run_oversized_modifiers_rejected(setup_routes):
"""modifiers/meta payloads exceeding _MAX_RUN_JSON_BYTES are rejected 400."""
client, routes = setup_routes
cap = routes._MAX_RUN_JSON_BYTES
# Build a dict whose serialised size just exceeds the cap.
oversized = {"k": "x" * (cap + 1)}
r = client.post(
"/api/plugins/minigames/runs",
json={"game_id": "g", "score": 10, "duration_ms": 0, "modifiers": oversized},
)
assert r.status_code == 400
def test_submit_run_within_size_limit_accepted(setup_routes):
"""Payloads well within the cap are accepted normally."""
client, _ = setup_routes
r = _submit(client, modifiers={"key": "value"}, meta={"info": "ok"})
assert r.status_code == 200
# ── Leaderboard ordering and limits ──────────────────────────────────────────
def test_list_runs_ordered_by_score_desc(setup_routes):
client, _ = setup_routes
for score in (50, 300, 100, 200):
_submit(client, game_id="race", score=score)
r = client.get("/api/plugins/minigames/runs?game_id=race")
assert r.status_code == 200
scores = [row["score"] for row in r.json()["runs"]]
assert scores == sorted(scores, reverse=True)
def test_list_runs_filter_by_game_id(setup_routes):
client, _ = setup_routes
_submit(client, game_id="alpha", score=100)
_submit(client, game_id="beta", score=200)
r = client.get("/api/plugins/minigames/runs?game_id=alpha")
assert r.status_code == 200
runs = r.json()["runs"]
assert all(row["game_id"] == "alpha" for row in runs)
assert len(runs) == 1
def test_list_runs_limit_respected(setup_routes):
client, _ = setup_routes
for i in range(10):
_submit(client, game_id="flood", score=i * 10)
r = client.get("/api/plugins/minigames/runs?game_id=flood&limit=5")
assert r.status_code == 200
assert len(r.json()["runs"]) == 5
def test_list_runs_limit_out_of_range_returns_400(setup_routes):
client, _ = setup_routes
r = client.get("/api/plugins/minigames/runs?limit=0")
assert r.status_code == 400
r = client.get("/api/plugins/minigames/runs?limit=501")
assert r.status_code == 400
def test_list_runs_invalid_scope_returns_400(setup_routes):
client, _ = setup_routes
r = client.get("/api/plugins/minigames/runs?scope=other")
assert r.status_code == 400
def test_list_runs_empty_returns_empty_list(setup_routes):
client, _ = setup_routes
r = client.get("/api/plugins/minigames/runs")
assert r.status_code == 200
assert r.json()["runs"] == []
def test_list_runs_run_fields_present(setup_routes):
client, _ = setup_routes
_submit(client, game_id="g", score=77, duration_ms=1234,
modifiers={"x": 1}, meta={"note": "hi"})
r = client.get("/api/plugins/minigames/runs?game_id=g")
row = r.json()["runs"][0]
assert row["game_id"] == "g"
assert row["score"] == 77
assert row["duration_ms"] == 1234
assert row["modifiers"] == {"x": 1}
assert row["meta"] == {"note": "hi"}
assert "xp_awarded" in row
assert "created_at" in row
# ── Profile ───────────────────────────────────────────────────────────────────
def test_get_profile_initial_state(setup_routes):
client, _ = setup_routes
r = client.get("/api/plugins/minigames/profile")
assert r.status_code == 200
p = r.json()
assert p["xp"] == 0
assert p["level"] == 1
assert p["unlocks"] == []
def test_get_profile_xp_to_next_level_present(setup_routes):
client, routes = setup_routes
r = client.get("/api/plugins/minigames/profile")
assert "xp_to_next_level" in r.json()
def test_get_profile_level_advances_with_xp(setup_routes):
client, routes = setup_routes
# Submit enough score to push xp past level-2 threshold (xp >= 100)
# xp_for_run(score) = floor(sqrt(score) * 10), so score=100 => xp=100 => L2
_submit(client, score=100)
r = client.get("/api/plugins/minigames/profile")
p = r.json()
assert p["level"] >= 2
# ── Reset semantics ───────────────────────────────────────────────────────────
def test_reset_clears_profile_and_runs(setup_routes):
client, _ = setup_routes
_submit(client, game_id="g", score=500)
r_reset = client.post("/api/plugins/minigames/profile/reset")
assert r_reset.status_code == 200
assert r_reset.json()["ok"] is True
# Profile should be zeroed.
r_profile = client.get("/api/plugins/minigames/profile")
p = r_profile.json()
assert p["xp"] == 0
assert p["level"] == 1
assert p["unlocks"] == []
# Run history should be empty.
r_runs = client.get("/api/plugins/minigames/runs")
assert r_runs.json()["runs"] == []
def test_reset_then_resubmit_works(setup_routes):
client, _ = setup_routes
_submit(client, game_id="g", score=100)
client.post("/api/plugins/minigames/profile/reset")
_submit(client, game_id="g", score=50)
# Run history should contain only the post-reset run.
r_runs = client.get("/api/plugins/minigames/runs")
assert len(r_runs.json()["runs"]) == 1
assert r_runs.json()["runs"][0]["score"] == 50
def test_reset_idempotent(setup_routes):
client, _ = setup_routes
r1 = client.post("/api/plugins/minigames/profile/reset")
r2 = client.post("/api/plugins/minigames/profile/reset")
assert r1.status_code == 200
assert r2.status_code == 200
# ── Registry ─────────────────────────────────────────────────────────────────
def test_registry_returns_empty_when_no_minigame_plugins(setup_routes):
client, _ = setup_routes
r = client.get("/api/plugins/minigames/registry")
assert r.status_code == 200
assert r.json()["minigames"] == []
def test_registry_returns_minigame_plugin(tmp_path, monkeypatch):
"""Simulate a plugin directory with a minigame block and verify it shows
up in the registry.
Uses FEEDBACK_PLUGINS_DIR so the resolver scans a controlled directory
rather than the repo's live plugins/ tree.
"""
plugins_dir = tmp_path / "plugins"
plugins_dir.mkdir()
plugin_dir = plugins_dir / "fake-minigame"
plugin_dir.mkdir()
(plugin_dir / "plugin.json").write_text(json.dumps({
"id": "fake-minigame",
"version": "1.0.0",
"minigame": {
"name": "Fake Minigame",
"description": "A test minigame.",
},
}), encoding="utf-8")
monkeypatch.setenv("FEEDBACK_PLUGINS_DIR", str(plugins_dir))
# Load a fresh module instance and call setup() so that _resolve_plugin_dirs
# (defined as a closure inside setup()) picks up the monkeypatched env var.
# os.environ.get() is called at resolver-invocation time, so the reload is
# not strictly necessary for the env var itself — but it ensures module-level
# state (_registry_cache, _state) is clean and that setup() re-defines the
# resolver closure in the new env context.
routes_path = (
Path(__file__).parent.parent / "plugins" / "minigames" / "routes.py"
)
spec = importlib.util.spec_from_file_location(
"minigames_routes_registry_test", routes_path
)
routes = importlib.util.module_from_spec(spec)
spec.loader.exec_module(routes)
config_dir = tmp_path / "config"
config_dir.mkdir()
app = FastAPI()
routes.setup(app, {"config_dir": str(config_dir)})
client = TestClient(app, raise_server_exceptions=True)
try:
r = client.get("/api/plugins/minigames/registry")
assert r.status_code == 200
ids = [g["plugin_id"] for g in r.json()["minigames"]]
assert "fake-minigame" in ids
finally:
client.close()
sys.modules.pop("minigames_routes_registry_test", None)
def test_registry_deduplicates_by_plugin_id(tmp_path, monkeypatch):
"""Two directories advertising the same plugin_id: only one entry in the
registry (first-wins / override-takes-precedence)."""
plugins_dir = tmp_path / "plugins"
plugins_dir.mkdir()
for subdir in ("dir-a", "dir-b"):
d = plugins_dir / subdir
d.mkdir()
(d / "plugin.json").write_text(json.dumps({
"id": "dupe-game",
"version": "1.0.0",
"minigame": {"name": subdir},
}), encoding="utf-8")
monkeypatch.setenv("FEEDBACK_PLUGINS_DIR", str(plugins_dir))
routes_path = (
Path(__file__).parent.parent / "plugins" / "minigames" / "routes.py"
)
spec = importlib.util.spec_from_file_location(
"minigames_routes_dedup_test", routes_path
)
routes = importlib.util.module_from_spec(spec)
spec.loader.exec_module(routes)
config_dir = tmp_path / "config"
config_dir.mkdir()
app = FastAPI()
routes.setup(app, {"config_dir": str(config_dir)})
client = TestClient(app, raise_server_exceptions=True)
try:
r = client.get("/api/plugins/minigames/registry")
assert r.status_code == 200
games = r.json()["minigames"]
ids = [g["plugin_id"] for g in games]
assert ids.count("dupe-game") == 1
# First-wins: resolver sorts children alphabetically so "dir-a" < "dir-b";
# the surviving entry should carry the "dir-a" name.
survivor = next(g for g in games if g["plugin_id"] == "dupe-game")
assert survivor["name"] == "dir-a"
finally:
client.close()
sys.modules.pop("minigames_routes_dedup_test", None)
# ── WAL mode ─────────────────────────────────────────────────────────────────
def test_db_uses_wal_journal_mode(setup_routes):
"""Verify that the SQLite connection is opened in WAL mode."""
_, routes = setup_routes
conn = routes._get_conn()
try:
row = conn.execute("PRAGMA journal_mode").fetchone()
assert row[0] == "wal"
finally:
conn.close()
# ── Progression hook (spec 010) ───────────────────────────────────────────────
def test_submit_run_reports_progression_event(tmp_path):
"""When core provides record_progression_event, a submitted run reports a
minigame_run event and the summary rides along in the response."""
routes_path = (
Path(__file__).parent.parent / "plugins" / "minigames" / "routes.py"
)
spec = importlib.util.spec_from_file_location(
"minigames_routes_progression_test", routes_path
)
routes = importlib.util.module_from_spec(spec)
spec.loader.exec_module(routes)
events = []
def _record(event_type, payload=None):
events.append((event_type, payload))
return {"challenges_completed": [], "quests_completed": [],
"level_ups": [], "calibration_completed": False, "mastery_rank": 0}
app = FastAPI()
routes.setup(app, {"config_dir": str(tmp_path),
"record_progression_event": _record})
client = TestClient(app, raise_server_exceptions=True)
try:
r = _submit(client, game_id="chord-sprint", score=250)
assert r.status_code == 200
assert events == [("minigame_run", {"game_id": "chord-sprint", "score": 250})]
assert r.json()["progression"]["mastery_rank"] == 0
finally:
client.close()
sys.modules.pop("minigames_routes_progression_test", None)
def test_submit_run_progression_failure_does_not_break_submission(tmp_path):
"""A raising progression hook must not fail the run submission."""
routes_path = (
Path(__file__).parent.parent / "plugins" / "minigames" / "routes.py"
)
spec = importlib.util.spec_from_file_location(
"minigames_routes_progression_fail_test", routes_path
)
routes = importlib.util.module_from_spec(spec)
spec.loader.exec_module(routes)
def _boom(event_type, payload=None):
raise RuntimeError("engine exploded")
app = FastAPI()
routes.setup(app, {"config_dir": str(tmp_path),
"record_progression_event": _boom})
client = TestClient(app, raise_server_exceptions=True)
try:
r = _submit(client, score=250)
assert r.status_code == 200
assert r.json()["ok"] is True
assert r.json()["progression"] is None
finally:
client.close()
sys.modules.pop("minigames_routes_progression_fail_test", None)
def test_submit_run_without_progression_hook_is_silent(setup_routes):
"""Standalone mode (no core hook): response carries progression: null."""
client, _ = setup_routes
r = _submit(client, score=100)
assert r.status_code == 200
assert r.json()["progression"] is None