mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-11 19:29:33 +00:00
feat(highway): show feedpak author/editor credits on song load (#629)
Surface the feedpak manifest `authors` list (spec §5.4) on the highway: a credits card ("Charted by Azure") shown over the highway when a song loads, riding the count-in / a ~3s hold and dismissed when playback starts. Gated to fresh feedpak plays only (minigames, loose/archive, arrangement switches, seeks, replays excluded). Includes a 12s backstop so the overlay never lingers if playback fails to start.
Closes #628. Reviewed by Codex (3 passes, converged). Verified locally: pytest 9/9, node --test 23/23, headless-browser end-to-end.
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
// Verify the feedpak credits overlay helpers in app.js:
|
||||
// - _creditLineLabel() role → friendly "<verb> by" label
|
||||
// - showSongCreditsOverlay() builds an XSS-safe card; no-op on empty list
|
||||
// - hideSongCreditsOverlay() removes the overlay element
|
||||
//
|
||||
// Same isolation strategy as autoplay_exit.test.js — extract the functions
|
||||
// from app.js by brace-matching and run them in a vm sandbox with a fake DOM.
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const { extractFunction } = require('./test_utils');
|
||||
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
const SRC = fs.readFileSync(APP_JS, 'utf8');
|
||||
|
||||
// Minimal fake DOM element: records className, children, and textContent.
|
||||
// Setting textContent clears children (matching real DOM) so we can assert
|
||||
// names were set via textContent (not innerHTML) — the XSS-safety contract.
|
||||
function makeEl() {
|
||||
return {
|
||||
className: '',
|
||||
children: [],
|
||||
_text: '',
|
||||
set textContent(v) { this._text = String(v); this.children = []; },
|
||||
get textContent() { return this._text; },
|
||||
appendChild(c) { this.children.push(c); return c; },
|
||||
replaceChildren() { this.children = []; },
|
||||
remove() { this.removed = true; },
|
||||
};
|
||||
}
|
||||
|
||||
function allText(node) {
|
||||
let s = node._text || '';
|
||||
for (const c of node.children) s += allText(c);
|
||||
return s;
|
||||
}
|
||||
|
||||
function buildSandbox(currentSong) {
|
||||
const body = makeEl();
|
||||
const sandbox = {
|
||||
document: { body, createElement: () => makeEl() },
|
||||
window: { feedBack: { currentSong, off() {} } },
|
||||
setTimeout: () => 1,
|
||||
clearTimeout: () => {},
|
||||
};
|
||||
vm.createContext(sandbox);
|
||||
const preamble = `
|
||||
let _creditsOverlay = null;
|
||||
let _creditsTimer = null;
|
||||
let _creditsHideOnPlay = null;
|
||||
let _creditsMaxTimer = null;
|
||||
const _CREDITS_MAX_MS = 12000;
|
||||
const _CREDIT_ROLE_VERBS = ${JSON.stringify({
|
||||
charter: 'Charted by', transcriber: 'Transcribed by',
|
||||
arranger: 'Arranged by', editor: 'Edited by', mixer: 'Mixed by',
|
||||
engineer: 'Engineered by', proofreader: 'Proofread by',
|
||||
})};
|
||||
`;
|
||||
vm.runInContext(
|
||||
preamble
|
||||
+ extractFunction(SRC, 'function _creditLineLabel(') + '\n'
|
||||
+ extractFunction(SRC, 'function showSongCreditsOverlay(') + '\n'
|
||||
+ extractFunction(SRC, 'function hideSongCreditsOverlay(') + '\n'
|
||||
+ 'globalThis._creditLineLabel = _creditLineLabel;'
|
||||
+ 'globalThis.showSongCreditsOverlay = showSongCreditsOverlay;'
|
||||
+ 'globalThis.hideSongCreditsOverlay = hideSongCreditsOverlay;'
|
||||
+ 'globalThis._getOverlay = () => _creditsOverlay;',
|
||||
sandbox,
|
||||
);
|
||||
return sandbox;
|
||||
}
|
||||
|
||||
test('_creditLineLabel maps known roles, title-cases unknown, blanks empty', () => {
|
||||
const s = buildSandbox({});
|
||||
assert.equal(s._creditLineLabel('charter'), 'Charted by');
|
||||
assert.equal(s._creditLineLabel('Editor'), 'Edited by'); // case-insensitive
|
||||
assert.equal(s._creditLineLabel('mixer'), 'Mixed by');
|
||||
assert.equal(s._creditLineLabel('luthier'), 'Luthier by'); // unknown → title-cased
|
||||
assert.equal(s._creditLineLabel(null), ''); // no role → bare name
|
||||
assert.equal(s._creditLineLabel(''), '');
|
||||
});
|
||||
|
||||
test('showSongCreditsOverlay builds a card with heading + credit lines', () => {
|
||||
const s = buildSandbox({ title: 'My Song' });
|
||||
s.showSongCreditsOverlay([
|
||||
{ name: 'Azure', role: 'charter' },
|
||||
{ name: 'Bob Lee', role: 'editor' },
|
||||
{ name: 'Solo', role: null },
|
||||
]);
|
||||
const overlay = s._getOverlay();
|
||||
assert.ok(overlay, 'overlay created');
|
||||
assert.equal(overlay.className, 'song-credits-overlay');
|
||||
assert.equal(s.document.body.children.length, 1);
|
||||
const text = allText(overlay);
|
||||
assert.match(text, /My Song/); // heading is the song title
|
||||
assert.match(text, /Charted by/);
|
||||
assert.match(text, /Azure/);
|
||||
assert.match(text, /Edited by/);
|
||||
assert.match(text, /Bob Lee/);
|
||||
assert.match(text, /Solo/); // role-less entry still shows the name
|
||||
});
|
||||
|
||||
test('showSongCreditsOverlay sets names via textContent (XSS-safe)', () => {
|
||||
const s = buildSandbox({ title: 'T' });
|
||||
s.showSongCreditsOverlay([{ name: '<img src=x onerror=alert(1)>', role: 'charter' }]);
|
||||
const overlay = s._getOverlay();
|
||||
// The raw string survives verbatim as text — proving it was never parsed
|
||||
// as HTML (no innerHTML interpolation anywhere on the path).
|
||||
assert.match(allText(overlay), /<img src=x onerror=alert\(1\)>/);
|
||||
});
|
||||
|
||||
test('showSongCreditsOverlay is a no-op for empty / non-array input', () => {
|
||||
const s = buildSandbox({ title: 'T' });
|
||||
s.showSongCreditsOverlay([]);
|
||||
assert.equal(s._getOverlay(), null);
|
||||
s.showSongCreditsOverlay(undefined);
|
||||
assert.equal(s._getOverlay(), null);
|
||||
assert.equal(s.document.body.children.length, 0);
|
||||
});
|
||||
|
||||
test('hideSongCreditsOverlay removes the overlay', () => {
|
||||
const s = buildSandbox({ title: 'T' });
|
||||
s.showSongCreditsOverlay([{ name: 'Azure', role: 'charter' }]);
|
||||
const overlay = s._getOverlay();
|
||||
assert.ok(overlay);
|
||||
s.hideSongCreditsOverlay();
|
||||
assert.equal(overlay.removed, true);
|
||||
assert.equal(s._getOverlay(), null);
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
"""Tests for feedpak contributor credits on the highway.
|
||||
|
||||
Covers the `_sanitize_authors` helper (unit) and the `song_info` WebSocket
|
||||
frame carrying the manifest `authors` list end-to-end (integration). The
|
||||
frontend uses a non-empty `authors` list to gate a credits overlay shown when
|
||||
a song loads, so loose/archive/synthetic plays must surface `[]`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import json
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
# ── _sanitize_authors unit tests ────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def server_mod(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("CONFIG_DIR", str(tmp_path / "config"))
|
||||
monkeypatch.setenv("DLC_DIR", str(tmp_path / "dlc"))
|
||||
(tmp_path / "dlc").mkdir()
|
||||
sys.modules.pop("server", None)
|
||||
mod = importlib.import_module("server")
|
||||
yield mod
|
||||
conn = getattr(getattr(mod, "meta_db", None), "conn", None)
|
||||
if conn is not None:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_sanitize_authors_valid(server_mod):
|
||||
out = server_mod._sanitize_authors(
|
||||
{
|
||||
"authors": [
|
||||
{"name": "Azure", "role": "charter", "email": "a@b.c", "url": "x"},
|
||||
{"name": "Bob Lee", "role": "editor"},
|
||||
{"name": "Solo"},
|
||||
]
|
||||
}
|
||||
)
|
||||
# name + role only; email/url dropped; missing role → None.
|
||||
assert out == [
|
||||
{"name": "Azure", "role": "charter"},
|
||||
{"name": "Bob Lee", "role": "editor"},
|
||||
{"name": "Solo", "role": None},
|
||||
]
|
||||
|
||||
|
||||
def test_sanitize_authors_skips_malformed(server_mod):
|
||||
out = server_mod._sanitize_authors(
|
||||
{
|
||||
"authors": [
|
||||
{"name": ""}, # blank name → skipped
|
||||
{"name": " "}, # whitespace name → skipped
|
||||
{"role": "mixer"}, # no name → skipped
|
||||
"not-a-dict", # non-dict → skipped
|
||||
{"name": " Kept ", "role": " arranger "}, # trimmed
|
||||
]
|
||||
}
|
||||
)
|
||||
assert out == [{"name": "Kept", "role": "arranger"}]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("manifest", [None, {}, {"authors": None}, {"authors": "x"}, "nope"])
|
||||
def test_sanitize_authors_absent_or_nonlist(server_mod, manifest):
|
||||
assert server_mod._sanitize_authors(manifest) == []
|
||||
|
||||
|
||||
# ── song_info WS integration ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _write_sloppak(dlc_root, *, authors):
|
||||
pak = dlc_root / "authortest.sloppak"
|
||||
pak.mkdir()
|
||||
(pak / "arrangements").mkdir()
|
||||
(pak / "arrangements" / "lead.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"notes": [],
|
||||
"chords": [],
|
||||
"anchors": [],
|
||||
"handshapes": [],
|
||||
"templates": [],
|
||||
"beats": [{"time": 0.0, "measure": 1}],
|
||||
"sections": [{"name": "intro", "number": 1, "time": 0.0}],
|
||||
}
|
||||
)
|
||||
)
|
||||
manifest = {
|
||||
"title": "Author Test",
|
||||
"artist": "Tester",
|
||||
"album": "",
|
||||
"year": 2026,
|
||||
"duration": 10.0,
|
||||
"arrangements": [{"id": "lead", "name": "Lead", "file": "arrangements/lead.json"}],
|
||||
"stems": [],
|
||||
}
|
||||
if authors is not None:
|
||||
manifest["authors"] = authors
|
||||
(pak / "manifest.yaml").write_text(yaml.safe_dump(manifest, sort_keys=False))
|
||||
return pak
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def make_client(tmp_path, monkeypatch):
|
||||
def _make():
|
||||
monkeypatch.setenv("CONFIG_DIR", str(tmp_path / "config"))
|
||||
monkeypatch.setenv("DLC_DIR", str(tmp_path / "dlc"))
|
||||
monkeypatch.setenv("FEEDBACK_SYNC_STARTUP", "1")
|
||||
sys.modules.pop("server", None)
|
||||
server = importlib.import_module("server")
|
||||
monkeypatch.setattr(server, "load_plugins", lambda *a, **kw: None)
|
||||
monkeypatch.setattr(server, "startup_scan", lambda: None)
|
||||
monkeypatch.setattr(server, "SLOPPAK_CACHE_DIR", tmp_path / "cache")
|
||||
return server
|
||||
|
||||
(tmp_path / "dlc").mkdir()
|
||||
yield _make
|
||||
server = sys.modules.get("server")
|
||||
conn = getattr(getattr(server, "meta_db", None), "conn", None)
|
||||
if conn is not None:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _song_info(client, path):
|
||||
with client.websocket_connect(path) as ws:
|
||||
for _ in range(200):
|
||||
msg = ws.receive_json()
|
||||
if msg.get("error"):
|
||||
raise AssertionError(f"WS error frame: {msg}")
|
||||
if msg.get("type") == "song_info":
|
||||
return msg
|
||||
if msg.get("type") == "ready":
|
||||
break
|
||||
raise AssertionError("no song_info frame received")
|
||||
|
||||
|
||||
def test_song_info_carries_authors(make_client):
|
||||
server = make_client()
|
||||
_write_sloppak(
|
||||
server._get_dlc_dir(),
|
||||
authors=[{"name": "Azure", "role": "charter", "email": "a@b.c"}],
|
||||
)
|
||||
with TestClient(server.app) as client:
|
||||
info = _song_info(client, "/ws/highway/authortest.sloppak?arrangement=0")
|
||||
assert info["authors"] == [{"name": "Azure", "role": "charter"}]
|
||||
|
||||
|
||||
def test_song_info_authors_empty_when_absent(make_client):
|
||||
server = make_client()
|
||||
_write_sloppak(server._get_dlc_dir(), authors=None)
|
||||
with TestClient(server.app) as client:
|
||||
info = _song_info(client, "/ws/highway/authortest.sloppak?arrangement=0")
|
||||
assert info["authors"] == []
|
||||
Reference in New Issue
Block a user