mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-10 18:59:56 +00:00
feat(career): career plugin — stars, venue tiers, pack downloads (career mode 2/3) (#907)
* feat(career): career plugin — stars from song_stats, venue tiers, pack downloads (career mode PR2) Bundled plugin: per-song stars from best_accuracy (60/75/85% → 1/2/3★), cumulative stars unlock bar → club → arena (data-driven venues.json). Venue packs (UE-rendered crowd loops) download on demand to CONFIG_DIR/plugin_uploads/career/ on a background thread with sha256 + zip-slip validation, served via FileResponse. Career screen (promoted sidebar entry) shows progress and pushes the active venue's manifest into the crowd video layer (v3VenueCrowd, PR1) — degrades cleanly when either side is absent. Pack URLs land in venues.json in PR3. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(career): keep manifest cleanup path alive on delete; badge only for installed venues Codex preflight: nulling _appliedManifestVenue on delete skipped pushCrowdManifest's setManifest(null) cleanup, leaving the crowd layer on a deleted pack; and the 'playing here' badge showed for an override venue whose pack was removed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(career): generation-guard in-flight manifest fetches Codex preflight: a manifest fetch resolving after a newer refresh (pack deleted, venue switched) could re-apply a stale pack over the user's newer selection — fetches now carry a generation token and bail when superseded. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(career): exclude orphaned song_stats from star totals Codex preflight: scans hide rather than delete stats of removed songs, so stars now apply the same existing-song filter other stats surfaces use (filename IN (SELECT filename FROM songs)). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(career): 50/150 star thresholds + star collection overview Byron's progression tuning: club at 50★, arena at 150★. /state now returns star_detail rows (title/artist joined from the library, stars, best accuracy, next-star threshold) sorted closest-to-next-star first, and the career screen renders a collection panel: tier summary plus a per-song list with a 'N% to next star' practice hint. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(career): venue select/unselect UX, intro manifest support, fullmatch guards - 'Play here' now also defaults the visualization to Venue (remembering the prior viz); active venues show 'Leave venue' which restores it and sets the '__none__' override so no installed venue silently reapplies. - Pack manifests may ship an intro block (flyover video + ambience mp3); files validate like loops/stingers, .mp3 added to the serving whitelist. - Codex preflight: whitelist regexes use fullmatch (trailing-newline names could validate but 500 on serving). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(career): let pushCrowdManifest clear the manifest on Leave venue Codex preflight: nulling _appliedManifestVenue before refresh skipped the setManifest(null) cleanup branch, leaving the crowd playing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(career): refresh tailwind output --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
e779c72396
commit
ea0ca94742
@@ -0,0 +1,50 @@
|
||||
'use strict';
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const ROOT = path.join(__dirname, '..', '..');
|
||||
const PLUGIN_DIR = path.join(ROOT, 'plugins', 'career');
|
||||
const SHELL_JS = path.join(ROOT, 'static', 'v3', 'shell.js');
|
||||
|
||||
test('career plugin manifest is complete and bundled', () => {
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(PLUGIN_DIR, 'plugin.json'), 'utf8'));
|
||||
assert.equal(manifest.id, 'career');
|
||||
assert.equal(manifest.bundled, true);
|
||||
assert.equal(manifest.screen, 'screen.html');
|
||||
assert.equal(manifest.script, 'screen.js');
|
||||
assert.equal(manifest.routes, 'routes.py');
|
||||
for (const f of ['screen.html', 'screen.js', 'routes.py', 'venues.json', manifest.styles]) {
|
||||
assert.ok(fs.existsSync(path.join(PLUGIN_DIR, f)), `${f} missing`);
|
||||
}
|
||||
});
|
||||
|
||||
test('venues.json defines the 3 ascending tiers with star thresholds', () => {
|
||||
const content = JSON.parse(fs.readFileSync(path.join(PLUGIN_DIR, 'venues.json'), 'utf8'));
|
||||
assert.deepEqual(content.star_accuracy_thresholds, [0.6, 0.75, 0.85]);
|
||||
const venues = content.venues;
|
||||
assert.deepEqual(venues.map((v) => v.id), ['bar', 'club', 'arena']);
|
||||
assert.equal(venues[0].star_threshold, 0, 'bar must always be unlocked');
|
||||
for (let i = 1; i < venues.length; i++) {
|
||||
assert.ok(venues[i].star_threshold > venues[i - 1].star_threshold,
|
||||
'thresholds must ascend');
|
||||
}
|
||||
});
|
||||
|
||||
test('shell promotes the career plugin into the sidebar', () => {
|
||||
const src = fs.readFileSync(SHELL_JS, 'utf8');
|
||||
assert.match(src, /key: 'career',\s*screen: 'plugin-career'/);
|
||||
assert.match(src, /navKey: 'career',\s*pluginId: 'career',\s*slotId: 'v3-nav-career'/);
|
||||
});
|
||||
|
||||
test('career screen pushes the crowd manifest with a base URL', () => {
|
||||
const src = fs.readFileSync(path.join(PLUGIN_DIR, 'screen.js'), 'utf8');
|
||||
assert.match(src, /v3VenueCrowd/);
|
||||
assert.match(src, /setManifest\(manifest\)/);
|
||||
assert.match(src, /manifest\.base = /);
|
||||
assert.match(src, /feedBack-career-venue/);
|
||||
// Degrades without the crowd layer (PR1 not merged / older desktop).
|
||||
assert.match(src, /typeof crowd\.setManifest !== 'function'\) return/);
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import sqlite3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / 'plugins' / 'career'))
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
# Drop a sibling 'routes' cached by another plugin's tests (bare-name collision).
|
||||
sys.modules.pop('routes', None)
|
||||
import routes as career_routes
|
||||
|
||||
|
||||
class FakeMetaDb:
|
||||
"""song_stats-only stand-in for MetadataDB (the plugin reads nothing else)."""
|
||||
|
||||
def __init__(self):
|
||||
self.conn = sqlite3.connect(":memory:", check_same_thread=False)
|
||||
self.conn.execute(
|
||||
"""CREATE TABLE song_stats (
|
||||
filename TEXT, arrangement TEXT, best_accuracy REAL
|
||||
)"""
|
||||
)
|
||||
self.conn.execute("CREATE TABLE songs (filename TEXT, title TEXT, artist TEXT)")
|
||||
|
||||
def add(self, filename, arrangement, best_accuracy, in_library=True):
|
||||
self.conn.execute("INSERT INTO song_stats VALUES (?, ?, ?)",
|
||||
(filename, arrangement, best_accuracy))
|
||||
if in_library:
|
||||
self.conn.execute(
|
||||
"INSERT INTO songs SELECT ?, ?, ? WHERE NOT EXISTS "
|
||||
"(SELECT 1 FROM songs WHERE filename = ?)",
|
||||
(filename, filename.replace(".feedpak", "").title(), "Test Artist", filename))
|
||||
self.conn.commit()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _bind_career_routes():
|
||||
"""Keep sys.modules['routes'] pointing at THIS plugin's routes for these tests."""
|
||||
prev = sys.modules.get('routes')
|
||||
sys.modules['routes'] = career_routes
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
if prev is not None:
|
||||
sys.modules['routes'] = prev
|
||||
else:
|
||||
sys.modules.pop('routes', None)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_state():
|
||||
# Module state outlives tests when the module stays imported — reset the
|
||||
# mutable bits so ordering can't leak downloads/content between tests.
|
||||
career_routes._state["downloads"] = {}
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def meta_db():
|
||||
return FakeMetaDb()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(tmp_path, meta_db):
|
||||
app = FastAPI()
|
||||
career_routes.setup(app, {"config_dir": str(tmp_path), "meta_db": meta_db})
|
||||
return TestClient(app)
|
||||
@@ -0,0 +1,152 @@
|
||||
"""HTTP-level tests for the career plugin: stars, unlocks, packs."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import zipfile
|
||||
|
||||
import routes as career_routes
|
||||
|
||||
|
||||
def _install_fake_pack(venue_id, files=None):
|
||||
"""Drop a valid installed pack into the plugin's venues dir."""
|
||||
pack_dir = career_routes._venue_dir(venue_id)
|
||||
pack_dir.mkdir(parents=True, exist_ok=True)
|
||||
loops = {s: f"{s}.mp4" for s in career_routes.REQUIRED_LOOPS}
|
||||
(pack_dir / "manifest.json").write_text(json.dumps(
|
||||
{"venue": venue_id, "version": 1, "loops": loops,
|
||||
"stingers": {"clap": "clap.mp4", "cheer": "cheer.mp4"}}))
|
||||
for name in list(loops.values()) + ["clap.mp4", "cheer.mp4"]:
|
||||
(pack_dir / name).write_bytes((files or {}).get(name, b"\x00video"))
|
||||
|
||||
|
||||
def test_stars_from_best_accuracy_across_arrangements(client, meta_db):
|
||||
# Thresholds 0.6/0.75/0.85 → 1/2/3 stars; best arrangement wins.
|
||||
meta_db.add("a.feedpak", "guitar", 0.5) # 0 stars
|
||||
meta_db.add("b.feedpak", "guitar", 0.62) # 1 star
|
||||
meta_db.add("c.feedpak", "guitar", 0.70)
|
||||
meta_db.add("c.feedpak", "bass", 0.80) # 2 stars (max across arrangements)
|
||||
meta_db.add("d.feedpak", "guitar", 0.99) # 3 stars
|
||||
state = client.get("/api/plugins/career/state").json()
|
||||
assert state["stars_total"] == 6
|
||||
assert state["stars_per_song"] == {"b.feedpak": 1, "c.feedpak": 2, "d.feedpak": 3}
|
||||
|
||||
|
||||
def test_unlock_flags_follow_thresholds(client, meta_db):
|
||||
# 6 stars: bar (0) unlocked, club (50) and arena (150) locked.
|
||||
for i in range(2):
|
||||
meta_db.add(f"s{i}.feedpak", "guitar", 0.9) # 3 stars each
|
||||
state = client.get("/api/plugins/career/state").json()
|
||||
by_id = {v["id"]: v for v in state["venues"]}
|
||||
assert by_id["bar"]["unlocked"] is True
|
||||
assert by_id["club"]["unlocked"] is False
|
||||
assert by_id["arena"]["unlocked"] is False
|
||||
|
||||
|
||||
def test_orphaned_stats_do_not_count(client, meta_db):
|
||||
# A song removed from the library (stats row survives the scan) must not
|
||||
# keep contributing stars.
|
||||
meta_db.add("gone.feedpak", "guitar", 0.99, in_library=False)
|
||||
meta_db.add("here.feedpak", "guitar", 0.99)
|
||||
state = client.get("/api/plugins/career/state").json()
|
||||
assert state["stars_total"] == 3
|
||||
assert "gone.feedpak" not in state["stars_per_song"]
|
||||
|
||||
|
||||
def test_star_detail_rows_sorted_by_next_star_gap(client, meta_db):
|
||||
meta_db.add("far.feedpak", "guitar", 0.61) # 1★, 14% from next
|
||||
meta_db.add("close.feedpak", "guitar", 0.84) # 2★, 1% from next
|
||||
meta_db.add("maxed.feedpak", "guitar", 0.99) # 3★, maxed
|
||||
detail = client.get("/api/plugins/career/state").json()["star_detail"]
|
||||
assert [r["filename"] for r in detail] == \
|
||||
["close.feedpak", "far.feedpak", "maxed.feedpak"]
|
||||
close = detail[0]
|
||||
assert close["stars"] == 2 and close["next_star_at"] == 0.85
|
||||
assert detail[2]["next_star_at"] is None
|
||||
|
||||
|
||||
def test_no_stats_still_serves_state(client):
|
||||
state = client.get("/api/plugins/career/state").json()
|
||||
assert state["stars_total"] == 0
|
||||
assert state["venues"][0]["unlocked"] is True # bar is always open
|
||||
|
||||
|
||||
def test_download_unknown_venue_404s(client):
|
||||
assert client.post("/api/plugins/career/packs/nope/download").status_code == 404
|
||||
assert client.post("/api/plugins/career/packs/../etc/download").status_code == 404
|
||||
|
||||
|
||||
def test_download_without_published_pack_404s(client):
|
||||
# venues.json ships pack: null until packs are released.
|
||||
assert client.post("/api/plugins/career/packs/bar/download").status_code == 404
|
||||
|
||||
|
||||
def test_download_locked_venue_403s(client, monkeypatch):
|
||||
club = career_routes._venue("club")
|
||||
monkeypatch.setitem(club, "pack", {"url": "http://x/pack.zip", "sha256": "0" * 64})
|
||||
assert client.post("/api/plugins/career/packs/club/download").status_code == 403
|
||||
|
||||
|
||||
def test_pack_file_serving_and_traversal_guard(client):
|
||||
_install_fake_pack("bar")
|
||||
ok = client.get("/api/plugins/career/venues/bar/manifest.json")
|
||||
assert ok.status_code == 200
|
||||
assert ok.json()["loops"]["ecstatic"] == "ecstatic.mp4"
|
||||
video = client.get("/api/plugins/career/venues/bar/bored.mp4")
|
||||
assert video.status_code == 200
|
||||
assert video.headers["content-type"].startswith("video/mp4")
|
||||
assert video.headers["x-content-type-options"] == "nosniff"
|
||||
# Traversal / junk shapes never resolve.
|
||||
for bad in ("../manifest.json", "..%2Fmanifest.json", "x.sh", "MANIFEST.JSON"):
|
||||
assert client.get(f"/api/plugins/career/venues/bar/{bad}").status_code == 404
|
||||
assert client.get("/api/plugins/career/venues/../bar/manifest.json").status_code == 404
|
||||
|
||||
|
||||
def test_state_reports_installed_and_delete_removes(client):
|
||||
_install_fake_pack("bar")
|
||||
state = client.get("/api/plugins/career/state").json()
|
||||
assert {v["id"]: v["installed"] for v in state["venues"]}["bar"] is True
|
||||
assert client.delete("/api/plugins/career/packs/bar").status_code == 200
|
||||
state = client.get("/api/plugins/career/state").json()
|
||||
assert {v["id"]: v["installed"] for v in state["venues"]}["bar"] is False
|
||||
|
||||
|
||||
def test_download_worker_end_to_end(client, tmp_path):
|
||||
# Build a real pack zip, serve it via file://, verify the full worker path:
|
||||
# stream → sha256 → extract (flat names only) → validate → swap in.
|
||||
src = tmp_path / "src"
|
||||
src.mkdir()
|
||||
names = [f"{s}.mp4" for s in career_routes.REQUIRED_LOOPS] + ["cheer.mp4"]
|
||||
for name in names:
|
||||
(src / name).write_bytes(b"fake-video-" + name.encode())
|
||||
(src / "manifest.json").write_text(json.dumps({
|
||||
"venue": "bar", "version": 1,
|
||||
"loops": {s: f"{s}.mp4" for s in career_routes.REQUIRED_LOOPS},
|
||||
"stingers": {"cheer": "cheer.mp4"},
|
||||
}))
|
||||
zip_path = tmp_path / "bar-pack.zip"
|
||||
with zipfile.ZipFile(zip_path, "w") as zf:
|
||||
for p in src.iterdir():
|
||||
zf.write(p, p.name)
|
||||
sha = hashlib.sha256(zip_path.read_bytes()).hexdigest()
|
||||
|
||||
progress = {"status": "running", "bytes_done": 0, "bytes_total": 0, "error": None}
|
||||
career_routes._download_pack(
|
||||
"bar", {"url": zip_path.as_uri(), "sha256": sha}, progress)
|
||||
assert progress["status"] == "done", progress["error"]
|
||||
assert career_routes._installed("bar")
|
||||
assert progress["bytes_done"] == zip_path.stat().st_size
|
||||
|
||||
# Corrupt hash → error status, nothing installed over the good pack.
|
||||
bad = {"status": "running", "bytes_done": 0, "bytes_total": 0, "error": None}
|
||||
career_routes._download_pack("bar", {"url": zip_path.as_uri(), "sha256": "0" * 64}, bad)
|
||||
assert bad["status"] == "error"
|
||||
assert "sha256" in bad["error"]
|
||||
|
||||
|
||||
def test_double_download_409s(client, monkeypatch):
|
||||
bar = career_routes._venue("bar")
|
||||
monkeypatch.setitem(bar, "pack", {"url": "http://x/pack.zip", "sha256": "0" * 64})
|
||||
# Pretend one is already running.
|
||||
career_routes._state["downloads"]["bar"] = {"status": "running"}
|
||||
assert client.post("/api/plugins/career/packs/bar/download").status_code == 409
|
||||
assert client.delete("/api/plugins/career/packs/bar").status_code == 409
|
||||
Reference in New Issue
Block a user