mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-13 08:29:28 +00:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d0229556f | ||
|
|
1787e19213 | ||
|
|
e729c44d5b | ||
|
|
2991612531 | ||
|
|
af611770aa | ||
|
|
ea9da0acde | ||
|
|
be473dc7af |
@@ -8,6 +8,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
- **Gold tier (career passports)** — an earned badge turns **gold** when
|
||||||
|
Virtuoso verifies an improvised jam in the passport's style (the
|
||||||
|
`gold_improv` artifact relays with the drill snapshot; a genre inherits its
|
||||||
|
family's style, gained-only, and gold never substitutes for the badge bar
|
||||||
|
itself). Gold gets its own ceremony, stamp slam, foil chip, and gold ink on
|
||||||
|
the shelf cover, profile wall, and passport card; the bronze page's "Gold
|
||||||
|
rung coming" preview becomes a live invitation to jam it.
|
||||||
- **Gigs (the career verb, frontend)** — book a gig from any opened passport:
|
- **Gigs (the career verb, frontend)** — book a gig from any opened passport:
|
||||||
a gig poster proposes the setlist (re-roll for a different bill; save or
|
a gig poster proposes the setlist (re-roll for a different bill; save or
|
||||||
copy the poster as a PNG), "Play the gig" hands the set to the play queue
|
copy the poster as a PNG), "Play the gig" hands the set to the play queue
|
||||||
@@ -39,6 +46,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
carry their gig log; instruments their gig count.
|
carry their gig log; instruments their gig count.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
- **Folder library renders only the songs on screen** (#965) — a song list used to
|
||||||
|
render *every* song it held. On a flat 50,944-song library that was one `<div>`
|
||||||
|
with 50,938 children and ~1.3 **million** DOM nodes (~4.2 GB of renderer memory),
|
||||||
|
built even while another screen was showing. A document that size also punishes
|
||||||
|
unrelated code: any `document.querySelector` that misses has to walk the whole
|
||||||
|
tree — which is how the song-preview menu check ended up eating ~50% of the
|
||||||
|
renderer and dropping the app to 2.7 fps. Lists longer than 200 songs are now
|
||||||
|
windowed (25–31 rows in the DOM instead of 50,000); shorter lists are unchanged.
|
||||||
- **The full mix is a stem** (#933) — core no longer depends on `original_audio:`, a
|
- **The full mix is a stem** (#933) — core no longer depends on `original_audio:`, a
|
||||||
top-level manifest key this repo invented (#583) that the feedpak spec never had.
|
top-level manifest key this repo invented (#583) that the feedpak spec never had.
|
||||||
The format already carried the pre-separation mixdown as a stem; feedpak 1.15.0
|
The format already carried the pre-separation mixdown as a stem; feedpak 1.15.0
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ and is a monolith in its own right, to be split per-table once the router train
|
|||||||
lands) · `static/v3/songs.js` (4,134) · `static/capabilities/audio-session.js`
|
lands) · `static/v3/songs.js` (4,134) · `static/capabilities/audio-session.js`
|
||||||
(2,974) · `plugins/highway_3d/screen.js` (15,656) · `plugins/keys_highway_3d/screen.js`
|
(2,974) · `plugins/highway_3d/screen.js` (15,656) · `plugins/keys_highway_3d/screen.js`
|
||||||
(3,780) · `plugins/drum_highway_3d/screen.js` (3,597) · `plugins/career/screen.js`
|
(3,780) · `plugins/drum_highway_3d/screen.js` (3,597) · `plugins/career/screen.js`
|
||||||
(1,516 — career v3 gigs pushed it over; split plan: carve the gig block into a
|
(1,530 — career v3 gigs + gold pushed it over; split plan: carve the gig block into a
|
||||||
`scriptType: module` file when career work next touches it) — and every monolith with a PR
|
`scriptType: module` file when career work next touches it) — and every monolith with a PR
|
||||||
train in the refactor plan. Test files (e.g. `tests/test_plugins.py`) are out of scope
|
train in the refactor plan. Test files (e.g. `tests/test_plugins.py`) are out of scope
|
||||||
by policy — the norm governs source files.
|
by policy — the norm governs source files.
|
||||||
|
|||||||
+9
-1
@@ -14,6 +14,7 @@ import os
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import appstate
|
import appstate
|
||||||
|
from safepath import resolved_root
|
||||||
|
|
||||||
|
|
||||||
def _get_dlc_dir(cfg: dict | None = None) -> Path | None:
|
def _get_dlc_dir(cfg: dict | None = None) -> Path | None:
|
||||||
@@ -86,7 +87,14 @@ def _resolve_dlc_path(dlc: Path, filename: str) -> Path | None:
|
|||||||
or PureWindowsPath(safe).drive):
|
or PureWindowsPath(safe).drive):
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
root = dlc.resolve()
|
# The library root is fixed for the life of the process, but this
|
||||||
|
# function runs once per song / art fetch / scanned row — and
|
||||||
|
# `.resolve()` lstats every path component. Re-resolving here was
|
||||||
|
# ~23,500 stat calls/sec on a 50,944-song library, which pins a core
|
||||||
|
# when the library sits on a FUSE mount (NTFS-3G, SMB, sshfs) where each
|
||||||
|
# stat is a userspace round trip. Resolve the root once; see
|
||||||
|
# safepath.resolved_root for the caching contract.
|
||||||
|
root = resolved_root(dlc)
|
||||||
# normpath collapses `.`/`..`/duplicate separators purely lexically —
|
# normpath collapses `.`/`..`/duplicate separators purely lexically —
|
||||||
# it never touches the filesystem, so an in-library junction component
|
# it never touches the filesystem, so an in-library junction component
|
||||||
# is preserved (allowed) while `..`/absolute segments still escape and
|
# is preserved (allowed) while `..`/absolute segments still escape and
|
||||||
|
|||||||
+31
-3
@@ -4,9 +4,34 @@ under a server-owned root.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from functools import lru_cache
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=16)
|
||||||
|
def resolved_root(root: Path) -> Path:
|
||||||
|
"""Canonical (link-resolved) form of a server-owned root directory.
|
||||||
|
|
||||||
|
``Path.resolve()`` is a filesystem call: it lstats every component of the
|
||||||
|
path. The roots we join against — the DLC library, a plugin's asset dir —
|
||||||
|
are fixed for the life of the process, but the containment helpers below
|
||||||
|
(and ``dlc_paths._resolve_dlc_path``) were re-resolving them on EVERY call,
|
||||||
|
and those are called once per song, per art fetch, per scanned row.
|
||||||
|
|
||||||
|
On a real 50,944-song library that cost ~23,500 stat/lstat calls per second,
|
||||||
|
pinning a core. It is brutal when the library lives on a FUSE mount
|
||||||
|
(NTFS-3G, SMB, sshfs), where every stat is a userspace round trip: the same
|
||||||
|
three parent directories were being walked over and over.
|
||||||
|
|
||||||
|
Cached because a root is a constant here, not because resolution is cheap.
|
||||||
|
Consequence: if a root's symlink/junction is re-pointed at a NEW target
|
||||||
|
while the server is running, the old target stays in effect until restart.
|
||||||
|
That is fine for a library path fixed at startup, and the cache is keyed on
|
||||||
|
the Path, so switching to a different library dir is a different key.
|
||||||
|
"""
|
||||||
|
return root.resolve()
|
||||||
|
|
||||||
|
|
||||||
def safe_join(root: Path, name: str) -> Path | None:
|
def safe_join(root: Path, name: str) -> Path | None:
|
||||||
"""Resolve ``name`` under ``root`` and return the resolved Path, or
|
"""Resolve ``name`` under ``root`` and return the resolved Path, or
|
||||||
``None`` if it would escape ``root`` or is unrepresentable.
|
``None`` if it would escape ``root`` or is unrepresentable.
|
||||||
@@ -35,9 +60,12 @@ def safe_join(root: Path, name: str) -> Path | None:
|
|||||||
return None
|
return None
|
||||||
safe = name.replace("\\", "/")
|
safe = name.replace("\\", "/")
|
||||||
try:
|
try:
|
||||||
root_resolved = root.resolve()
|
# The ROOT is a constant — resolve it once (see resolved_root). The
|
||||||
candidate = (root_resolved / safe).resolve()
|
# CANDIDATE must still be resolved on every call: following its symlinks
|
||||||
if not candidate.is_relative_to(root_resolved):
|
# is exactly the zip-slip / traversal defence, so it is never cached.
|
||||||
|
root_res = resolved_root(root)
|
||||||
|
candidate = (root_res / safe).resolve()
|
||||||
|
if not candidate.is_relative_to(root_res):
|
||||||
return None
|
return None
|
||||||
except (ValueError, OSError):
|
except (ValueError, OSError):
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -525,7 +525,18 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Gold foil preview — honest "coming", never earnable-looking. */
|
/* Gold ink — a REAL gold badge (comb-verified improv). */
|
||||||
|
.pp-stamp-gold {
|
||||||
|
border-color: #b8860b;
|
||||||
|
color: #a97b1b;
|
||||||
|
box-shadow: inset 0 0 0 3px #f3e8c8, inset 0 0 0 4px #b8860b;
|
||||||
|
}
|
||||||
|
.pp-stamp-mini.pp-stamp-gold {
|
||||||
|
color: #f0c75e;
|
||||||
|
border-color: #f0c75e;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
/* Gold foil chip — rendered only alongside an earned gold stamp. */
|
||||||
.pp-gold-foil {
|
.pp-gold-foil {
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -535,8 +546,8 @@
|
|||||||
margin-top: 0.9rem;
|
margin-top: 0.9rem;
|
||||||
padding: 0.28rem 0.85rem;
|
padding: 0.28rem 0.85rem;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
border: 2px dashed #c8b273;
|
border: 2px solid #d9a253;
|
||||||
color: #a8946d;
|
color: #c89040;
|
||||||
font-size: 0.58rem;
|
font-size: 0.58rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.32em;
|
letter-spacing: 0.32em;
|
||||||
|
|||||||
@@ -330,10 +330,11 @@ def _badge_requirement(gkey, instrument="guitar"):
|
|||||||
def _drill_by_node():
|
def _drill_by_node():
|
||||||
doc = _load_json(_drill_file(), {})
|
doc = _load_json(_drill_file(), {})
|
||||||
if not isinstance(doc, dict):
|
if not isinstance(doc, dict):
|
||||||
return None, {}
|
return None, {}, {}
|
||||||
snapshot = doc.get("snapshot") if isinstance(doc.get("snapshot"), dict) else {}
|
snapshot = doc.get("snapshot") if isinstance(doc.get("snapshot"), dict) else {}
|
||||||
by_node = snapshot.get("byNode") if isinstance(snapshot.get("byNode"), dict) else {}
|
by_node = snapshot.get("byNode") if isinstance(snapshot.get("byNode"), dict) else {}
|
||||||
return doc.get("received_at"), by_node
|
gold = snapshot.get("goldImprov") if isinstance(snapshot.get("goldImprov"), dict) else {}
|
||||||
|
return doc.get("received_at"), by_node, gold
|
||||||
|
|
||||||
|
|
||||||
def _merge_drill_nodes(old, new):
|
def _merge_drill_nodes(old, new):
|
||||||
@@ -367,6 +368,16 @@ def _merge_drill_nodes(old, new):
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_gold(old, new):
|
||||||
|
"""Gained-only merge of goldImprov artifacts: a minted style never
|
||||||
|
un-mints via a stale relay; the FIRST artifact per style is kept."""
|
||||||
|
out = dict(old)
|
||||||
|
for style_id, art in (new or {}).items():
|
||||||
|
if isinstance(art, dict) and style_id not in out:
|
||||||
|
out[style_id] = art
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _node_cleared(by_node, node_id):
|
def _node_cleared(by_node, node_id):
|
||||||
"""A drill counts as cleared on real completion evidence: mastered, any
|
"""A drill counts as cleared on real completion evidence: mastered, any
|
||||||
depth rung flipped true, or a key cleared (a top-tier clean pass in one
|
depth rung flipped true, or a key cleared (a top-tier clean pass in one
|
||||||
@@ -388,7 +399,7 @@ def _passports_view():
|
|||||||
st = _career_state()
|
st = _career_state()
|
||||||
all_gigs = st.get("gigs") if isinstance(st.get("gigs"), list) else []
|
all_gigs = st.get("gigs") if isinstance(st.get("gigs"), list) else []
|
||||||
played, played_seconds = _played_by_instrument_genre()
|
played, played_seconds = _played_by_instrument_genre()
|
||||||
received_at, by_node = _drill_by_node()
|
received_at, by_node, gold_improv = _drill_by_node()
|
||||||
instruments = {}
|
instruments = {}
|
||||||
for inst in cfg.get("instruments") or []:
|
for inst in cfg.get("instruments") or []:
|
||||||
committed_at = (st["instruments"].get(inst) or {}).get("committed_at")
|
committed_at = (st["instruments"].get(inst) or {}).get("committed_at")
|
||||||
@@ -414,7 +425,20 @@ def _passports_view():
|
|||||||
# false badge denial — the doc's shown-not-judged rule.
|
# false badge denial — the doc's shown-not-judged rule.
|
||||||
badge = "shown_not_judged"
|
badge = "shown_not_judged"
|
||||||
elif qualifying >= req["songs"] and len(cleared) == len(required):
|
elif qualifying >= req["songs"] and len(cleared) == len(required):
|
||||||
badge = "earned"
|
# Bronze is earned; GOLD upgrades it when a verified improv
|
||||||
|
# artifact exists for this genre's jam style. Virtuoso mints
|
||||||
|
# under raw STYLE_PALETTES ids ('punk', 'djent', 'disco', ...),
|
||||||
|
# which are mostly NOT family keys — so match in family space:
|
||||||
|
# the same keyword bucketing genres get ('punk' and 'punk
|
||||||
|
# rock' both bucket to 'rock'), with the exact key as a direct
|
||||||
|
# hit. Bronze remains a standalone win; gold never becomes an
|
||||||
|
# obligation.
|
||||||
|
fam = _genre_family(gkey)
|
||||||
|
gold = any(
|
||||||
|
s == gkey or (fam is not None and _genre_family(s) == fam)
|
||||||
|
for s in gold_improv
|
||||||
|
)
|
||||||
|
badge = "gold" if gold else "earned"
|
||||||
else:
|
else:
|
||||||
badge = "in_progress"
|
badge = "in_progress"
|
||||||
# Practice invitation: the non-qualifying songs closest to the
|
# Practice invitation: the non-qualifying songs closest to the
|
||||||
@@ -687,10 +711,23 @@ def setup(app, context):
|
|||||||
# drops junk entries, which must not become a size-guard bypass.
|
# drops junk entries, which must not become a size-guard bypass.
|
||||||
if len(json.dumps(body["byNode"])) > DRILL_SNAPSHOT_MAX_BYTES:
|
if len(json.dumps(body["byNode"])) > DRILL_SNAPSHOT_MAX_BYTES:
|
||||||
raise HTTPException(413, "Snapshot too large.")
|
raise HTTPException(413, "Snapshot too large.")
|
||||||
|
gold_in = body.get("goldImprov", {})
|
||||||
|
if not isinstance(gold_in, dict):
|
||||||
|
# A relay bug must be LOUD, not a silent 200 that drops gold.
|
||||||
|
raise HTTPException(400, "goldImprov must be an object keyed by style id.")
|
||||||
|
# Keep only plausible artifacts: a dict that names its verifier —
|
||||||
|
# an empty {} must not mint an evidence-free gold.
|
||||||
|
gold_in = {k: v for k, v in gold_in.items()
|
||||||
|
if isinstance(v, dict) and v.get("verifier")}
|
||||||
|
# Same pre-merge bound byNode gets: the gained-only merge dropping
|
||||||
|
# junk must not become a size-guard bypass (nor lock-held CPU burn).
|
||||||
|
if len(json.dumps(gold_in)) > DRILL_SNAPSHOT_MAX_BYTES:
|
||||||
|
raise HTTPException(413, "Snapshot too large.")
|
||||||
with _lock:
|
with _lock:
|
||||||
_, existing = _drill_by_node()
|
_, existing, existing_gold = _drill_by_node()
|
||||||
snapshot = {"mode": body.get("mode"), "xp": body.get("xp"),
|
snapshot = {"mode": body.get("mode"), "xp": body.get("xp"),
|
||||||
"byNode": _merge_drill_nodes(existing, body["byNode"])}
|
"byNode": _merge_drill_nodes(existing, body["byNode"]),
|
||||||
|
"goldImprov": _merge_gold(existing_gold, gold_in)}
|
||||||
if len(json.dumps(snapshot)) > DRILL_SNAPSHOT_MAX_BYTES:
|
if len(json.dumps(snapshot)) > DRILL_SNAPSHOT_MAX_BYTES:
|
||||||
raise HTTPException(413, "Snapshot too large.")
|
raise HTTPException(413, "Snapshot too large.")
|
||||||
_save_json(_drill_file(), {"received_at": _now_iso(),
|
_save_json(_drill_file(), {"received_at": _now_iso(),
|
||||||
|
|||||||
+37
-22
@@ -304,11 +304,15 @@
|
|||||||
} catch (_) { return {}; }
|
} catch (_) { return {}; }
|
||||||
}
|
}
|
||||||
|
|
||||||
function badgeId(inst, gkey) { return inst + '/' + gkey; }
|
// Bronze keeps the legacy un-suffixed id, so badges seen before the Gold
|
||||||
|
// tier existed stay seen; gold is a distinct moment with its own id.
|
||||||
|
function badgeId(inst, gkey, tier) { return inst + '/' + gkey + (tier === 'gold' ? '@gold' : ''); }
|
||||||
|
|
||||||
function markBadgeSeen(inst, gkey) {
|
function markBadgeSeen(inst, gkey, tier) {
|
||||||
const seen = seenBadges();
|
const seen = seenBadges();
|
||||||
seen[badgeId(inst, gkey)] = 1;
|
seen[badgeId(inst, gkey, tier)] = 1;
|
||||||
|
// A gold slam covers the bronze moment too — never queue both.
|
||||||
|
if (tier === 'gold') seen[badgeId(inst, gkey)] = 1;
|
||||||
lsSet(PP_SEEN_KEY, JSON.stringify(seen));
|
lsSet(PP_SEEN_KEY, JSON.stringify(seen));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -320,15 +324,19 @@
|
|||||||
const seen = seenBadges();
|
const seen = seenBadges();
|
||||||
for (const inst of Object.keys(view.instruments || {})) {
|
for (const inst of Object.keys(view.instruments || {})) {
|
||||||
for (const p of (view.instruments[inst].passports || [])) {
|
for (const p of (view.instruments[inst].passports || [])) {
|
||||||
const id = badgeId(inst, p.genre_key);
|
if (p.badge !== 'earned' && p.badge !== 'gold') continue;
|
||||||
if (p.badge !== 'earned' || seen[id] || _ppNotified[id]) continue;
|
const gold = p.badge === 'gold';
|
||||||
|
const id = badgeId(inst, p.genre_key, p.badge);
|
||||||
|
if (seen[id] || _ppNotified[id]) continue;
|
||||||
_ppNotified[id] = true;
|
_ppNotified[id] = true;
|
||||||
sfx('chime');
|
sfx('chime');
|
||||||
if (window.fbNotify && typeof window.fbNotify.show === 'function') {
|
if (window.fbNotify && typeof window.fbNotify.show === 'function') {
|
||||||
window.fbNotify.show({
|
window.fbNotify.show({
|
||||||
big: true, icon: '🛂', accent: '#b45309',
|
big: true, icon: gold ? '🏅' : '🛂', accent: gold ? '#d9a253' : '#b45309',
|
||||||
title: 'Badge earned!',
|
title: gold ? 'Gold — a verified improv!' : 'Badge earned!',
|
||||||
message: `${p.genre} — Bronze, ready to stamp into your ${ppLabel(inst)} passport.`,
|
message: gold
|
||||||
|
? `${p.genre} — your ${ppLabel(inst)} badge turns gold.`
|
||||||
|
: `${p.genre} — Bronze, ready to stamp into your ${ppLabel(inst)} passport.`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
badgeCeremony(inst, p);
|
badgeCeremony(inst, p);
|
||||||
@@ -377,11 +385,11 @@
|
|||||||
el.innerHTML = `
|
el.innerHTML = `
|
||||||
<canvas class="pp-confetti"></canvas>
|
<canvas class="pp-confetti"></canvas>
|
||||||
<div class="pp-ceremony-card">
|
<div class="pp-ceremony-card">
|
||||||
<div class="pp-stamp pp-stamp-page pp-ceremony-stamp" style="--pp-rot:${ppJitter(p.genre_key, 7).toFixed(1)}deg">
|
<div class="pp-stamp pp-stamp-page pp-ceremony-stamp${p.badge === 'gold' ? ' pp-stamp-gold' : ''}" style="--pp-rot:${ppJitter(p.genre_key, 7).toFixed(1)}deg">
|
||||||
<span class="pp-stamp-genre">${esc(p.genre.toUpperCase())}</span>
|
<span class="pp-stamp-genre">${esc(p.genre.toUpperCase())}</span>
|
||||||
<span class="pp-stamp-tier">BRONZE</span>
|
<span class="pp-stamp-tier">${p.badge === 'gold' ? 'GOLD' : 'BRONZE'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="pp-ceremony-title">Badge earned</div>
|
<div class="pp-ceremony-title">${p.badge === 'gold' ? 'Gold — a verified improv' : 'Badge earned'}</div>
|
||||||
<div class="pp-ceremony-sub">${esc(p.genre)} — ${esc(ppLabel(inst))} passport</div>
|
<div class="pp-ceremony-sub">${esc(p.genre)} — ${esc(ppLabel(inst))} passport</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
let timer = 0;
|
let timer = 0;
|
||||||
@@ -445,7 +453,11 @@
|
|||||||
fetch(`${API}/drill-state`, {
|
fetch(`${API}/drill-state`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ mode: snap.mode, xp: snap.xp, byNode: snap.byNode }),
|
body: JSON.stringify({
|
||||||
|
mode: snap.mode, xp: snap.xp, byNode: snap.byNode,
|
||||||
|
...(snap.goldImprov && typeof snap.goldImprov === 'object' && !Array.isArray(snap.goldImprov)
|
||||||
|
? { goldImprov: snap.goldImprov } : {}),
|
||||||
|
}),
|
||||||
}).then(() => refreshPassports()).catch(() => { /* next event retries */ });
|
}).then(() => refreshPassports()).catch(() => { /* next event retries */ });
|
||||||
}, 1500);
|
}, 1500);
|
||||||
}
|
}
|
||||||
@@ -483,9 +495,9 @@
|
|||||||
|
|
||||||
function ppCoverHTML(inst, p) {
|
function ppCoverHTML(inst, p) {
|
||||||
const rot = ppJitter(inst + p.genre_key, 1.6).toFixed(2);
|
const rot = ppJitter(inst + p.genre_key, 1.6).toFixed(2);
|
||||||
const earned = p.badge === 'earned';
|
const earned = p.badge === 'earned' || p.badge === 'gold';
|
||||||
const stamp = earned
|
const stamp = earned
|
||||||
? `<span class="pp-stamp pp-stamp-mini" style="--pp-rot:${ppJitter(p.genre_key, 8).toFixed(1)}deg">BRONZE</span>`
|
? `<span class="pp-stamp pp-stamp-mini${p.badge === 'gold' ? ' pp-stamp-gold' : ''}" style="--pp-rot:${ppJitter(p.genre_key, 8).toFixed(1)}deg">${p.badge === 'gold' ? 'GOLD' : 'BRONZE'}</span>`
|
||||||
: '';
|
: '';
|
||||||
const stubs = p.qualifying_count === 1 ? '1 stub' : `${p.qualifying_count} stubs`;
|
const stubs = p.qualifying_count === 1 ? '1 stub' : `${p.qualifying_count} stubs`;
|
||||||
const hours = fmtHours(p.seconds_total);
|
const hours = fmtHours(p.seconds_total);
|
||||||
@@ -599,7 +611,7 @@
|
|||||||
const data = (_pp.instruments || {})[inst] || { passports: [] };
|
const data = (_pp.instruments || {})[inst] || { passports: [] };
|
||||||
host.innerHTML = ((_pp.config || {}).instruments || []).map((i) => {
|
host.innerHTML = ((_pp.config || {}).instruments || []).map((i) => {
|
||||||
const d = (_pp.instruments || {})[i] || {};
|
const d = (_pp.instruments || {})[i] || {};
|
||||||
const earned = (d.passports || []).filter((p) => p.badge === 'earned').length;
|
const earned = (d.passports || []).filter((p) => p.badge === 'earned' || p.badge === 'gold').length;
|
||||||
const committed = !!d.committed_at;
|
const committed = !!d.committed_at;
|
||||||
return `<button class="pp-inst${i === inst ? ' active' : ''}${committed ? '' : ' uncommitted'}" data-pp-inst="${esc(i)}">
|
return `<button class="pp-inst${i === inst ? ' active' : ''}${committed ? '' : ' uncommitted'}" data-pp-inst="${esc(i)}">
|
||||||
${esc(ppLabel(i))}${earned ? ` <span class="pp-inst-badges">⚡${earned}</span>` : ''}${committed ? '' : ' <span class="pp-inst-plus">+</span>'}
|
${esc(ppLabel(i))}${earned ? ` <span class="pp-inst-badges">⚡${earned}</span>` : ''}${committed ? '' : ' <span class="pp-inst-plus">+</span>'}
|
||||||
@@ -641,13 +653,15 @@
|
|||||||
let badgeArea = '';
|
let badgeArea = '';
|
||||||
if (p.badge === 'shown_not_judged') {
|
if (p.badge === 'shown_not_judged') {
|
||||||
badgeArea = `<div class="pp-snj">Shown, not judged — your ${esc(ppLabel(inst).toLowerCase())} repertoire speaks for itself.</div>`;
|
badgeArea = `<div class="pp-snj">Shown, not judged — your ${esc(ppLabel(inst).toLowerCase())} repertoire speaks for itself.</div>`;
|
||||||
} else if (p.badge === 'earned') {
|
} else if (p.badge === 'earned' || p.badge === 'gold') {
|
||||||
badgeArea = `<div class="pp-stamp pp-stamp-page${pendingSlam ? ' pp-stamp-hidden' : ' pp-tilt'}" style="--pp-rot:${ppJitter(p.genre_key, 7).toFixed(1)}deg">
|
const gold = p.badge === 'gold';
|
||||||
|
badgeArea = `<div class="pp-stamp pp-stamp-page${pendingSlam ? ' pp-stamp-hidden' : ' pp-tilt'}${gold ? ' pp-stamp-gold' : ''}" style="--pp-rot:${ppJitter(p.genre_key, 7).toFixed(1)}deg">
|
||||||
<span class="pp-stamp-genre">${esc(p.genre.toUpperCase())}</span>
|
<span class="pp-stamp-genre">${esc(p.genre.toUpperCase())}</span>
|
||||||
<span class="pp-stamp-tier">BRONZE</span>
|
<span class="pp-stamp-tier">${gold ? 'GOLD' : 'BRONZE'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="pp-gold-foil" aria-hidden="true">GOLD</div>
|
${gold
|
||||||
<div class="pp-gold-note">Gold rung coming — improvise it, verified.</div>
|
? '<div class="pp-gold-foil" aria-hidden="true">GOLD</div><div class="pp-gold-note">A verified improv — the comb heard it live.</div>'
|
||||||
|
: '<div class="pp-gold-note">Gold rung: improvise over this style in a Virtuoso jam — verified, not self-reported.</div>'}
|
||||||
<div class="pp-card-actions">
|
<div class="pp-card-actions">
|
||||||
<button class="career-btn career-btn-ghost" data-pp-card="save">Save card</button>
|
<button class="career-btn career-btn-ghost" data-pp-card="save">Save card</button>
|
||||||
<button class="career-btn career-btn-ghost" data-pp-card="copy">Copy card</button>
|
<button class="career-btn career-btn-ghost" data-pp-card="copy">Copy card</button>
|
||||||
@@ -724,7 +738,8 @@
|
|||||||
if (!p || !overlay) return;
|
if (!p || !overlay) return;
|
||||||
_ppBook = { inst, gkey };
|
_ppBook = { inst, gkey };
|
||||||
_ppReturnFocus = document.activeElement;
|
_ppReturnFocus = document.activeElement;
|
||||||
const pending = p.badge === 'earned' && !seenBadges()[badgeId(inst, gkey)];
|
const pending = (p.badge === 'earned' || p.badge === 'gold')
|
||||||
|
&& !seenBadges()[badgeId(inst, gkey, p.badge)];
|
||||||
overlay.innerHTML = ppBookHTML(inst, p, pending);
|
overlay.innerHTML = ppBookHTML(inst, p, pending);
|
||||||
overlay.classList.remove('hidden');
|
overlay.classList.remove('hidden');
|
||||||
const close = overlay.querySelector('.pp-book-close');
|
const close = overlay.querySelector('.pp-book-close');
|
||||||
@@ -746,7 +761,7 @@
|
|||||||
stamp.classList.add('pp-tilt'); // freshly slammed = trading card too
|
stamp.classList.add('pp-tilt'); // freshly slammed = trading card too
|
||||||
if (book) book.classList.add('pp-shake');
|
if (book) book.classList.add('pp-shake');
|
||||||
sfx('stamp');
|
sfx('stamp');
|
||||||
markBadgeSeen(inst, gkey);
|
markBadgeSeen(inst, gkey, p.badge);
|
||||||
renderPassports(); // the shelf cover gains its mini-stamp
|
renderPassports(); // the shelf cover gains its mini-stamp
|
||||||
}, 950);
|
}, 950);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -198,3 +198,52 @@ test('gig runner lifecycle: advance on ended, abandon on dead-queue stop', () =>
|
|||||||
t.onGigSongStop();
|
t.onGigSongStop();
|
||||||
assert.equal(t.getGigRun(), null);
|
assert.equal(t.getGigRun(), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a gold upgrade notifies even when the bronze moment was already seen', () => {
|
||||||
|
// Bronze seen under the legacy un-suffixed id; the badge then turns gold.
|
||||||
|
const w = load({ 'feedBack-career-badges-seen': '{"guitar/blues":1}' });
|
||||||
|
const t = w.__careerPassportTest;
|
||||||
|
const view = { instruments: { guitar: { passports: [
|
||||||
|
{ genre_key: 'blues', genre: 'Blues', badge: 'gold' }] } } };
|
||||||
|
t.detectNewBadges(view);
|
||||||
|
assert.equal(w.notifications.length, 1);
|
||||||
|
assert.match(w.notifications[0].title, /Gold/);
|
||||||
|
// Same session: no duplicate.
|
||||||
|
t.detectNewBadges(view);
|
||||||
|
assert.equal(w.notifications.length, 1);
|
||||||
|
// Gold slam seen → fresh session stays silent.
|
||||||
|
t.markBadgeSeen('guitar', 'blues', 'gold');
|
||||||
|
const w2 = load({ 'feedBack-career-badges-seen': JSON.stringify(t.seenBadges()) });
|
||||||
|
w2.__careerPassportTest.detectNewBadges(view);
|
||||||
|
assert.equal(w2.notifications.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a gold slam marks the bronze moment seen too — never both ceremonies', () => {
|
||||||
|
const w = load();
|
||||||
|
const t = w.__careerPassportTest;
|
||||||
|
t.markBadgeSeen('guitar', 'blues', 'gold');
|
||||||
|
const seen = JSON.parse(JSON.stringify(t.seenBadges()));
|
||||||
|
assert.equal(seen['guitar/blues@gold'], 1);
|
||||||
|
assert.equal(seen['guitar/blues'], 1);
|
||||||
|
// A later view where the badge reads 'earned' (e.g. gold state lost
|
||||||
|
// server-side) must not replay the bronze ceremony.
|
||||||
|
const view = { instruments: { guitar: { passports: [
|
||||||
|
{ genre_key: 'blues', genre: 'Blues', badge: 'earned' }] } } };
|
||||||
|
const w2 = load({ 'feedBack-career-badges-seen': JSON.stringify(seen) });
|
||||||
|
w2.__careerPassportTest.detectNewBadges(view);
|
||||||
|
assert.equal(w2.notifications.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('careerTotals counts gold badges on the wall', () => {
|
||||||
|
const t = load().__careerPassportTest;
|
||||||
|
t.setView({
|
||||||
|
config: { instruments: ['guitar'] },
|
||||||
|
instruments: { guitar: { committed_at: 1, gig_count: 0, passports: [
|
||||||
|
{ genre_key: 'blues', genre: 'Blues', badge: 'gold', seconds_total: 60 },
|
||||||
|
{ genre_key: 'funk', genre: 'Funk', badge: 'in_progress', seconds_total: 0 },
|
||||||
|
] } },
|
||||||
|
});
|
||||||
|
const totals = t.careerTotals();
|
||||||
|
assert.equal(totals.badges, 1);
|
||||||
|
assert.equal(totals.walls[0].earned[0].badge, 'gold');
|
||||||
|
});
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"venue": "arena",
|
||||||
|
"version": 1,
|
||||||
|
"loops": {"bored": "bored.mp4", "neutral": "neutral.mp4", "engaged": "engaged.mp4", "ecstatic": "ecstatic.mp4"},
|
||||||
|
"stingers": {"clap": "clap.mp4", "cheer": "cheer.mp4"},
|
||||||
|
"intro": {"video": "intro.mp4", "audio": "arena-ambience.mp3"},
|
||||||
|
"sfx": {"up": "sfx-up.mp3", "down": "sfx-down.mp3"}
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"venue": "club",
|
||||||
|
"version": 1,
|
||||||
|
"loops": {"bored": "bored.mp4", "neutral": "neutral.mp4", "engaged": "engaged.mp4", "ecstatic": "ecstatic.mp4"},
|
||||||
|
"stingers": {"clap": "clap.mp4", "cheer": "cheer.mp4"},
|
||||||
|
"intro": {"video": "intro.mp4", "audio": "club-ambience.mp3"},
|
||||||
|
"sfx": {"up": "sfx-up.mp3", "down": "sfx-down.mp3"}
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -878,6 +878,146 @@ function createFolderSurface(cfg) {
|
|||||||
var _dragRafId = null;
|
var _dragRafId = null;
|
||||||
var _DRAG_THRESH = 5, _DRAG_ZONE = 150, _DRAG_SPEED = 50;
|
var _DRAG_THRESH = 5, _DRAG_ZONE = 150, _DRAG_SPEED = 50;
|
||||||
|
|
||||||
|
// ── Windowed song lists ─────────────────────────────────────────────
|
||||||
|
// A song list used to render EVERY song it held. On a flat 50,944-song
|
||||||
|
// library that is one <div> with 50,938 children and ~1.3 MILLION DOM nodes
|
||||||
|
// (~25 per row) — ~4.2 GB of renderer RSS, for a screen the user may not
|
||||||
|
// even be looking at. It also poisons unrelated code: any
|
||||||
|
// `document.querySelector` miss anywhere in the app must walk that whole
|
||||||
|
// tree, which is how song_preview's per-frame menu check ended up eating
|
||||||
|
// ~50% of the renderer and dropping the app to 2.7 fps (feedBack#965).
|
||||||
|
//
|
||||||
|
// So render only what is on screen. Rows are uniform height (and grid cards
|
||||||
|
// uniform size), so the window is pure arithmetic — no per-row observers.
|
||||||
|
// Off-window rows are represented by padding on the list itself rather than
|
||||||
|
// spacer elements: a spacer <div> would become a grid ITEM in grid view and
|
||||||
|
// shift the columns, whereas padding works identically for both layouts.
|
||||||
|
var VIRTUAL_MIN = 200; // below this, render everything — no behaviour change
|
||||||
|
var VIRTUAL_BUFFER = 6; // rows kept rendered above/below the viewport
|
||||||
|
var _virtualCleanups = [];
|
||||||
|
var _virtualLists = []; // repaint fns, one per live windowed list
|
||||||
|
|
||||||
|
// Which slice of the list is on screen. Pure arithmetic — kept separate from
|
||||||
|
// the DOM so it can be tested directly (see tests/virtual_list.test.js).
|
||||||
|
//
|
||||||
|
// top : list's offset relative to the scroller viewport's top. NEGATIVE
|
||||||
|
// once the user has scrolled the list's start above the fold.
|
||||||
|
// rows : total ROWS (grid packs `perRow` songs into one row; list view is 1)
|
||||||
|
//
|
||||||
|
// Returns the song index range [start, end) to render, plus how many ROWS of
|
||||||
|
// padding stand in for the songs above and below it.
|
||||||
|
function _visibleWindow(top, viewportH, itemH, perRow, rows, total) {
|
||||||
|
if (!(itemH > 0) || !(rows > 0)) return { start: 0, end: total, padRowsTop: 0, padRowsBottom: 0 };
|
||||||
|
var firstRow = Math.max(0, Math.floor(-top / itemH) - VIRTUAL_BUFFER);
|
||||||
|
var lastRow = Math.min(rows, Math.ceil((-top + viewportH) / itemH) + VIRTUAL_BUFFER);
|
||||||
|
// Scrolled entirely past the list (either direction): keep one row alive
|
||||||
|
// rather than emptying it, so the padding math stays anchored.
|
||||||
|
if (lastRow <= firstRow) {
|
||||||
|
firstRow = Math.min(firstRow, rows - 1);
|
||||||
|
lastRow = firstRow + 1;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
start: firstRow * perRow,
|
||||||
|
end: Math.min(total, lastRow * perRow),
|
||||||
|
padRowsTop: firstRow,
|
||||||
|
padRowsBottom: Math.max(0, rows - lastRow),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function _clearVirtualLists() {
|
||||||
|
_virtualCleanups.forEach(function (fn) { try { fn(); } catch (_) {} });
|
||||||
|
_virtualCleanups = [];
|
||||||
|
_virtualLists = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill `list` with `songs`, windowed when the list is big enough to matter.
|
||||||
|
// `make(song)` builds one row/card.
|
||||||
|
function _fillSongList(list, songs, make) {
|
||||||
|
var sorted = _sortSongs(songs);
|
||||||
|
if (sorted.length <= VIRTUAL_MIN) {
|
||||||
|
sorted.forEach(function (s) { list.appendChild(make(s)); });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var scroller = _getScrollEl();
|
||||||
|
var basePadTop = parseFloat(window.getComputedStyle(list).paddingTop) || 0;
|
||||||
|
var basePadBot = parseFloat(window.getComputedStyle(list).paddingBottom) || 0;
|
||||||
|
|
||||||
|
// Measure one real row once — no hardcoded row height to drift out of
|
||||||
|
// sync with the CSS. (The list is shown before it is populated, so this
|
||||||
|
// measures a laid-out row, not a zero-height one.)
|
||||||
|
var probe = make(sorted[0]);
|
||||||
|
probe.style.visibility = 'hidden';
|
||||||
|
list.appendChild(probe);
|
||||||
|
var probeRect = probe.getBoundingClientRect();
|
||||||
|
var rowH = probeRect.height || 44;
|
||||||
|
var cardW = probeRect.width || 150;
|
||||||
|
list.removeChild(probe);
|
||||||
|
|
||||||
|
var GRID_GAP = 12; // matches the grid's `gap:12px`
|
||||||
|
var raf = 0, lastStart = -1, lastEnd = -1;
|
||||||
|
|
||||||
|
// Recomputed on EVERY paint, not captured once: a window resize changes
|
||||||
|
// the grid's column count, and therefore the row count and the height of
|
||||||
|
// the padding standing in for off-window rows. paint() runs on resize, so
|
||||||
|
// stale metrics would slice the wrong songs and mis-size the list.
|
||||||
|
function metrics() {
|
||||||
|
var perRow = 1, itemH = rowH;
|
||||||
|
if (_view === 'grid') {
|
||||||
|
perRow = Math.max(1, Math.floor((list.clientWidth + GRID_GAP) / (cardW + GRID_GAP)));
|
||||||
|
itemH = rowH + GRID_GAP;
|
||||||
|
}
|
||||||
|
return { perRow: perRow, itemH: itemH, rows: Math.ceil(sorted.length / perRow) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function paint() {
|
||||||
|
raf = 0;
|
||||||
|
// Collapsed (display:none) or detached: nothing to paint, and don't
|
||||||
|
// pay for layout on every scroll tick of a section nobody can see.
|
||||||
|
// Forget the last window so re-showing repaints from scratch against
|
||||||
|
// the new position rather than short-circuiting on a stale memo.
|
||||||
|
if (!list.isConnected || list.offsetParent === null) {
|
||||||
|
lastStart = -1; lastEnd = -1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var m = metrics();
|
||||||
|
// Where the list sits relative to the scroller's viewport.
|
||||||
|
var top = list.getBoundingClientRect().top - scroller.getBoundingClientRect().top;
|
||||||
|
var vh = scroller.clientHeight || window.innerHeight;
|
||||||
|
var w = _visibleWindow(top, vh, m.itemH, m.perRow, m.rows, sorted.length);
|
||||||
|
if (w.start === lastStart && w.end === lastEnd) return; // nothing moved
|
||||||
|
lastStart = w.start; lastEnd = w.end;
|
||||||
|
|
||||||
|
var frag = document.createDocumentFragment();
|
||||||
|
for (var i = w.start; i < w.end; i++) frag.appendChild(make(sorted[i]));
|
||||||
|
list.textContent = '';
|
||||||
|
list.style.paddingTop = (basePadTop + w.padRowsTop * m.itemH) + 'px';
|
||||||
|
list.style.paddingBottom = (basePadBot + w.padRowsBottom * m.itemH) + 'px';
|
||||||
|
list.appendChild(frag);
|
||||||
|
}
|
||||||
|
function schedule() { if (!raf) raf = window.requestAnimationFrame(paint); }
|
||||||
|
|
||||||
|
scroller.addEventListener('scroll', schedule, { passive: true });
|
||||||
|
window.addEventListener('resize', schedule);
|
||||||
|
// Expanding or collapsing ANY section moves every list below it. Those
|
||||||
|
// lists' windows are computed from their position, so they must repaint
|
||||||
|
// too — otherwise they keep the window from their old position and show
|
||||||
|
// blank padding where songs should be until the user happens to scroll.
|
||||||
|
_virtualLists.push(schedule);
|
||||||
|
_virtualCleanups.push(function () {
|
||||||
|
scroller.removeEventListener('scroll', schedule);
|
||||||
|
window.removeEventListener('resize', schedule);
|
||||||
|
if (raf) window.cancelAnimationFrame(raf);
|
||||||
|
});
|
||||||
|
paint();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-window every live list — call after anything that can move them
|
||||||
|
// vertically (a folder expanding/collapsing, a section being shown).
|
||||||
|
function _repaintVirtualLists() {
|
||||||
|
_virtualLists.forEach(function (fn) { try { fn(); } catch (_) {} });
|
||||||
|
}
|
||||||
|
|
||||||
function _getScrollEl() {
|
function _getScrollEl() {
|
||||||
var el = _treeEl();
|
var el = _treeEl();
|
||||||
while (el && el !== document.documentElement) {
|
while (el && el !== document.documentElement) {
|
||||||
@@ -1159,8 +1299,8 @@ function createFolderSurface(cfg) {
|
|||||||
|
|
||||||
var _listPopulated = open;
|
var _listPopulated = open;
|
||||||
function _populateList() {
|
function _populateList() {
|
||||||
_sortSongs(folder.songs).forEach(function (s) {
|
_fillSongList(list, folder.songs, function (s) {
|
||||||
list.appendChild(_view === 'grid' ? _songCard(s, folder.path) : _songRow(s, folder.path));
|
return _view === 'grid' ? _songCard(s, folder.path) : _songRow(s, folder.path);
|
||||||
});
|
});
|
||||||
(folder.children || []).forEach(function (child) {
|
(folder.children || []).forEach(function (child) {
|
||||||
childrenWrap.appendChild(_folderSection(child, depth + 1));
|
childrenWrap.appendChild(_folderSection(child, depth + 1));
|
||||||
@@ -1195,12 +1335,18 @@ function createFolderSurface(cfg) {
|
|||||||
hdr.addEventListener('click', function () {
|
hdr.addEventListener('click', function () {
|
||||||
if (_query()) return;
|
if (_query()) return;
|
||||||
var nowOpen = content.style.display === 'none';
|
var nowOpen = content.style.display === 'none';
|
||||||
if (nowOpen && !_listPopulated) { _populateList(); _listPopulated = true; }
|
// Show BEFORE populating: a windowed list measures a real row and the
|
||||||
|
// scroller viewport, and both are zero while display:none.
|
||||||
content.style.display = nowOpen ? '' : 'none';
|
content.style.display = nowOpen ? '' : 'none';
|
||||||
|
if (nowOpen && !_listPopulated) { _populateList(); _listPopulated = true; }
|
||||||
chev.style.transform = nowOpen ? 'rotate(90deg)' : '';
|
chev.style.transform = nowOpen ? 'rotate(90deg)' : '';
|
||||||
if (nowOpen) _openFolders.add(folder.path);
|
if (nowOpen) _openFolders.add(folder.path);
|
||||||
else _openFolders.delete(folder.path);
|
else _openFolders.delete(folder.path);
|
||||||
_storeJSON('open', [..._openFolders]);
|
_storeJSON('open', [..._openFolders]);
|
||||||
|
// This toggle moved everything below it — re-window the other lists,
|
||||||
|
// and re-window THIS one if it was already populated (its saved
|
||||||
|
// window was computed at its old position).
|
||||||
|
_repaintVirtualLists();
|
||||||
});
|
});
|
||||||
|
|
||||||
wrap.appendChild(hdr); wrap.appendChild(content);
|
wrap.appendChild(hdr); wrap.appendChild(content);
|
||||||
@@ -1245,8 +1391,8 @@ function createFolderSurface(cfg) {
|
|||||||
}
|
}
|
||||||
var _populated = _unsortedOpen;
|
var _populated = _unsortedOpen;
|
||||||
function _populate() {
|
function _populate() {
|
||||||
_sortSongs(songs).forEach(function (s) {
|
_fillSongList(list, songs, function (s) {
|
||||||
list.appendChild(_view === 'grid' ? _songCard(s, '') : _songRow(s, ''));
|
return _view === 'grid' ? _songCard(s, '') : _songRow(s, '');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (_unsortedOpen) { _populate(); } else { list.style.display = 'none'; }
|
if (_unsortedOpen) { _populate(); } else { list.style.display = 'none'; }
|
||||||
@@ -1255,10 +1401,12 @@ function createFolderSurface(cfg) {
|
|||||||
hdr.addEventListener('click', function () {
|
hdr.addEventListener('click', function () {
|
||||||
if (_query()) return;
|
if (_query()) return;
|
||||||
_unsortedOpen = list.style.display === 'none';
|
_unsortedOpen = list.style.display === 'none';
|
||||||
if (_unsortedOpen && !_populated) { _populate(); _populated = true; }
|
// Show BEFORE populating — see the folder toggle above.
|
||||||
list.style.display = _unsortedOpen ? (_view === 'grid' ? 'grid' : '') : 'none';
|
list.style.display = _unsortedOpen ? (_view === 'grid' ? 'grid' : '') : 'none';
|
||||||
|
if (_unsortedOpen && !_populated) { _populate(); _populated = true; }
|
||||||
chev.style.transform = _unsortedOpen ? 'rotate(90deg)' : '';
|
chev.style.transform = _unsortedOpen ? 'rotate(90deg)' : '';
|
||||||
_store(cfg.unsortedKey, String(_unsortedOpen));
|
_store(cfg.unsortedKey, String(_unsortedOpen));
|
||||||
|
_repaintVirtualLists(); // this toggle moved every list below it
|
||||||
});
|
});
|
||||||
|
|
||||||
wrap.appendChild(hdr); wrap.appendChild(list);
|
wrap.appendChild(hdr); wrap.appendChild(list);
|
||||||
@@ -1340,6 +1488,10 @@ function createFolderSurface(cfg) {
|
|||||||
// ── Render ──────────────────────────────────────────────────────────
|
// ── Render ──────────────────────────────────────────────────────────
|
||||||
function _render() {
|
function _render() {
|
||||||
_hoveredFolder = null; // DOM is rebuilt; discard any stale reference
|
_hoveredFolder = null; // DOM is rebuilt; discard any stale reference
|
||||||
|
// Drop the scroll listeners of the previous render's windowed lists —
|
||||||
|
// their `list` nodes are about to be detached, and a surviving listener
|
||||||
|
// would keep painting into orphaned DOM (and leak on every re-render).
|
||||||
|
_clearVirtualLists();
|
||||||
var treeEl = _treeEl();
|
var treeEl = _treeEl();
|
||||||
if (!treeEl) return;
|
if (!treeEl) return;
|
||||||
var data = _filtered();
|
var data = _filtered();
|
||||||
@@ -1451,6 +1603,7 @@ function createFolderSurface(cfg) {
|
|||||||
|
|
||||||
// ── Unload (lib surface) ────────────────────────────────────────────
|
// ── Unload (lib surface) ────────────────────────────────────────────
|
||||||
function _unload() {
|
function _unload() {
|
||||||
|
_clearVirtualLists(); // don't leave scroll listeners behind on teardown
|
||||||
if (!cfg.searchInputId) return;
|
if (!cfg.searchInputId) return;
|
||||||
var el = _el(cfg.searchInputId);
|
var el = _el(cfg.searchInputId);
|
||||||
if (el) el.style.maxWidth = '';
|
if (el) el.style.maxWidth = '';
|
||||||
@@ -1554,6 +1707,8 @@ function createFolderSurface(cfg) {
|
|||||||
init: _init,
|
init: _init,
|
||||||
onScreenChanged: _onScreenChanged,
|
onScreenChanged: _onScreenChanged,
|
||||||
render: _render,
|
render: _render,
|
||||||
|
// Pure window arithmetic, exposed for tests (no DOM needed).
|
||||||
|
__test: { visibleWindow: _visibleWindow, VIRTUAL_MIN: VIRTUAL_MIN, VIRTUAL_BUFFER: VIRTUAL_BUFFER },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1656,6 +1811,7 @@ if (!window.__folderLibraryLib) {
|
|||||||
window.folderLibrary = {
|
window.folderLibrary = {
|
||||||
load: function (force) { return _lib.load(force); },
|
load: function (force) { return _lib.load(force); },
|
||||||
unload: function () { _lib.unload(); },
|
unload: function () { _lib.unload(); },
|
||||||
|
__test: _lib.__test,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Auto-load if folder view was already active when this script was injected.
|
// Auto-load if folder view was already active when this script was injected.
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
// Windowed song lists (feedBack#965).
|
||||||
|
//
|
||||||
|
// A song list used to render EVERY song. On a flat 50,944-song library that is
|
||||||
|
// one div with 50,938 children and ~1.3 MILLION DOM nodes (~25 per row) —
|
||||||
|
// ~4.2 GB of renderer RSS, for a screen the user may not even be looking at. It
|
||||||
|
// also poisoned unrelated code: any `document.querySelector` miss anywhere in
|
||||||
|
// the app had to walk that whole tree.
|
||||||
|
//
|
||||||
|
// _visibleWindow is the arithmetic that decides which slice is on screen. If it
|
||||||
|
// is wrong the list silently shows the wrong songs, or scrolls to the wrong
|
||||||
|
// place, so it is tested directly — the DOM glue around it is not the risky bit.
|
||||||
|
|
||||||
|
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');
|
||||||
|
|
||||||
|
function load() {
|
||||||
|
const window = {
|
||||||
|
console,
|
||||||
|
document: {
|
||||||
|
readyState: 'complete',
|
||||||
|
addEventListener() {},
|
||||||
|
getElementById() { return null; },
|
||||||
|
querySelector() { return null; },
|
||||||
|
querySelectorAll() { return []; },
|
||||||
|
createElement() { return { style: {}, classList: { add() {}, remove() {}, contains() { return false; } }, addEventListener() {}, appendChild() {} }; },
|
||||||
|
},
|
||||||
|
addEventListener() {},
|
||||||
|
localStorage: { getItem() { return null; }, setItem() {} },
|
||||||
|
performance: { now: () => 0 },
|
||||||
|
setInterval() { return 0; },
|
||||||
|
clearInterval() {},
|
||||||
|
requestAnimationFrame() { return 0; },
|
||||||
|
cancelAnimationFrame() {},
|
||||||
|
getComputedStyle() { return { overflowY: 'visible', paddingTop: '0px', paddingBottom: '0px' }; },
|
||||||
|
innerHeight: 800,
|
||||||
|
};
|
||||||
|
window.window = window;
|
||||||
|
window.globalThis = window;
|
||||||
|
const ctx = vm.createContext(window);
|
||||||
|
vm.runInContext(fs.readFileSync(path.join(__dirname, '..', 'screen.js'), 'utf8'), ctx, { filename: 'screen.js' });
|
||||||
|
assert.ok(window.folderLibrary && window.folderLibrary.__test, 'plugin must expose __test');
|
||||||
|
return window.folderLibrary.__test;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { visibleWindow, VIRTUAL_BUFFER, VIRTUAL_MIN } = load();
|
||||||
|
|
||||||
|
// A flat 50k library in list view: 1 song per row, 44px rows, 800px viewport.
|
||||||
|
const ROW = 44;
|
||||||
|
const VH = 800;
|
||||||
|
const TOTAL = 50938;
|
||||||
|
|
||||||
|
test('the whole point: a 50k list renders a bounded window, not 50k rows', () => {
|
||||||
|
const w = visibleWindow(0, VH, ROW, 1, TOTAL, TOTAL);
|
||||||
|
const rendered = w.end - w.start;
|
||||||
|
assert.ok(rendered < 60, `expected a small window, got ${rendered} rows`);
|
||||||
|
// ~18 rows fit in 800px, plus buffer above and below.
|
||||||
|
assert.ok(rendered >= Math.ceil(VH / ROW), 'must at least fill the viewport');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('at the top: starts at 0, all remaining rows are bottom padding', () => {
|
||||||
|
const w = visibleWindow(0, VH, ROW, 1, TOTAL, TOTAL);
|
||||||
|
assert.equal(w.start, 0);
|
||||||
|
assert.equal(w.padRowsTop, 0);
|
||||||
|
assert.equal(w.padRowsBottom, TOTAL - w.end);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('scrolled into the middle: window tracks the scroll, padding adds up', () => {
|
||||||
|
const scrolled = 10000 * ROW; // row 10,000 at the fold
|
||||||
|
const w = visibleWindow(-scrolled, VH, ROW, 1, TOTAL, TOTAL);
|
||||||
|
assert.equal(w.start, (10000 - VIRTUAL_BUFFER) * 1);
|
||||||
|
assert.ok(w.end > w.start);
|
||||||
|
// The invariant that keeps the scrollbar honest: padding rows + rendered
|
||||||
|
// rows must account for every song, or the list changes height as you scroll.
|
||||||
|
assert.equal(w.padRowsTop + (w.end - w.start) + w.padRowsBottom, TOTAL);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('at the very bottom: no bottom padding, end lands on the last song', () => {
|
||||||
|
const rows = TOTAL;
|
||||||
|
const scrolled = rows * ROW - VH; // scrolled to the end
|
||||||
|
const w = visibleWindow(-scrolled, VH, ROW, 1, rows, TOTAL);
|
||||||
|
assert.equal(w.end, TOTAL);
|
||||||
|
assert.equal(w.padRowsBottom, 0);
|
||||||
|
assert.equal(w.padRowsTop + (w.end - w.start), TOTAL);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('grid view: perRow songs collapse into one row', () => {
|
||||||
|
const perRow = 6;
|
||||||
|
const rows = Math.ceil(TOTAL / perRow);
|
||||||
|
const w = visibleWindow(0, VH, 190, perRow, rows, TOTAL);
|
||||||
|
assert.equal(w.start, 0);
|
||||||
|
assert.equal(w.start % perRow, 0, 'a window must start on a row boundary');
|
||||||
|
assert.ok(w.end <= TOTAL);
|
||||||
|
assert.ok((w.end - w.start) < 200, 'grid window must stay bounded');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('scrolled far past the list: keeps one row, never a negative window', () => {
|
||||||
|
const w = visibleWindow(-99999999, VH, ROW, 1, TOTAL, TOTAL);
|
||||||
|
assert.ok(w.end > w.start, 'window must never invert');
|
||||||
|
assert.ok(w.start >= 0 && w.end <= TOTAL);
|
||||||
|
assert.equal(w.padRowsTop + (w.end - w.start) + w.padRowsBottom, TOTAL);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('list not yet scrolled to (below the fold): still yields a valid window', () => {
|
||||||
|
const w = visibleWindow(5000, VH, ROW, 1, TOTAL, TOTAL); // list starts below viewport
|
||||||
|
assert.equal(w.start, 0);
|
||||||
|
assert.ok(w.end > 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('degenerate inputs fall back to rendering everything, never to a broken window', () => {
|
||||||
|
// Measured height of 0 (e.g. list still display:none) must not divide by zero
|
||||||
|
// and must not silently render an empty list.
|
||||||
|
const w = visibleWindow(0, VH, 0, 1, TOTAL, TOTAL);
|
||||||
|
assert.equal(w.start, 0);
|
||||||
|
assert.equal(w.end, TOTAL);
|
||||||
|
assert.equal(w.padRowsTop, 0);
|
||||||
|
assert.equal(w.padRowsBottom, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('small lists are below the virtualization threshold', () => {
|
||||||
|
assert.ok(VIRTUAL_MIN >= 100, 'threshold must be high enough that normal folders are untouched');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── the grid must be re-measured when the window resizes (CodeRabbit, #967) ──
|
||||||
|
// perRow and rows were originally captured once at fill time. paint() also runs
|
||||||
|
// on resize, so a narrower/wider window changed the column count while the
|
||||||
|
// window maths still used the OLD one — slicing the wrong songs and mis-sizing
|
||||||
|
// the padding. These pin that the geometry is a function of perRow, so a stale
|
||||||
|
// perRow cannot silently survive.
|
||||||
|
|
||||||
|
test('resizing the grid to fewer columns re-windows against the new row count', () => {
|
||||||
|
const total = 10000;
|
||||||
|
const wide = visibleWindow(0, VH, 190, 6, Math.ceil(total / 6), total);
|
||||||
|
const narrow = visibleWindow(0, VH, 190, 3, Math.ceil(total / 3), total);
|
||||||
|
|
||||||
|
// Same viewport, half the columns -> about half as many songs on screen.
|
||||||
|
assert.ok(narrow.end < wide.end, 'fewer columns must render fewer songs per screen');
|
||||||
|
// ...and the total must still add up, or the scrollbar lies after a resize.
|
||||||
|
for (const [w, perRow] of [[wide, 6], [narrow, 3]]) {
|
||||||
|
const rows = Math.ceil(total / perRow);
|
||||||
|
assert.equal(w.padRowsTop + Math.ceil((w.end - w.start) / perRow) + w.padRowsBottom, rows,
|
||||||
|
`rows must account for every song at perRow=${perRow}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a stale perRow would break the total-height invariant (the bug)', () => {
|
||||||
|
const total = 10000;
|
||||||
|
// Grid re-laid out to 3 columns, but windowed with the OLD perRow of 6:
|
||||||
|
// the row count no longer matches the geometry, and the padding is wrong.
|
||||||
|
const stalePerRow = 6, actualRows = Math.ceil(total / 3);
|
||||||
|
const bad = visibleWindow(0, VH, 190, stalePerRow, actualRows, total);
|
||||||
|
const accounted = bad.padRowsTop + Math.ceil((bad.end - bad.start) / 3) + bad.padRowsBottom;
|
||||||
|
assert.notEqual(accounted, actualRows,
|
||||||
|
'this asserts the FAILURE mode: mismatched perRow/rows must not silently look correct — ' +
|
||||||
|
'metrics() recomputes both together on every paint so this cannot happen in practice');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('scrolled grid window always starts on a row boundary', () => {
|
||||||
|
const total = 10000, perRow = 4;
|
||||||
|
const rows = Math.ceil(total / perRow);
|
||||||
|
const w = visibleWindow(-5000, VH, 190, perRow, rows, total);
|
||||||
|
assert.equal(w.start % perRow, 0, 'a partial row would shift every card in the grid');
|
||||||
|
});
|
||||||
@@ -51,6 +51,56 @@ test('bar venue pack ships with intro media in the plugin checkout', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('arena venue pack ships with full media in the plugin checkout', () => {
|
||||||
|
const packDir = path.join(PLUGIN_DIR, 'venue-packs', 'arena');
|
||||||
|
const manifest = JSON.parse(fs.readFileSync(path.join(packDir, 'manifest.json'), 'utf8'));
|
||||||
|
assert.equal(manifest.venue, 'arena');
|
||||||
|
assert.deepEqual(manifest.loops, {
|
||||||
|
bored: 'bored.mp4', neutral: 'neutral.mp4',
|
||||||
|
engaged: 'engaged.mp4', ecstatic: 'ecstatic.mp4',
|
||||||
|
});
|
||||||
|
assert.deepEqual(manifest.stingers, { clap: 'clap.mp4', cheer: 'cheer.mp4' });
|
||||||
|
assert.deepEqual(manifest.sfx, { up: 'sfx-up.mp3', down: 'sfx-down.mp3' });
|
||||||
|
assert.equal(manifest.intro.video, 'intro.mp4');
|
||||||
|
assert.equal(manifest.intro.audio, 'arena-ambience.mp3');
|
||||||
|
for (const f of [
|
||||||
|
...Object.values(manifest.loops),
|
||||||
|
...Object.values(manifest.stingers),
|
||||||
|
manifest.intro.video,
|
||||||
|
manifest.intro.audio,
|
||||||
|
manifest.sfx.up,
|
||||||
|
manifest.sfx.down,
|
||||||
|
]) {
|
||||||
|
const stat = fs.statSync(path.join(packDir, f));
|
||||||
|
assert.ok(stat.size > 0, `${f} must be present`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('club venue pack ships with full media in the plugin checkout', () => {
|
||||||
|
const packDir = path.join(PLUGIN_DIR, 'venue-packs', 'club');
|
||||||
|
const manifest = JSON.parse(fs.readFileSync(path.join(packDir, 'manifest.json'), 'utf8'));
|
||||||
|
assert.equal(manifest.venue, 'club');
|
||||||
|
assert.deepEqual(manifest.loops, {
|
||||||
|
bored: 'bored.mp4', neutral: 'neutral.mp4',
|
||||||
|
engaged: 'engaged.mp4', ecstatic: 'ecstatic.mp4',
|
||||||
|
});
|
||||||
|
assert.deepEqual(manifest.stingers, { clap: 'clap.mp4', cheer: 'cheer.mp4' });
|
||||||
|
assert.deepEqual(manifest.sfx, { up: 'sfx-up.mp3', down: 'sfx-down.mp3' });
|
||||||
|
assert.equal(manifest.intro.video, 'intro.mp4');
|
||||||
|
assert.equal(manifest.intro.audio, 'club-ambience.mp3');
|
||||||
|
for (const f of [
|
||||||
|
...Object.values(manifest.loops),
|
||||||
|
...Object.values(manifest.stingers),
|
||||||
|
manifest.intro.video,
|
||||||
|
manifest.intro.audio,
|
||||||
|
manifest.sfx.up,
|
||||||
|
manifest.sfx.down,
|
||||||
|
]) {
|
||||||
|
const stat = fs.statSync(path.join(packDir, f));
|
||||||
|
assert.ok(stat.size > 0, `${f} must be present`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('shell promotes the career plugin into the sidebar', () => {
|
test('shell promotes the career plugin into the sidebar', () => {
|
||||||
const src = fs.readFileSync(SHELL_JS, 'utf8');
|
const src = fs.readFileSync(SHELL_JS, 'utf8');
|
||||||
assert.match(src, /key: 'career',\s*screen: 'plugin-career'/);
|
assert.match(src, /key: 'career',\s*screen: 'plugin-career'/);
|
||||||
|
|||||||
@@ -371,3 +371,77 @@ def test_gig_propose_backfill_offset_survives_stakes(client, meta_db):
|
|||||||
files = [s["filename"] for s in res.json()["songs"]]
|
files = [s["filename"] for s in res.json()["songs"]]
|
||||||
assert len(files) == 5 and len(set(files)) == 5
|
assert len(files) == 5 and len(set(files)) == 5
|
||||||
assert "near.feedpak" in files
|
assert "near.feedpak" in files
|
||||||
|
|
||||||
|
|
||||||
|
# ── Gold rung ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_gold_upgrades_bronze_via_family_style_artifact(client, meta_db):
|
||||||
|
# Bronze earned on 'death metal' (family: metal); a metal gold artifact
|
||||||
|
# from the jam verifier upgrades it — bronze-only stays 'earned' elsewhere.
|
||||||
|
for i in range(5):
|
||||||
|
meta_db.add(f"dm{i}.feedpak", 0, 0.9, genre="Death Metal", arrangements=LEAD)
|
||||||
|
career_routes._state["passports_content"]["genres"]["metal"] = {} # no drill gate for this test
|
||||||
|
_open(client, "guitar", "Death Metal")
|
||||||
|
client.post("/api/plugins/career/drill-state", json={"byNode": {}})
|
||||||
|
assert _passport(client, "guitar", "death metal")["badge"] == "earned"
|
||||||
|
client.post("/api/plugins/career/drill-state", json={
|
||||||
|
"byNode": {}, "goldImprov": {"metal": {"at": 1, "verifier": "comb", "inKeyPct": 0.9}}})
|
||||||
|
assert _passport(client, "guitar", "death metal")["badge"] == "gold"
|
||||||
|
|
||||||
|
|
||||||
|
def test_gold_without_bronze_stays_in_progress(client, meta_db):
|
||||||
|
meta_db.add("one.feedpak", 0, 0.9, genre="Soul", arrangements=LEAD)
|
||||||
|
_open(client, "guitar", "Soul")
|
||||||
|
client.post("/api/plugins/career/drill-state", json={
|
||||||
|
"byNode": {}, "goldImprov": {"soul": {"at": 1, "verifier": "comb"}}})
|
||||||
|
assert _passport(client, "guitar", "soul")["badge"] == "in_progress"
|
||||||
|
|
||||||
|
|
||||||
|
def test_gold_merge_is_gained_only(client, meta_db):
|
||||||
|
for i in range(5):
|
||||||
|
meta_db.add(f"s{i}.feedpak", 0, 0.9, genre="Soul", arrangements=LEAD)
|
||||||
|
_open(client, "guitar", "Soul")
|
||||||
|
client.post("/api/plugins/career/drill-state", json={
|
||||||
|
"byNode": {}, "goldImprov": {"soul": {"at": 1, "verifier": "comb"}}})
|
||||||
|
assert _passport(client, "guitar", "soul")["badge"] == "gold"
|
||||||
|
# A stale relay without the artifact never un-mints.
|
||||||
|
client.post("/api/plugins/career/drill-state", json={"byNode": {}})
|
||||||
|
assert _passport(client, "guitar", "soul")["badge"] == "gold"
|
||||||
|
# And a different artifact for the same style never overwrites the first —
|
||||||
|
# asserted against the PERSISTED snapshot (the view doesn't expose
|
||||||
|
# artifact contents), so a last-write-wins regression can't stay green.
|
||||||
|
client.post("/api/plugins/career/drill-state", json={
|
||||||
|
"byNode": {}, "goldImprov": {"soul": {"at": 999, "verifier": "yin"}}})
|
||||||
|
_, _, gold = career_routes._drill_by_node()
|
||||||
|
assert gold["soul"] == {"at": 1, "verifier": "comb"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_gold_matches_raw_style_id_through_family(client, meta_db):
|
||||||
|
# Virtuoso mints under raw STYLE_PALETTES ids ('punk', not 'rock'): a
|
||||||
|
# 'punk rock' passport (family rock) must go gold from a 'punk' artifact.
|
||||||
|
for i in range(5):
|
||||||
|
meta_db.add(f"pk{i}.feedpak", 0, 0.9, genre="Punk Rock", arrangements=LEAD)
|
||||||
|
career_routes._state["passports_content"]["genres"]["rock"] = {} # no drill gate
|
||||||
|
_open(client, "guitar", "Punk Rock")
|
||||||
|
client.post("/api/plugins/career/drill-state", json={
|
||||||
|
"byNode": {}, "goldImprov": {"punk": {"at": 1, "verifier": "comb"}}})
|
||||||
|
assert _passport(client, "guitar", "punk rock")["badge"] == "gold"
|
||||||
|
|
||||||
|
|
||||||
|
def test_gold_intake_rejects_junk(client, meta_db):
|
||||||
|
# A non-dict goldImprov is a relay bug: loud 400, never a silent drop.
|
||||||
|
res = client.post("/api/plugins/career/drill-state",
|
||||||
|
json={"byNode": {}, "goldImprov": ["metal"]})
|
||||||
|
assert res.status_code == 400
|
||||||
|
# Evidence-free artifacts (no verifier) never mint.
|
||||||
|
for i in range(5):
|
||||||
|
meta_db.add(f"j{i}.feedpak", 0, 0.9, genre="Soul", arrangements=LEAD)
|
||||||
|
_open(client, "guitar", "Soul")
|
||||||
|
client.post("/api/plugins/career/drill-state", json={
|
||||||
|
"byNode": {}, "goldImprov": {"soul": {}}})
|
||||||
|
assert _passport(client, "guitar", "soul")["badge"] == "earned"
|
||||||
|
# An oversized goldImprov is bounded BEFORE the merge, like byNode.
|
||||||
|
blob = {f"s{i}": {"verifier": "comb", "pad": "x" * 4096} for i in range(200)}
|
||||||
|
res = client.post("/api/plugins/career/drill-state",
|
||||||
|
json={"byNode": {}, "goldImprov": blob})
|
||||||
|
assert res.status_code == 413
|
||||||
|
|||||||
@@ -121,12 +121,17 @@ def test_pack_file_serving_and_traversal_guard(client):
|
|||||||
|
|
||||||
|
|
||||||
def test_state_reports_installed_and_delete_removes(client):
|
def test_state_reports_installed_and_delete_removes(client):
|
||||||
|
# All three venues now ship bundled, so deleting the downloaded copy
|
||||||
|
# falls back to the bundled pack: installed stays True by design
|
||||||
|
# (downloaded packs override bundled ones, never replace them).
|
||||||
_install_fake_pack("club")
|
_install_fake_pack("club")
|
||||||
state = client.get("/api/plugins/career/state").json()
|
state = client.get("/api/plugins/career/state").json()
|
||||||
assert {v["id"]: v["installed"] for v in state["venues"]}["club"] is True
|
assert {v["id"]: v["installed"] for v in state["venues"]}["club"] is True
|
||||||
assert client.delete("/api/plugins/career/packs/club").status_code == 200
|
assert client.delete("/api/plugins/career/packs/club").status_code == 200
|
||||||
state = client.get("/api/plugins/career/state").json()
|
state = client.get("/api/plugins/career/state").json()
|
||||||
assert {v["id"]: v["installed"] for v in state["venues"]}["club"] is False
|
assert {v["id"]: v["installed"] for v in state["venues"]}["club"] is True
|
||||||
|
# the downloaded override itself is gone
|
||||||
|
assert not (career_routes._venue_dir("club") / "manifest.json").exists()
|
||||||
|
|
||||||
|
|
||||||
def test_download_worker_end_to_end(client, tmp_path):
|
def test_download_worker_end_to_end(client, tmp_path):
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"""The library root must be resolved ONCE, not on every path check.
|
||||||
|
|
||||||
|
`Path.resolve()` lstats every component of a path. `_resolve_dlc_path` and
|
||||||
|
`safe_join` run once per song / art fetch / scanned row, and both used to
|
||||||
|
re-resolve their root every single call.
|
||||||
|
|
||||||
|
Measured on a real 50,944-song library sitting on an NTFS-3G (FUSE) mount:
|
||||||
|
~23,500 stat/lstat calls per second, re-walking the same three parent
|
||||||
|
directories, pinning a core of the server. Every stat crosses into userspace on
|
||||||
|
FUSE, so the constant re-resolution — not the work itself — was the cost.
|
||||||
|
|
||||||
|
These tests pin the fix (root resolved once) AND that caching it did not weaken
|
||||||
|
containment, which is the thing that matters: `safe_join` is the zip-slip guard.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from dlc_paths import _resolve_dlc_path
|
||||||
|
from safepath import resolved_root, safe_join
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clear_cache():
|
||||||
|
resolved_root.cache_clear()
|
||||||
|
yield
|
||||||
|
resolved_root.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
|
def test_dlc_root_is_resolved_once_across_many_lookups(tmp_path):
|
||||||
|
"""The regression: 500 lookups must not mean 500 root resolutions."""
|
||||||
|
(tmp_path / "a.feedpak").write_bytes(b"x")
|
||||||
|
|
||||||
|
for i in range(500):
|
||||||
|
assert _resolve_dlc_path(tmp_path, f"song{i}.feedpak") is not None
|
||||||
|
|
||||||
|
info = resolved_root.cache_info()
|
||||||
|
assert info.misses == 1, (
|
||||||
|
f"the library root must be resolved ONCE, not per call "
|
||||||
|
f"(got {info.misses} resolutions for 500 lookups)"
|
||||||
|
)
|
||||||
|
assert info.hits == 499
|
||||||
|
|
||||||
|
|
||||||
|
def test_safe_join_resolves_its_root_once_too(tmp_path):
|
||||||
|
for i in range(200):
|
||||||
|
assert safe_join(tmp_path, f"asset{i}.png") is not None
|
||||||
|
assert resolved_root.cache_info().misses == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_different_root_is_a_different_cache_entry(tmp_path):
|
||||||
|
other = tmp_path / "other"
|
||||||
|
other.mkdir()
|
||||||
|
_resolve_dlc_path(tmp_path, "a.feedpak")
|
||||||
|
_resolve_dlc_path(other, "a.feedpak")
|
||||||
|
assert resolved_root.cache_info().misses == 2, "switching library dir must re-resolve"
|
||||||
|
|
||||||
|
|
||||||
|
# ── containment must be unchanged (the part that matters) ───────────────────
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("evil", [
|
||||||
|
"../etc/passwd",
|
||||||
|
"..\\etc\\passwd",
|
||||||
|
"a/../../etc/passwd",
|
||||||
|
"/etc/passwd",
|
||||||
|
"C:/Windows/system.ini",
|
||||||
|
"",
|
||||||
|
])
|
||||||
|
def test_resolve_dlc_path_still_rejects_escapes(tmp_path, evil):
|
||||||
|
assert _resolve_dlc_path(tmp_path, evil) is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("evil", [
|
||||||
|
"../outside.txt",
|
||||||
|
"..\\outside.txt",
|
||||||
|
"a/../../outside.txt",
|
||||||
|
"",
|
||||||
|
])
|
||||||
|
def test_safe_join_still_rejects_escapes(tmp_path, evil):
|
||||||
|
assert safe_join(tmp_path, evil) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_safe_join_still_follows_symlinks_out(tmp_path):
|
||||||
|
"""safe_join's candidate resolution is the zip-slip defence and is NOT cached:
|
||||||
|
a symlink pointing outside the root must still be refused."""
|
||||||
|
outside = tmp_path.parent / "outside_secret"
|
||||||
|
outside.mkdir(exist_ok=True)
|
||||||
|
(outside / "secret.txt").write_text("x")
|
||||||
|
|
||||||
|
root = tmp_path / "root"
|
||||||
|
root.mkdir()
|
||||||
|
(root / "escape").symlink_to(outside)
|
||||||
|
|
||||||
|
assert safe_join(root, "escape/secret.txt") is None, (
|
||||||
|
"a symlink escaping the root must still be rejected — caching the ROOT "
|
||||||
|
"must not disable resolution of the CANDIDATE"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_in_library_paths_still_resolve(tmp_path):
|
||||||
|
assert _resolve_dlc_path(tmp_path, "sub/song.feedpak") == tmp_path / "sub" / "song.feedpak"
|
||||||
|
assert safe_join(tmp_path, "art/cover.png") == (tmp_path / "art" / "cover.png").resolve()
|
||||||
Reference in New Issue
Block a user