mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-12 11:49:28 +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:
@@ -2898,6 +2898,35 @@ def _sanitized_song_offset(song) -> float:
|
|||||||
return v if math.isfinite(v) else 0.0
|
return v if math.isfinite(v) else 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_authors(manifest: dict | None) -> list[dict]:
|
||||||
|
"""Extract a display-safe contributor list from a feedpak manifest.
|
||||||
|
|
||||||
|
The feedpak spec (§5.4) defines an OPTIONAL top-level `authors` list of
|
||||||
|
objects `{name (required), role?, email?, url?}`. We surface only `name`
|
||||||
|
and `role` to the highway — contact fields (email/url) are intentionally
|
||||||
|
dropped from the on-screen credits. Malformed entries (non-dict, missing /
|
||||||
|
blank name) are skipped; absent / non-list `authors` yields `[]`.
|
||||||
|
"""
|
||||||
|
if not isinstance(manifest, dict):
|
||||||
|
return []
|
||||||
|
raw = manifest.get("authors")
|
||||||
|
if not isinstance(raw, list):
|
||||||
|
return []
|
||||||
|
out: list[dict] = []
|
||||||
|
for entry in raw:
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
name = entry.get("name")
|
||||||
|
if not isinstance(name, str) or not name.strip():
|
||||||
|
continue
|
||||||
|
role = entry.get("role")
|
||||||
|
out.append({
|
||||||
|
"name": name.strip(),
|
||||||
|
"role": role.strip() if isinstance(role, str) and role.strip() else None,
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _stat_for_cache(f: Path) -> tuple[float, int]:
|
def _stat_for_cache(f: Path) -> tuple[float, int]:
|
||||||
"""Return (mtime, size) for cache freshness checks.
|
"""Return (mtime, size) for cache freshness checks.
|
||||||
|
|
||||||
@@ -7304,6 +7333,13 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
|
|||||||
# and break the frontend's song_info parsing.
|
# and break the frontend's song_info parsing.
|
||||||
"offset": _sanitized_song_offset(song) if is_loose else 0.0,
|
"offset": _sanitized_song_offset(song) if is_loose else 0.0,
|
||||||
"format": "sloppak" if is_slop else ("loose" if is_loose else "archive"),
|
"format": "sloppak" if is_slop else ("loose" if is_loose else "archive"),
|
||||||
|
# Feedpak contributor credits (manifest `authors:`, spec §5.4) —
|
||||||
|
# name + role only, shown on the highway when a song is loaded.
|
||||||
|
# Only sloppak/feedpak packs carry a manifest; loose/archive
|
||||||
|
# sources get []. The frontend uses a non-empty list as the gate
|
||||||
|
# for the credits overlay, so minigames / synthetic highway uses
|
||||||
|
# (no manifest) never trigger it.
|
||||||
|
"authors": _sanitize_authors(loaded_slop.manifest) if (is_slop and loaded_slop is not None) else [],
|
||||||
"stems": stems_payload,
|
"stems": stems_payload,
|
||||||
# Full-mix audio (sloppak `original_audio:`) served alongside the
|
# Full-mix audio (sloppak `original_audio:`) served alongside the
|
||||||
# separate `stems`. The stems plugin plays this single file while
|
# separate `stems`. The stems plugin plays this single file while
|
||||||
|
|||||||
+143
-1
@@ -5993,11 +5993,45 @@ let _pendingAutostart = false;
|
|||||||
window.feedBack.on('song:ready', () => {
|
window.feedBack.on('song:ready', () => {
|
||||||
if (!_pendingAutostart) return;
|
if (!_pendingAutostart) return;
|
||||||
_pendingAutostart = false;
|
_pendingAutostart = false;
|
||||||
if (!_autoplayExitEnabled() || isPlaying) return;
|
if (isPlaying) return;
|
||||||
|
// Feedpak contributor credits: only real feedpak plays carry authors
|
||||||
|
// (loose/archive and minigames get []), so a non-empty list is the gate.
|
||||||
|
// Shown over the highway and dismissed the moment real playback begins
|
||||||
|
// (song:play). This fresh-load path is the only place it fires —
|
||||||
|
// arrangement switches / seeks / manual replays never arm _pendingAutostart,
|
||||||
|
// and minigames never get here. Decoupled from autoplay below so credits
|
||||||
|
// show on load even when autoplay-exit is disabled.
|
||||||
|
const authors = (window.feedBack.currentSong && window.feedBack.currentSong.authors) || [];
|
||||||
|
if (authors.length) {
|
||||||
|
showSongCreditsOverlay(authors);
|
||||||
|
_creditsHideOnPlay = () => { _creditsHideOnPlay = null; hideSongCreditsOverlay(); };
|
||||||
|
window.feedBack.on('song:play', _creditsHideOnPlay, { once: true });
|
||||||
|
}
|
||||||
|
// Autoplay-exit disabled: don't auto-start. Still let the credits dwell a
|
||||||
|
// couple seconds on the freshly-loaded song, then clear them (they also
|
||||||
|
// clear early if the user manually presses Play, via _creditsHideOnPlay).
|
||||||
|
if (!_autoplayExitEnabled()) {
|
||||||
|
if (authors.length) _creditsTimer = setTimeout(hideSongCreditsOverlay, _CREDITS_HOLD_MS);
|
||||||
|
return;
|
||||||
|
}
|
||||||
// "Countdown before song": play a 4-beat count-in, then start. Otherwise
|
// "Countdown before song": play a 4-beat count-in, then start. Otherwise
|
||||||
// reuse the Play button's start path directly (handles HTML5 + _juceMode).
|
// reuse the Play button's start path directly (handles HTML5 + _juceMode).
|
||||||
if (_countdownBeforeSongEnabled()) {
|
if (_countdownBeforeSongEnabled()) {
|
||||||
|
// The count-in (~2.5s) gives the credits their on-screen dwell.
|
||||||
Promise.resolve(startSongCountIn()).catch((err) => console.warn('[app] song count-in failed:', err));
|
Promise.resolve(startSongCountIn()).catch((err) => console.warn('[app] song count-in failed:', err));
|
||||||
|
} else if (authors.length) {
|
||||||
|
// No count-in window — hold the credits a couple seconds, then start.
|
||||||
|
// _cancelCountIn() and changeArrangement() both clear _creditsTimer, so
|
||||||
|
// a teardown / arrangement switch during the hold cancels this play.
|
||||||
|
_creditsTimer = setTimeout(() => {
|
||||||
|
_creditsTimer = null;
|
||||||
|
// If playback doesn't actually start (e.g. HTML5 autoplay rejection),
|
||||||
|
// song:play never fires — clear the credits promptly rather than
|
||||||
|
// waiting for the backstop. On success the song:play listener owns it.
|
||||||
|
Promise.resolve(togglePlay())
|
||||||
|
.then(() => { if (!isPlaying) hideSongCreditsOverlay(); })
|
||||||
|
.catch((err) => { console.warn('[app] autoplay failed:', err); hideSongCreditsOverlay(); });
|
||||||
|
}, _CREDITS_HOLD_MS);
|
||||||
} else {
|
} else {
|
||||||
Promise.resolve(togglePlay()).catch((err) => console.warn('[app] autoplay failed:', err));
|
Promise.resolve(togglePlay()).catch((err) => console.warn('[app] autoplay failed:', err));
|
||||||
}
|
}
|
||||||
@@ -6395,6 +6429,11 @@ let _arrBusyTimeout = null;
|
|||||||
|
|
||||||
async function changeArrangement(index) {
|
async function changeArrangement(index) {
|
||||||
if (currentFilename) {
|
if (currentFilename) {
|
||||||
|
// Tear down any pending fresh-load credits before switching: the
|
||||||
|
// no-count-in hold timer would otherwise fire togglePlay() against the
|
||||||
|
// incoming (still-loading) arrangement. hideSongCreditsOverlay() clears
|
||||||
|
// the timer, the song:play listener, and the overlay node.
|
||||||
|
hideSongCreditsOverlay();
|
||||||
window.feedBack.emit('song:arrangement-changed', { filename: currentFilename, arrangement: index });
|
window.feedBack.emit('song:arrangement-changed', { filename: currentFilename, arrangement: index });
|
||||||
const wasPlaying = isPlaying;
|
const wasPlaying = isPlaying;
|
||||||
const time = _audioTime();
|
const time = _audioTime();
|
||||||
@@ -9285,10 +9324,27 @@ let _countOverlay = null;
|
|||||||
let _countInGen = 0;
|
let _countInGen = 0;
|
||||||
let _countInTimer = null;
|
let _countInTimer = null;
|
||||||
let _countInRaf = 0;
|
let _countInRaf = 0;
|
||||||
|
// Feedpak credits overlay (manifest `authors:`, spec §5.4): shown on the
|
||||||
|
// highway when a song is loaded, alongside the count-in. Torn down together
|
||||||
|
// with the count-in via _cancelCountIn().
|
||||||
|
let _creditsOverlay = null;
|
||||||
|
let _creditsTimer = null;
|
||||||
|
let _creditsHideOnPlay = null;
|
||||||
|
let _creditsMaxTimer = null;
|
||||||
|
const _CREDITS_HOLD_MS = 3000;
|
||||||
|
// Backstop: the overlay's primary dismiss is song:play, but playback can fail
|
||||||
|
// to start without emitting it (HTML5 autoplay rejection, JUCE start failure,
|
||||||
|
// a count-in handoff that never plays). This hard cap guarantees the credits
|
||||||
|
// never linger over the highway. Generous enough to outlast a normal count-in.
|
||||||
|
const _CREDITS_MAX_MS = 12000;
|
||||||
function _cancelCountIn() {
|
function _cancelCountIn() {
|
||||||
_countInGen++;
|
_countInGen++;
|
||||||
_countingIn = false;
|
_countingIn = false;
|
||||||
hideCountOverlay();
|
hideCountOverlay();
|
||||||
|
// The credits overlay rides the count-in lifecycle (and its no-count-in
|
||||||
|
// hold timer), so a teardown — leaving the player, loading another song —
|
||||||
|
// must clear it too, or it lingers on the next screen.
|
||||||
|
hideSongCreditsOverlay();
|
||||||
if (_countInTimer) { clearTimeout(_countInTimer); _countInTimer = null; }
|
if (_countInTimer) { clearTimeout(_countInTimer); _countInTimer = null; }
|
||||||
if (_countInRaf) { cancelAnimationFrame(_countInRaf); _countInRaf = 0; }
|
if (_countInRaf) { cancelAnimationFrame(_countInRaf); _countInRaf = 0; }
|
||||||
}
|
}
|
||||||
@@ -9306,6 +9362,92 @@ function hideCountOverlay() {
|
|||||||
if (_countOverlay) { _countOverlay.remove(); _countOverlay = null; }
|
if (_countOverlay) { _countOverlay.remove(); _countOverlay = null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Map a feedpak author `role` to a friendly "<verb> by" credit line. The
|
||||||
|
// recommended vocabulary is from feedpak spec §5.4; unknown roles are
|
||||||
|
// title-cased ("foo" → "Foo by"); a missing role shows the bare name.
|
||||||
|
const _CREDIT_ROLE_VERBS = {
|
||||||
|
charter: 'Charted by',
|
||||||
|
transcriber: 'Transcribed by',
|
||||||
|
arranger: 'Arranged by',
|
||||||
|
editor: 'Edited by',
|
||||||
|
mixer: 'Mixed by',
|
||||||
|
engineer: 'Engineered by',
|
||||||
|
proofreader: 'Proofread by',
|
||||||
|
};
|
||||||
|
|
||||||
|
function _creditLineLabel(role) {
|
||||||
|
if (!role) return '';
|
||||||
|
const key = String(role).trim().toLowerCase();
|
||||||
|
if (_CREDIT_ROLE_VERBS[key]) return _CREDIT_ROLE_VERBS[key];
|
||||||
|
return key.charAt(0).toUpperCase() + key.slice(1) + ' by';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show the feedpak contributor credits over the highway. `authors` is the
|
||||||
|
// sanitized [{name, role}] list from window.feedBack.currentSong.authors.
|
||||||
|
// Anchored to the lower third (bottom-center) so it never collides with the
|
||||||
|
// vertically-centered count-in number, and pointer-events-none so it never
|
||||||
|
// intercepts clicks. No-op when there are no contributors to show.
|
||||||
|
function showSongCreditsOverlay(authors) {
|
||||||
|
if (!Array.isArray(authors) || authors.length === 0) return;
|
||||||
|
if (!_creditsOverlay) {
|
||||||
|
_creditsOverlay = document.createElement('div');
|
||||||
|
_creditsOverlay.className = 'song-credits-overlay';
|
||||||
|
document.body.appendChild(_creditsOverlay);
|
||||||
|
}
|
||||||
|
// Build via DOM + textContent — author names are untrusted pack data and
|
||||||
|
// must never be interpolated as HTML.
|
||||||
|
_creditsOverlay.replaceChildren();
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'song-credits-card';
|
||||||
|
|
||||||
|
const eyebrow = document.createElement('div');
|
||||||
|
eyebrow.className = 'song-credits-eyebrow';
|
||||||
|
eyebrow.textContent = 'Credits';
|
||||||
|
card.appendChild(eyebrow);
|
||||||
|
|
||||||
|
const title = (window.feedBack && window.feedBack.currentSong
|
||||||
|
&& window.feedBack.currentSong.title) || '';
|
||||||
|
if (title) {
|
||||||
|
const heading = document.createElement('div');
|
||||||
|
heading.className = 'song-credits-heading';
|
||||||
|
heading.textContent = title;
|
||||||
|
card.appendChild(heading);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const a of authors) {
|
||||||
|
if (!a || !a.name) continue;
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'song-credits-line';
|
||||||
|
const label = _creditLineLabel(a.role);
|
||||||
|
if (label) {
|
||||||
|
const lab = document.createElement('span');
|
||||||
|
lab.className = 'song-credits-role';
|
||||||
|
lab.textContent = label + ' ';
|
||||||
|
row.appendChild(lab);
|
||||||
|
}
|
||||||
|
const nm = document.createElement('span');
|
||||||
|
nm.className = 'song-credits-name';
|
||||||
|
nm.textContent = a.name;
|
||||||
|
row.appendChild(nm);
|
||||||
|
card.appendChild(row);
|
||||||
|
}
|
||||||
|
_creditsOverlay.appendChild(card);
|
||||||
|
// Arm the backstop so the overlay self-clears even if playback never starts
|
||||||
|
// / never emits song:play. song:play (or any teardown) clears it earlier.
|
||||||
|
if (_creditsMaxTimer) clearTimeout(_creditsMaxTimer);
|
||||||
|
_creditsMaxTimer = setTimeout(hideSongCreditsOverlay, _CREDITS_MAX_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideSongCreditsOverlay() {
|
||||||
|
if (_creditsTimer) { clearTimeout(_creditsTimer); _creditsTimer = null; }
|
||||||
|
if (_creditsMaxTimer) { clearTimeout(_creditsMaxTimer); _creditsMaxTimer = null; }
|
||||||
|
if (_creditsHideOnPlay) {
|
||||||
|
window.feedBack.off('song:play', _creditsHideOnPlay);
|
||||||
|
_creditsHideOnPlay = null;
|
||||||
|
}
|
||||||
|
if (_creditsOverlay) { _creditsOverlay.remove(); _creditsOverlay = null; }
|
||||||
|
}
|
||||||
|
|
||||||
async function startCountIn(opts = {}) {
|
async function startCountIn(opts = {}) {
|
||||||
if (_countingIn) return;
|
if (_countingIn) return;
|
||||||
_countingIn = true;
|
_countingIn = true;
|
||||||
|
|||||||
@@ -3530,6 +3530,13 @@ function createHighway() {
|
|||||||
// matchesArrangement on this rather than the
|
// matchesArrangement on this rather than the
|
||||||
// arrangement name.
|
// arrangement name.
|
||||||
hasNotation: Boolean(msg.has_notation),
|
hasNotation: Boolean(msg.has_notation),
|
||||||
|
// Feedpak contributor credits (manifest
|
||||||
|
// `authors:`, spec §5.4): [{name, role}].
|
||||||
|
// Only real feedpak plays carry these; loose/
|
||||||
|
// archive sources and synthetic highway uses
|
||||||
|
// (minigames) get []. app.js shows a credits
|
||||||
|
// overlay on song load when this is non-empty.
|
||||||
|
authors: Array.isArray(msg.authors) ? msg.authors : [],
|
||||||
};
|
};
|
||||||
window.feedBack.emit('song:loaded', window.feedBack.currentSong);
|
window.feedBack.emit('song:loaded', window.feedBack.currentSong);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -863,3 +863,93 @@ html { scroll-behavior: smooth; }
|
|||||||
box-shadow: 0 0 0 2px rgba(64, 128, 224, 0.7);
|
box-shadow: 0 0 0 2px rgba(64, 128, 224, 0.7);
|
||||||
border-radius: 0.25rem;
|
border-radius: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Feedpak contributor credits shown over the highway when a song loads
|
||||||
|
(manifest `authors:`, spec §5.4). Anchored to the upper third so it sits
|
||||||
|
ABOVE the vertically-centered count-in number; click-through. */
|
||||||
|
.song-credits-overlay {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: 15%;
|
||||||
|
/* Above the modal layer (z-[200], incl. the "Loading audio" backdrop) and
|
||||||
|
the count-in number (z-[100]) so the credits stay prominent through the
|
||||||
|
whole load → count-in → play window. */
|
||||||
|
z-index: 205;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
pointer-events: none;
|
||||||
|
animation: song-credits-fade-in 0.45s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-credits-card {
|
||||||
|
position: relative;
|
||||||
|
min-width: 16rem;
|
||||||
|
max-width: min(90vw, 34rem);
|
||||||
|
padding: 1.4rem 2.5rem 1.5rem;
|
||||||
|
text-align: center;
|
||||||
|
background:
|
||||||
|
radial-gradient(120% 140% at 50% 0%, rgb(56 78 130 / 0.45) 0%, transparent 60%),
|
||||||
|
linear-gradient(165deg, rgb(23 30 48 / 0.92) 0%, rgb(11 15 26 / 0.94) 100%);
|
||||||
|
border: 1px solid rgb(129 140 248 / 0.28);
|
||||||
|
border-radius: 1rem;
|
||||||
|
box-shadow:
|
||||||
|
0 18px 50px rgb(0 0 0 / 0.55),
|
||||||
|
0 0 0 1px rgb(0 0 0 / 0.35),
|
||||||
|
inset 0 1px 0 rgb(255 255 255 / 0.07);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
-webkit-backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Accent bar across the top edge of the card. */
|
||||||
|
.song-credits-card::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 3.25rem;
|
||||||
|
height: 3px;
|
||||||
|
border-radius: 0 0 3px 3px;
|
||||||
|
background: linear-gradient(90deg, #38bdf8, #818cf8);
|
||||||
|
box-shadow: 0 0 12px rgb(99 102 241 / 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-credits-eyebrow {
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.22em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: rgb(165 180 252 / 0.9);
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-credits-heading {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #f8fafc;
|
||||||
|
margin-bottom: 0.7rem;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
text-shadow: 0 1px 8px rgb(0 0 0 / 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-credits-line {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-credits-role {
|
||||||
|
color: rgb(148 163 184 / 0.95);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-credits-name {
|
||||||
|
font-weight: 700;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes song-credits-fade-in {
|
||||||
|
from { opacity: 0; transform: translateY(-12px) scale(0.97); }
|
||||||
|
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||||
|
}
|
||||||
|
|||||||
@@ -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