mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-11 10:14:29 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9c3565a86 |
+1
-2
@@ -26,8 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
next root-level module can't ship broken.
|
||||
|
||||
### Added
|
||||
- **Perf harness now measures 2D-highway frame time (R3c gate).** `scripts/perf-baseline.mjs` gains a `--song` mode that reports per-frame draw-cost p50/p95/p99 (draw-tagged via `highway.addDrawHook`), the metric that gates the `highway.js` split. Maintainer/CI-only; baseline recorded in `docs/perf-baseline.md`.
|
||||
- **`routers/` — extracting `server.py`'s route layer, cheapest-first (R3).** Each PR moves a cohesive route group into a `fastapi.APIRouter` under `lib/routers/`, mounted with `app.include_router(...)` at its original site (FastAPI matches in registration order; the full route table stays byte-identical). Bodies are verbatim — only the decorator receiver (`@app` → `@router`) and singleton reads (`meta_db` → `appstate.meta_db`, resolved at call time) change. So far: `audio_effects` (5), `artist_aliases` (5), `loops` (3), `playlists` (12 + covers), `ws_highway` (the 902-line highway chart WebSocket), `chart` (split/unsplit/work/fileinfo — unblocked by the DLC-path substrate). The DLC library-path resolution (`_get_dlc_dir`, pure `_resolve_dlc_path`) moved to `lib/dlc_paths.py`, reading paths through the seam; `config_dir`/`dlc_dir`/`dlc_dir_env` now ride the `appstate` seam (env-derived, so the pop-and-reimport fixtures reconfigure it for free), and the shared request-field sanitizer `_clean_str` moved to `lib/reqfields.py`. The next cut is picked by a dependency-closure scan that ranks groups by how many `monkeypatch.setattr(server, …)` targets they'd drag along.
|
||||
- **`routers/` — extracting `server.py`'s route layer, cheapest-first (R3).** Each PR moves a cohesive route group into a `fastapi.APIRouter` under `lib/routers/`, mounted with `app.include_router(...)` at its original site (FastAPI matches in registration order; the full route table stays byte-identical). Bodies are verbatim — only the decorator receiver (`@app` → `@router`) and singleton reads (`meta_db` → `appstate.meta_db`, resolved at call time) change. So far: `audio_effects` (5), `artist_aliases` (5), `loops` (3 — saved A/B practice regions). The next cut is picked by a dependency-closure scan that ranks groups by how many `monkeypatch.setattr(server, …)` targets they'd drag along.
|
||||
- **`routers/` — the first extracted route module (R3).** The five audio-effects mapping
|
||||
endpoints move out of `server.py` into `lib/routers/audio_effects.py` as a
|
||||
`fastapi.APIRouter`, mounted with `app.include_router(...)` **at the point in the file
|
||||
|
||||
@@ -37,31 +37,6 @@ the hooks, they just need real songs in `DLC_DIR`.
|
||||
|
||||
## Results
|
||||
|
||||
### R3c pre-lift baseline — 2026-07-10 (2D highway draw cost)
|
||||
|
||||
The gate for the `highway.js` split. Captured on a seeded library (the 33 MB
|
||||
Arcturus feedpak) with the new `--song` mode, which measures **per-frame draw
|
||||
cost** — rAF callbacks are tagged via `highway.addDrawHook`, so only frames the
|
||||
highway actually painted count (the other ~half are cheap no-op loops that would
|
||||
otherwise mask a regression). Any `highway.js` change must re-run this on the
|
||||
same machine and stay within noise of these numbers.
|
||||
|
||||
```bash
|
||||
node scripts/perf-baseline.mjs --base http://127.0.0.1:8300 \
|
||||
--song "Arcturus - The Sham Mirrors - Kinetic.feedpak"
|
||||
```
|
||||
|
||||
| run | draw frames | p50 | p95 | p99 | max |
|
||||
|---|---|---|---|---|---|
|
||||
| 1 | 53/106 | 2.2 | 3.2 | 3.6 | 3.6 |
|
||||
| 2 | 50/100 | 2.2 | 2.9 | 3.2 | 3.2 |
|
||||
| 3 | 53/106 | 2.1 | 2.7 | 3.5 | 3.5 |
|
||||
|
||||
**p50 ≈ 2.2 ms · p95 spread 2.7–3.2 ms** (3 runs × 10 s playback, headless
|
||||
chromium on the dev box). The `H`-container lift changes each closure-slot read
|
||||
to a `H.<slot>` property load; this is the number that proves it doesn't cost the
|
||||
hot loop.
|
||||
|
||||
### R0 baseline — 2026-07-08 (branch `feat/r0-plugin-module-rails`)
|
||||
|
||||
> ⚠️ A quick capture (`--n 50 --soak 8`) against an **empty** library (no charts
|
||||
|
||||
@@ -55,8 +55,8 @@ without a *signed* exemption" is unenforceable.
|
||||
## Planned, NOT exempt (owned by split plans — listed so nothing falls between states)
|
||||
|
||||
core `static/app.js` (11,852) · `static/highway.js` (4,168, whole file) · `server.py`
|
||||
(7,798 — was 14,037; ratcheted by the R3 `MetadataDB` + `AudioEffectsMappingDB`
|
||||
extractions and nine `routers/` modules) ·
|
||||
(9,302 — was 14,037; ratcheted by the R3 `MetadataDB` + `AudioEffectsMappingDB`
|
||||
extractions and the first three `routers/` modules) ·
|
||||
`lib/metadata_db.py` (4,373 — new in R3; the `MetadataDB` class alone is 4,018 lines
|
||||
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`
|
||||
|
||||
+4
-31
@@ -62,37 +62,10 @@ copies a hardcoded file list — that regression is what moved this file here.
|
||||
meta_db = None
|
||||
audio_effect_mappings = None
|
||||
|
||||
# Config paths. server.py derives these from the environment (fresh on every
|
||||
# import, so the ~49 pop-and-reimport fixtures keep working) and injects them
|
||||
# here. Routers read them as `appstate.config_dir` etc. — a module attribute at
|
||||
# call time. NOTE: config_dir/dlc_dir are env-derived, so a `setenv`+reimport
|
||||
# test reconfigures them for free; STATIC_DIR/SLOPPAK_CACHE_DIR are patched via
|
||||
# `setattr(server, …)` in a few tests, so those slots (when added) need their
|
||||
# tests retargeted to appstate in the same PR.
|
||||
config_dir = None
|
||||
dlc_dir = None # the DLC_DIR env value as a Path (Path("") if unset)
|
||||
dlc_dir_env = None # the raw DLC_DIR env string, "" if unset — distinguishes
|
||||
# "unset" from Path("")→"." (see dlc_paths._get_dlc_dir)
|
||||
# Cache/asset dirs. static_dir + sloppak_cache_dir are patched via
|
||||
# `setattr(server, …)` in a few tests, so a router reading them here needs those
|
||||
# setattr sites retargeted to `setattr(appstate, …)` in the same PR (ws_highway
|
||||
# retargets the 3 test_highway_ws_* SLOPPAK sites). config_dir-derived dirs are
|
||||
# reconfigured for free on a setenv+reimport.
|
||||
static_dir = None
|
||||
sloppak_cache_dir = None
|
||||
audio_cache_dir = None
|
||||
|
||||
# Injected callables (not values): server owns the impl + its state, routers call
|
||||
# through the seam. get_progression_content wraps a lazy content cache that stays
|
||||
# in server.py (its `setattr(server, "_progression_content")` test is untouched).
|
||||
get_progression_content = None
|
||||
|
||||
_SLOTS = frozenset({
|
||||
"meta_db", "audio_effect_mappings",
|
||||
"config_dir", "dlc_dir", "dlc_dir_env",
|
||||
"static_dir", "sloppak_cache_dir", "audio_cache_dir",
|
||||
"get_progression_content",
|
||||
})
|
||||
# Declared up front so `configure()` can reject a typo'd or stale keyword
|
||||
# instead of silently creating a new global that nothing ever reads. A seam
|
||||
# whose wiring can no-op undetected is worse than no seam.
|
||||
_SLOTS = frozenset({"meta_db", "audio_effect_mappings"})
|
||||
|
||||
|
||||
def configure(**kwargs) -> None:
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
"""DLC library path resolution — where the song files live, plus safe containment.
|
||||
|
||||
Extracted from ``server.py`` (R3). ``_resolve_dlc_path`` is pure and moved
|
||||
verbatim. ``_get_dlc_dir`` reads the env-derived paths through the ``appstate``
|
||||
seam (``server.py`` configures ``dlc_dir``/``dlc_dir_env``/``config_dir`` at
|
||||
import, fresh on every re-import), so this module does no import-time IO and the
|
||||
pop-and-reimport fixtures keep working. ``server.py`` re-exports both names, so
|
||||
existing ``server._get_dlc_dir`` / ``server._resolve_dlc_path`` references
|
||||
(tests, other handlers) resolve unchanged.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import appstate
|
||||
|
||||
|
||||
def _get_dlc_dir(cfg: dict | None = None) -> Path | None:
|
||||
# Only consider DLC_DIR if the env var was non-empty. `Path("")` collapses
|
||||
# to `.` and reports `.is_dir() == True`, which would silently shadow the
|
||||
# config.json fallback. Checking the raw env string preserves
|
||||
# `DLC_DIR=.` as a valid opt-in for cwd while keeping unset/empty out.
|
||||
if appstate.dlc_dir_env and appstate.dlc_dir.is_dir():
|
||||
return appstate.dlc_dir
|
||||
if cfg is None:
|
||||
config_file = appstate.config_dir / "config.json"
|
||||
if config_file.exists():
|
||||
try:
|
||||
cfg = json.loads(config_file.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
pass
|
||||
if isinstance(cfg, dict):
|
||||
raw = str(cfg.get("dlc_dir", "")).strip()
|
||||
if raw:
|
||||
p = Path(raw)
|
||||
if p.is_dir():
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_dlc_path(dlc: Path, filename: str) -> Path | None:
|
||||
"""Resolve `filename` under DLC_DIR and refuse anything that escapes.
|
||||
|
||||
`filename` arrives from `:path` route params and can contain `..`
|
||||
segments. The Sloppak and archive paths happen to fail safely later
|
||||
because their loaders raise on missing/invalid files, but loose-
|
||||
folder format detection (`is_loose_song`) globs and parses XML on
|
||||
disk first, which lets a crafted path trigger filesystem reads
|
||||
outside DLC_DIR before any guard fires. Centralise the containment
|
||||
check so every filename-bound handler validates before touching the
|
||||
filesystem.
|
||||
|
||||
Containment here is LEXICAL (normalize `.`/`..` WITHOUT following
|
||||
symlinks), not `safe_join`'s `.resolve()`-based check — because users
|
||||
commonly mount their song library through a directory JUNCTION/symlink
|
||||
(a library shared across app installs; the desktop app's own mounts).
|
||||
`.resolve()` follows that junction to its real target, sees it sits
|
||||
outside DLC_DIR, and wrongly rejects every song reached through it — the
|
||||
scanner's `rglob` indexes those songs, but art/load then 403/404s (broken
|
||||
covers, unplayable songs). Lexical normalization still rejects the only
|
||||
escapes a `:path` filename can express — `..` traversal and absolute
|
||||
paths — which the traversal tests pin. `safe_join` stays strict (it is
|
||||
the zip-slip / plugin-asset guard, where following a symlink out IS the
|
||||
defense); the loose-folder art handler keeps its own per-file symlink
|
||||
re-check for defence-in-depth.
|
||||
|
||||
Returns the validated Path (not necessarily link-resolved), or None if
|
||||
the filename is empty, contains a NUL, or escapes the DLC root.
|
||||
"""
|
||||
if not filename:
|
||||
return None
|
||||
# Backslashes → forward slashes so a Windows-style `..\\x` traversal is
|
||||
# rejected identically on POSIX (mirrors safe_join's normalisation).
|
||||
safe = filename.replace("\\", "/")
|
||||
if "\x00" in safe:
|
||||
return None
|
||||
# Reject drive-letter / absolute paths in BOTH conventions. A POSIX "/x" is
|
||||
# caught by the containment check below (the `/` operator discards `root`),
|
||||
# but a Windows drive-absolute "C:/x" is treated as a relative "C:" dir on
|
||||
# POSIX and would otherwise slip in as `<root>/C:/x` — so the contract must
|
||||
# hold cross-platform (a shared library is reached from either OS).
|
||||
from pathlib import PurePosixPath, PureWindowsPath
|
||||
if (PurePosixPath(safe).is_absolute()
|
||||
or PureWindowsPath(safe).is_absolute()
|
||||
or PureWindowsPath(safe).drive):
|
||||
return None
|
||||
try:
|
||||
root = dlc.resolve()
|
||||
# normpath collapses `.`/`..`/duplicate separators purely lexically —
|
||||
# it never touches the filesystem, so an in-library junction component
|
||||
# is preserved (allowed) while `..`/absolute segments still escape and
|
||||
# get caught by the containment check below.
|
||||
candidate = Path(os.path.normpath(root / safe))
|
||||
if not candidate.is_relative_to(root):
|
||||
return None
|
||||
except (ValueError, OSError):
|
||||
return None
|
||||
return candidate
|
||||
@@ -1,13 +0,0 @@
|
||||
"""Request-field coercion helpers shared by the raw-`dict` POST handlers.
|
||||
|
||||
Extracted verbatim from ``server.py`` (R3). Pure — no IO, no globals — so it
|
||||
imports cleanly from both ``server`` and any ``routers/`` module.
|
||||
"""
|
||||
|
||||
|
||||
def _clean_str(value) -> str:
|
||||
"""Trim a request field to a string; non-strings (or missing) → ''.
|
||||
Lets the raw-`dict` POST handlers treat wrong-typed JSON (an int/list/etc.
|
||||
where a string was expected) as "empty" and answer 400, instead of raising
|
||||
AttributeError/TypeError → 500 on a later .strip()/`in`."""
|
||||
return value.strip() if isinstance(value, str) else ""
|
||||
@@ -1,115 +0,0 @@
|
||||
"""Chart-level endpoints — split/unsplit a chart from its work, resolve work
|
||||
membership, and the context-menu "Get info" file inspector.
|
||||
|
||||
Extracted verbatim from ``server.py`` (R3); edits: ``@app`` -> ``@router``,
|
||||
``meta_db`` -> ``appstate.meta_db``. DLC path resolution comes from
|
||||
``dlc_paths``; sloppak/loose detection from the shared lib modules.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
import appstate
|
||||
from dlc_paths import _get_dlc_dir, _resolve_dlc_path
|
||||
import sloppak as sloppak_mod
|
||||
import loosefolder as loosefolder_mod
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/api/chart/{filename:path}/split")
|
||||
def api_split_chart(filename: str):
|
||||
"""'These aren't the same song' — split this chart out as its own singleton
|
||||
work. Under /api/chart (NOT /api/song) so the DELETE /api/song/{path}
|
||||
catch-all can't shadow it."""
|
||||
key = appstate.meta_db._canonical_song_filename(filename)
|
||||
appstate.meta_db.split_chart(key)
|
||||
return {"ok": True, "filename": key}
|
||||
|
||||
|
||||
@router.post("/api/chart/{filename:path}/unsplit")
|
||||
def api_unsplit_chart(filename: str):
|
||||
"""Undo a split — rejoin the chart to its work."""
|
||||
key = appstate.meta_db._canonical_song_filename(filename)
|
||||
appstate.meta_db.unsplit_chart(key)
|
||||
return {"ok": True, "filename": key}
|
||||
|
||||
|
||||
@router.get("/api/chart/{filename:path}/work")
|
||||
def api_get_chart_work(filename: str):
|
||||
"""Resolve a chart's work membership: {work_key, chart_count}. For openers
|
||||
on rows that came from an ungrouped query (the tree view) — grouped grid
|
||||
rows already carry both fields inline."""
|
||||
return appstate.meta_db.chart_work(filename)
|
||||
|
||||
|
||||
@router.get("/api/chart/{filename:path}/fileinfo")
|
||||
def api_chart_fileinfo(filename: str):
|
||||
"""The context menu's "Get info": where the file lives + what the pack
|
||||
contains. Under /api/chart — the GET /api/song/{path} catch-all would
|
||||
swallow a /api/song/…/fileinfo suffix. Read-only; demo-mode blocks it
|
||||
because it exposes filesystem paths."""
|
||||
dlc = _get_dlc_dir()
|
||||
if not dlc:
|
||||
raise HTTPException(status_code=404, detail="not configured")
|
||||
p = _resolve_dlc_path(dlc, filename)
|
||||
if p is None:
|
||||
raise HTTPException(status_code=403, detail="forbidden")
|
||||
if not p.exists():
|
||||
raise HTTPException(status_code=404, detail="not found")
|
||||
# Restrict to actual charts — sloppak or loose song. Without this the route
|
||||
# would stat ANY file the user happens to keep under DLC_DIR (e.g. notes),
|
||||
# leaking its path/size; the app only recognises these two song formats.
|
||||
is_pak = sloppak_mod.is_sloppak(p)
|
||||
is_loose = loosefolder_mod.is_loose_song(p)
|
||||
if not (is_pak or is_loose):
|
||||
raise HTTPException(status_code=404, detail="not a chart")
|
||||
st = p.stat()
|
||||
info = {
|
||||
"filename": filename,
|
||||
"path": str(p),
|
||||
"folder": str(p.parent),
|
||||
"format": "sloppak" if is_pak else "loose",
|
||||
# Directory-form songs report the tree's total (covers loose folders
|
||||
# and dir-form paks); zip-form paks report the archive size. Symlinked
|
||||
# entries are skipped so a link inside the folder can't pull in — or
|
||||
# leak the size of — a file outside it.
|
||||
"size": (st.st_size if p.is_file()
|
||||
else sum(f.stat().st_size for f in p.rglob("*")
|
||||
if f.is_file() and not f.is_symlink())),
|
||||
"mtime": st.st_mtime,
|
||||
}
|
||||
if is_pak:
|
||||
try:
|
||||
m = sloppak_mod.load_manifest(p) or {}
|
||||
except Exception:
|
||||
m = {}
|
||||
arrs = [str(a.get("name", a.get("id", ""))) for a in (m.get("arrangements") or [])
|
||||
if isinstance(a, dict)]
|
||||
stems = [str(s.get("id", "")) for s in (m.get("stems") or []) if isinstance(s, dict)]
|
||||
try:
|
||||
has_cover = sloppak_mod.read_cover_bytes(p, m) is not None
|
||||
except Exception:
|
||||
has_cover = False
|
||||
# The optional identity/catalog keys, listed only when present — the
|
||||
# Get-info panel's "what this pack carries vs what's missing" readout.
|
||||
identity = {k: m.get(k) for k in
|
||||
("mbid", "isrc", "genres", "track", "disc", "album_artist",
|
||||
"feedpak_version", "language")
|
||||
if m.get(k) not in (None, "", [])}
|
||||
info["manifest"] = {
|
||||
"title": str(m.get("title", "")), "artist": str(m.get("artist", "")),
|
||||
"album": str(m.get("album", "")), "year": str(m.get("year", "") or ""),
|
||||
"arrangements": arrs, "stems": stems,
|
||||
"has_cover": has_cover, "has_lyrics": bool(m.get("lyrics")),
|
||||
"authors": [a.get("name", "") if isinstance(a, dict) else str(a)
|
||||
for a in (m.get("authors") or [])],
|
||||
"identity": identity,
|
||||
}
|
||||
# The enrichment verdict, so Get info can say "Matched (auto, 96%)" /
|
||||
# "Pinned by you" / "Not matched" alongside the file facts.
|
||||
row = appstate.meta_db.get_enrichment(filename)
|
||||
if row:
|
||||
info["match"] = {k: row.get(k) for k in
|
||||
("match_state", "match_source", "match_score",
|
||||
"canon_artist", "canon_title", "canon_album", "canon_year")}
|
||||
return info
|
||||
@@ -1,75 +0,0 @@
|
||||
"""Small meta_db-backed library / user-state endpoints — work keeper-chart
|
||||
prefs, favorites, personal tags, saved-for-later, and continue-playing.
|
||||
|
||||
Extracted verbatim from ``server.py`` (R3); edits: ``@app`` -> ``@router``,
|
||||
``meta_db`` -> ``appstate.meta_db``, ``_clean_str`` from ``reqfields``. All paths
|
||||
are distinct and non-overlapping, so mounting them together (rather than at each
|
||||
original scattered site) does not change routing.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
import appstate
|
||||
from reqfields import _clean_str
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/api/work/{work_key:path}/charts")
|
||||
def api_get_work_charts(work_key: str):
|
||||
"""All charts in a work + which is the keeper (your pick vs auto-pick)."""
|
||||
return appstate.meta_db.work_charts(work_key)
|
||||
|
||||
|
||||
@router.put("/api/work/{work_key:path}/preferred")
|
||||
def api_set_work_preferred(work_key: str, data: dict):
|
||||
"""Set the keeper chart of a work: body {filename}. The filename must be a
|
||||
current member of the work. Returns the refreshed chart list."""
|
||||
fn = (data.get("filename") or "").strip()
|
||||
if not fn:
|
||||
return JSONResponse({"error": "filename is required"}, 400)
|
||||
members = {c["filename"] for c in appstate.meta_db.work_charts(work_key)["charts"]}
|
||||
if fn not in members:
|
||||
return JSONResponse({"error": "filename is not a chart of this work"}, 400)
|
||||
appstate.meta_db.set_chart_preferred(work_key, fn)
|
||||
return appstate.meta_db.work_charts(work_key)
|
||||
|
||||
|
||||
@router.delete("/api/work/{work_key:path}/preferred")
|
||||
def api_reset_work_preferred(work_key: str):
|
||||
"""Reset a work to auto-pick (drop the explicit preferred)."""
|
||||
appstate.meta_db.clear_chart_preferred(work_key)
|
||||
return appstate.meta_db.work_charts(work_key)
|
||||
|
||||
|
||||
@router.post("/api/favorites/toggle")
|
||||
def toggle_favorite(data: dict):
|
||||
"""Toggle a song's favorite status."""
|
||||
filename = data.get("filename", "")
|
||||
if not filename:
|
||||
return {"error": "No filename"}
|
||||
new_state = appstate.meta_db.toggle_favorite(filename)
|
||||
return {"favorite": new_state}
|
||||
|
||||
|
||||
@router.get("/api/tags")
|
||||
def list_tags():
|
||||
"""All personal tags in use (over still-present songs), most-used first —
|
||||
powers the tag filter UI."""
|
||||
return {"tags": appstate.meta_db.all_tags()}
|
||||
|
||||
|
||||
@router.post("/api/saved/toggle")
|
||||
def api_toggle_saved(data: dict):
|
||||
"""Add/remove a song on the reserved Saved-for-Later playlist."""
|
||||
filename = _clean_str(data.get("filename"))
|
||||
if not filename:
|
||||
return JSONResponse({"error": "filename required"}, status_code=400)
|
||||
return {"saved": appstate.meta_db.toggle_saved(filename)}
|
||||
|
||||
|
||||
@router.get("/api/session/continue")
|
||||
def api_session_continue():
|
||||
"""The Continue-Playing card's song (most recent play) or null."""
|
||||
return appstate.meta_db.continue_session()
|
||||
+12
-20
@@ -15,15 +15,10 @@ router = APIRouter()
|
||||
|
||||
@router.get("/api/loops")
|
||||
def list_loops(filename: str):
|
||||
# Hold the DB lock for the read: the shared single connection
|
||||
# (check_same_thread=False) is serialized through meta_db._lock by every
|
||||
# writer, so an unlocked SELECT here can overlap a POST/DELETE commit.
|
||||
db = appstate.meta_db
|
||||
with db._lock:
|
||||
rows = db.conn.execute(
|
||||
"SELECT id, name, start_time, end_time FROM loops WHERE filename = ? ORDER BY start_time",
|
||||
(filename,)
|
||||
).fetchall()
|
||||
rows = appstate.meta_db.conn.execute(
|
||||
"SELECT id, name, start_time, end_time FROM loops WHERE filename = ? ORDER BY start_time",
|
||||
(filename,)
|
||||
).fetchall()
|
||||
return [{"id": r[0], "name": r[1], "start": r[2], "end": r[3]} for r in rows]
|
||||
|
||||
|
||||
@@ -35,20 +30,17 @@ def save_loop(data: dict):
|
||||
end = data.get("end")
|
||||
if not filename or start is None or end is None:
|
||||
return {"error": "Missing fields"}
|
||||
db = appstate.meta_db
|
||||
with db._lock:
|
||||
# COUNT + INSERT under one lock so two unnamed POSTs can't read the same
|
||||
# count and both mint "Loop N" (the count is only used to name the row).
|
||||
if not name:
|
||||
count = db.conn.execute(
|
||||
"SELECT COUNT(*) FROM loops WHERE filename = ?", (filename,)
|
||||
).fetchone()[0]
|
||||
name = f"Loop {count + 1}"
|
||||
db.conn.execute(
|
||||
if not name:
|
||||
count = appstate.meta_db.conn.execute(
|
||||
"SELECT COUNT(*) FROM loops WHERE filename = ?", (filename,)
|
||||
).fetchone()[0]
|
||||
name = f"Loop {count + 1}"
|
||||
with appstate.meta_db._lock:
|
||||
appstate.meta_db.conn.execute(
|
||||
"INSERT INTO loops (filename, name, start_time, end_time) VALUES (?, ?, ?, ?)",
|
||||
(filename, name, float(start), float(end))
|
||||
)
|
||||
db.conn.commit()
|
||||
appstate.meta_db.conn.commit()
|
||||
return {"ok": True, "name": name}
|
||||
|
||||
|
||||
|
||||
@@ -1,267 +0,0 @@
|
||||
"""Playlists + custom playlist covers (fee[dB]ack v0.3.0).
|
||||
|
||||
Extracted verbatim from ``server.py`` (R3). Edits: ``@app`` -> ``@router``,
|
||||
``meta_db`` -> ``appstate.meta_db``, ``CONFIG_DIR`` -> ``appstate.config_dir``
|
||||
(both read at call time through the seam), and ``_clean_str`` now imports from
|
||||
``reqfields``. See ``appstate.py``.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
|
||||
import appstate
|
||||
from reqfields import _clean_str
|
||||
|
||||
log = logging.getLogger("feedBack.server")
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Cache policy for the custom-cover file response: revalidate every time so a
|
||||
# replaced cover is never served stale (pairs with the mtime-ns URL token).
|
||||
_ART_CACHE_HEADERS = {"Cache-Control": "no-cache"}
|
||||
|
||||
|
||||
def _playlist_cover_path(pid) -> Path | None:
|
||||
"""Filesystem path of a playlist's optional custom cover image (PNG),
|
||||
stored under CONFIG_DIR. Returns None for a non-integer id."""
|
||||
try:
|
||||
pid = int(pid)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return appstate.config_dir / "playlist_covers" / f"{pid}.png"
|
||||
|
||||
|
||||
def _playlist_cover_url(pid) -> str | None:
|
||||
cover = _playlist_cover_path(pid)
|
||||
if not cover or not cover.exists():
|
||||
return None
|
||||
try:
|
||||
# Nanosecond mtime so a same-second replace/remove/re-upload still
|
||||
# changes the cache-bust token (int seconds could collide → stale image).
|
||||
mt = cover.stat().st_mtime_ns
|
||||
except OSError:
|
||||
mt = 0
|
||||
return f"/api/playlists/{pid}/cover?v={mt}"
|
||||
|
||||
|
||||
@router.get("/api/playlists")
|
||||
def api_list_playlists():
|
||||
lists = appstate.meta_db.list_playlists()
|
||||
for pl in lists:
|
||||
pl["cover_url"] = _playlist_cover_url(pl["id"])
|
||||
return lists
|
||||
|
||||
|
||||
@router.post("/api/playlists")
|
||||
def api_create_playlist(data: dict):
|
||||
name = _clean_str(data.get("name"))
|
||||
if not (1 <= len(name) <= 100):
|
||||
return JSONResponse({"error": "Playlist name must be 1–100 characters."}, status_code=400)
|
||||
# kind='album' = a curated album (§7.2): hand-picked works, a chosen chart
|
||||
# per slot, played front-to-back on the queue. Absent/None = a regular mix.
|
||||
kind = _clean_str(data.get("kind")) or None
|
||||
if kind not in (None, "album"):
|
||||
return JSONResponse({"error": "kind must be 'album' or omitted"}, status_code=400)
|
||||
return appstate.meta_db.create_playlist(name, kind=kind)
|
||||
|
||||
|
||||
@router.get("/api/playlists/{pid}")
|
||||
def api_get_playlist(pid: int):
|
||||
pl = appstate.meta_db.get_playlist(pid)
|
||||
if pl is None:
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
pl["cover_url"] = _playlist_cover_url(pid)
|
||||
return pl
|
||||
|
||||
|
||||
@router.patch("/api/playlists/{pid}")
|
||||
def api_rename_playlist(pid: int, data: dict):
|
||||
pl = appstate.meta_db.get_playlist(pid)
|
||||
if pl is None:
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
if pl["system_key"]:
|
||||
return JSONResponse({"error": "System playlists cannot be renamed."}, status_code=400)
|
||||
name = _clean_str(data.get("name"))
|
||||
if not (1 <= len(name) <= 100):
|
||||
return JSONResponse({"error": "Playlist name must be 1–100 characters."}, status_code=400)
|
||||
appstate.meta_db.rename_playlist(pid, name)
|
||||
return appstate.meta_db.get_playlist(pid)
|
||||
|
||||
|
||||
@router.delete("/api/playlists/{pid}")
|
||||
def api_delete_playlist(pid: int):
|
||||
pl = appstate.meta_db.get_playlist(pid)
|
||||
if pl is None:
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
if pl["system_key"]:
|
||||
return JSONResponse({"error": "System playlists cannot be deleted."}, status_code=400)
|
||||
if not appstate.meta_db.delete_playlist(pid): # vanished under us (concurrent delete)
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
cover = _playlist_cover_path(pid) # drop any custom cover with the playlist
|
||||
if cover and cover.exists():
|
||||
try:
|
||||
cover.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/api/playlists/{pid}/songs")
|
||||
def api_add_playlist_song(pid: int, data: dict):
|
||||
if appstate.meta_db.get_playlist(pid) is None:
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
filename = _clean_str(data.get("filename"))
|
||||
if not filename:
|
||||
return JSONResponse({"error": "filename required"}, status_code=400)
|
||||
if appstate.meta_db.add_playlist_song(pid, filename) is None: # playlist vanished under us
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
pl = appstate.meta_db.get_playlist(pid)
|
||||
return pl if pl is not None else JSONResponse({"error": "not found"}, status_code=404)
|
||||
|
||||
|
||||
@router.patch("/api/playlists/{pid}/songs/{filename:path}")
|
||||
def api_update_playlist_slot(pid: int, filename: str, data: dict):
|
||||
"""Edit one curated-album slot: {"arrangement": name|null} pins/clears the
|
||||
slot's arrangement; {"chart_filename": fn} swaps the slot to another chart
|
||||
of the same work (position + pin kept). Albums only — a mix has no slots."""
|
||||
pl = appstate.meta_db.get_playlist(pid)
|
||||
if pl is None:
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
if pl.get("kind") != "album":
|
||||
return JSONResponse({"error": "Slot editing is for albums."}, status_code=400)
|
||||
kwargs = {}
|
||||
if "chart_filename" in data:
|
||||
new_fn = _clean_str(data.get("chart_filename"))
|
||||
if not new_fn:
|
||||
return JSONResponse({"error": "chart_filename must be a filename"}, status_code=400)
|
||||
kwargs["new_filename"] = new_fn
|
||||
if "arrangement" in data:
|
||||
arr = data.get("arrangement")
|
||||
if arr is not None and not (isinstance(arr, str) and 1 <= len(arr.strip()) <= 100):
|
||||
return JSONResponse({"error": "arrangement must be a name or null"}, status_code=400)
|
||||
kwargs["arrangement"] = arr.strip() if isinstance(arr, str) else None
|
||||
if not kwargs:
|
||||
return JSONResponse({"error": "nothing to update"}, status_code=400)
|
||||
if appstate.meta_db.update_playlist_slot(pid, filename, **kwargs) is None:
|
||||
return JSONResponse(
|
||||
{"error": "no such slot, or the chart isn't a version of this song"},
|
||||
status_code=400)
|
||||
return appstate.meta_db.get_playlist(pid)
|
||||
|
||||
|
||||
@router.delete("/api/playlists/{pid}/songs/{filename:path}")
|
||||
def api_remove_playlist_song(pid: int, filename: str):
|
||||
if appstate.meta_db.get_playlist(pid) is None:
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
appstate.meta_db.remove_playlist_song(pid, filename)
|
||||
pl = appstate.meta_db.get_playlist(pid)
|
||||
return pl if pl is not None else JSONResponse({"error": "not found"}, status_code=404)
|
||||
|
||||
|
||||
@router.post("/api/playlists/{pid}/reorder")
|
||||
def api_reorder_playlist(pid: int, data: dict):
|
||||
pl = appstate.meta_db.get_playlist(pid)
|
||||
if pl is None:
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
order = data.get("order")
|
||||
if not isinstance(order, list) or not all(isinstance(f, str) for f in order):
|
||||
return JSONResponse({"error": "order must be a list of filenames"}, status_code=400)
|
||||
# Require an exact permutation of the playlist's current songs: a list with
|
||||
# duplicates, omissions, or extras would otherwise produce duplicate
|
||||
# positions / a partial reorder while still returning 200.
|
||||
current = [s["filename"] for s in pl["songs"]]
|
||||
if len(order) != len(current) or sorted(order) != sorted(current):
|
||||
return JSONResponse(
|
||||
{"error": "order must be a permutation of the playlist's current songs"},
|
||||
status_code=400,
|
||||
)
|
||||
appstate.meta_db.reorder_playlist(pid, order)
|
||||
return appstate.meta_db.get_playlist(pid)
|
||||
|
||||
|
||||
@router.post("/api/playlists/{pid}/cover")
|
||||
async def api_set_playlist_cover(pid: int, data: dict):
|
||||
"""Set a playlist's custom cover from a base64 / data-URL image (PNG/JPG).
|
||||
Overrides the content-dependent (song-art) cover. Stored as a small PNG
|
||||
thumbnail under CONFIG_DIR/playlist_covers/."""
|
||||
if appstate.meta_db.get_playlist(pid) is None:
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
import base64
|
||||
import io
|
||||
b64 = data.get("image", "")
|
||||
# Guard the type before the `","` membership test — a non-string image
|
||||
# (e.g. {"image": 123} / null) would otherwise raise TypeError → 500.
|
||||
# Mirrors the avatar/song-art upload guard.
|
||||
if not isinstance(b64, str) or not b64:
|
||||
return JSONResponse({"error": "No image data"}, status_code=400)
|
||||
if "," in b64:
|
||||
b64 = b64.split(",", 1)[1]
|
||||
if not b64:
|
||||
return JSONResponse({"error": "No image data"}, status_code=400)
|
||||
try:
|
||||
img_data = base64.b64decode(b64)
|
||||
except Exception:
|
||||
return JSONResponse({"error": "Invalid base64"}, status_code=400)
|
||||
cover = _playlist_cover_path(pid)
|
||||
cover.parent.mkdir(parents=True, exist_ok=True)
|
||||
# Decode/validate the image — a bad payload is a CLIENT error (400), and the
|
||||
# message stays generic so it can't echo internals.
|
||||
try:
|
||||
from PIL import Image
|
||||
img = Image.open(io.BytesIO(img_data)).convert("RGB")
|
||||
img.thumbnail((640, 640)) # covers stay small
|
||||
except Exception:
|
||||
return JSONResponse({"error": "Invalid image"}, status_code=400)
|
||||
# Persist. A save/replace failure is a SERVER error (500, logged, no
|
||||
# filesystem detail leaked) — the pre-split handler mislabeled these as 400
|
||||
# and echoed the exception. A unique temp name in the cover dir (not a shared
|
||||
# `{pid}.png.tmp`) means two concurrent uploads can't clobber each other's
|
||||
# temp file; the atomic replace publishes. Re-check the playlist still exists
|
||||
# just before publishing so a delete that raced the decode above can't leave
|
||||
# an orphan cover — cheap belt-and-braces; FeedBack is single-user
|
||||
# (Principle I), so a full per-playlist lock would be for a race the
|
||||
# deployment model precludes.
|
||||
tmp = None
|
||||
try:
|
||||
# mkstemp is inside the try too: an unwritable dir / full disk raises
|
||||
# here, and that's the same class of persistence failure as save/replace.
|
||||
fd, tmp_name = tempfile.mkstemp(prefix=f".{pid}.", suffix=".png.tmp", dir=str(cover.parent))
|
||||
tmp = Path(tmp_name)
|
||||
with os.fdopen(fd, "wb") as f:
|
||||
img.save(f, "PNG")
|
||||
if appstate.meta_db.get_playlist(pid) is None:
|
||||
tmp.unlink(missing_ok=True)
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
tmp.replace(cover)
|
||||
except Exception:
|
||||
if tmp is not None:
|
||||
tmp.unlink(missing_ok=True)
|
||||
log.exception("playlist cover save failed (pid=%s)", pid)
|
||||
return JSONResponse({"error": "could not save cover"}, status_code=500)
|
||||
return {"ok": True, "cover_url": _playlist_cover_url(pid)}
|
||||
|
||||
|
||||
@router.get("/api/playlists/{pid}/cover")
|
||||
def api_get_playlist_cover(pid: int):
|
||||
cover = _playlist_cover_path(pid)
|
||||
if not cover or not cover.exists():
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
# no-cache (revalidate) like song art, so a replaced cover is never served
|
||||
# stale — pairs with the mtime-ns cache-bust token on the URL.
|
||||
return FileResponse(str(cover), media_type="image/png", headers=_ART_CACHE_HEADERS)
|
||||
|
||||
|
||||
@router.delete("/api/playlists/{pid}/cover")
|
||||
def api_delete_playlist_cover(pid: int):
|
||||
cover = _playlist_cover_path(pid)
|
||||
if cover and cover.exists():
|
||||
try:
|
||||
cover.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
return {"ok": True}
|
||||
@@ -1,62 +0,0 @@
|
||||
"""Cosmetics shop (spec 010) — buy/equip avatars & themes with earned currency.
|
||||
|
||||
Extracted verbatim from ``server.py`` (R3); edits: ``@app`` -> ``@router``,
|
||||
``meta_db`` -> ``appstate.meta_db``, ``_get_progression_content()`` ->
|
||||
``appstate.get_progression_content()`` (the accessor is injected into the seam;
|
||||
its lazy content cache stays in server.py).
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
import appstate
|
||||
from reqfields import _clean_str
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/api/shop")
|
||||
def api_shop():
|
||||
content = appstate.get_progression_content()
|
||||
owned = appstate.meta_db.get_owned_items()
|
||||
equipped = appstate.meta_db.get_equipped()
|
||||
items = [
|
||||
{**item, "owned": iid in owned, "equipped": equipped.get(item["slot"]) == iid}
|
||||
for iid, item in sorted(content["shop"].items())
|
||||
]
|
||||
return {"items": items, "wallet": appstate.meta_db.get_wallet()}
|
||||
|
||||
|
||||
@router.post("/api/shop/buy")
|
||||
def api_shop_buy(data: dict):
|
||||
"""Spend Decibels on a cosmetic. Atomic: balance check + spend + ownership
|
||||
in one transaction. Decibels are earned by playing only — never purchasable."""
|
||||
item_id = _clean_str(data.get("item_id"))
|
||||
item = appstate.get_progression_content()["shop"].get(item_id)
|
||||
if not item:
|
||||
return JSONResponse({"error": f"unknown item: {item_id!r}"}, status_code=400)
|
||||
status, wallet = appstate.meta_db.buy_shop_item(item)
|
||||
if status == "owned":
|
||||
return JSONResponse({"error": "already owned", "wallet": wallet}, status_code=409)
|
||||
if status == "insufficient":
|
||||
return JSONResponse({"error": "insufficient balance", "wallet": wallet}, status_code=402)
|
||||
return {"ok": True, "item_id": item_id, "wallet": wallet}
|
||||
|
||||
|
||||
@router.post("/api/shop/equip")
|
||||
def api_shop_equip(data: dict):
|
||||
"""Equip an owned cosmetic into its slot. Body: {slot, item_id|null}
|
||||
(null unequips, restoring the default look)."""
|
||||
import progression as progression_mod
|
||||
slot = _clean_str(data.get("slot"))
|
||||
if slot not in progression_mod.SHOP_SLOTS:
|
||||
return JSONResponse({"error": f"slot must be one of {sorted(progression_mod.SHOP_SLOTS)}"}, status_code=400)
|
||||
item_id = data.get("item_id")
|
||||
if item_id is not None:
|
||||
item_id = _clean_str(item_id)
|
||||
item = appstate.get_progression_content()["shop"].get(item_id)
|
||||
if not item or item["slot"] != slot:
|
||||
return JSONResponse({"error": f"unknown item for slot {slot}: {item_id!r}"}, status_code=400)
|
||||
if item_id not in appstate.meta_db.get_owned_items():
|
||||
return JSONResponse({"error": "item not owned"}, status_code=403)
|
||||
return {"ok": True, "equipped": appstate.meta_db.equip_item(slot, item_id)}
|
||||
@@ -1,45 +0,0 @@
|
||||
"""Wishlist / "wanted" API (feedBack#636) — songs the user wants but doesn't own.
|
||||
|
||||
Extracted verbatim from ``server.py`` (R3); edits: ``@app`` -> ``@router``,
|
||||
``meta_db`` -> ``appstate.meta_db``, ``_clean_str`` from ``reqfields``.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
import appstate
|
||||
from reqfields import _clean_str
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/api/wanted")
|
||||
def api_list_wanted():
|
||||
"""The wishlist — songs the user wants but doesn't own yet (newest first)."""
|
||||
return {"wanted": appstate.meta_db.list_wanted()}
|
||||
|
||||
|
||||
@router.post("/api/wanted")
|
||||
def api_add_wanted(data: dict):
|
||||
"""Add a not-owned song to the wishlist. `artist`/`title` are required (at
|
||||
least one non-empty); `source`/`source_ref`/`note` are optional. Idempotent
|
||||
on identity so producers (find_more ownership-diff, manual add) can re-post."""
|
||||
if not isinstance(data, dict):
|
||||
return JSONResponse({"error": "body must be an object"}, status_code=400)
|
||||
artist = _clean_str(data.get("artist"))
|
||||
title = _clean_str(data.get("title"))
|
||||
if not artist and not title:
|
||||
return JSONResponse({"error": "artist or title required"}, status_code=400)
|
||||
row = appstate.meta_db.add_wanted(
|
||||
artist=artist, title=title,
|
||||
source=_clean_str(data.get("source")) or "manual",
|
||||
source_ref=_clean_str(data.get("source_ref")),
|
||||
note=_clean_str(data.get("note")),
|
||||
)
|
||||
return {"ok": True, "wanted": row}
|
||||
|
||||
|
||||
@router.delete("/api/wanted/{wanted_id}")
|
||||
def api_remove_wanted(wanted_id: int):
|
||||
"""Remove a wishlist entry by id."""
|
||||
return {"ok": appstate.meta_db.remove_wanted(wanted_id)}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,16 +6,6 @@
|
||||
//
|
||||
// Usage:
|
||||
// node scripts/perf-baseline.mjs --base http://127.0.0.1:8000 [--n 60] [--soak 30]
|
||||
// node scripts/perf-baseline.mjs --base http://127.0.0.1:8300 --song "Arcturus ….feedpak"
|
||||
//
|
||||
// With --song, it additionally measures the 2D highway's PER-FRAME DRAW cost:
|
||||
// it wraps requestAnimationFrame before any page script runs, tags the frames
|
||||
// in which the highway actually painted (via highway.addDrawHook), starts
|
||||
// playback, and reports draw-frame p50/p95/p99 over --frames seconds. Tagging
|
||||
// matters — roughly half the rAF callbacks belong to other cheap loops, and
|
||||
// averaging them in hides a renderer regression behind ~0.1 ms no-op frames.
|
||||
// This is the metric that gates the highway.js split (R3c); run it before AND
|
||||
// after each highway change on the same machine.
|
||||
//
|
||||
// Maintainer/CI-only dev tooling (uses the committed @playwright/test browser);
|
||||
// never part of the serve or Docker path. Metrics that need a seeded library
|
||||
@@ -31,9 +21,6 @@ for (let i = 2; i < process.argv.length; i += 2) args.set(process.argv[i].replac
|
||||
const BASE = args.get('base') || 'http://127.0.0.1:8000';
|
||||
const N = parseInt(args.get('n') || '60', 10);
|
||||
const SOAK_S = parseInt(args.get('soak') || '30', 10);
|
||||
const SONG = args.get('song') || null;
|
||||
const FRAME_S = parseInt(args.get('frames') || '10', 10);
|
||||
const RUNS = parseInt(args.get('runs') || '3', 10); // repeat frame sampling to show spread
|
||||
|
||||
const pct = (xs, p) => {
|
||||
if (!xs.length) return null;
|
||||
@@ -82,64 +69,6 @@ async function clientMetrics() {
|
||||
return { bootMs, memStartMB: mem0 && mem0 / 1048576, memSoakMB: mem1 && mem1 / 1048576, scripts };
|
||||
}
|
||||
|
||||
// ── 2D highway per-frame draw cost (needs --song + a seeded library) ──────────
|
||||
async function frameTimeOnce(browser) {
|
||||
const page = await browser.newPage();
|
||||
let f, t2, notes;
|
||||
try {
|
||||
// Wrap rAF before any page script; mark frames the highway actually drew.
|
||||
await page.addInitScript(() => {
|
||||
window.__f = [];
|
||||
window.__drew = false;
|
||||
const raf = window.requestAnimationFrame.bind(window);
|
||||
window.requestAnimationFrame = (cb) => raf((t) => {
|
||||
window.__drew = false;
|
||||
const t0 = performance.now();
|
||||
try { cb(t); } finally { window.__f.push({ ms: performance.now() - t0, drew: window.__drew }); }
|
||||
});
|
||||
});
|
||||
await page.goto(BASE, { waitUntil: 'networkidle', timeout: 60000 });
|
||||
await page.evaluate(() => document.getElementById('v3-onboarding')?.remove());
|
||||
await page.evaluate((s) => window.playSong(s), SONG);
|
||||
await page.waitForFunction(() => (window.highway?.getNotes?.() || []).length >= 0 && window.highway?.getSongInfo?.(),
|
||||
null, { timeout: 45000 }).catch(() => {});
|
||||
await page.waitForFunction(() => (window.highway?.getNotes?.() || []).length > 0, null, { timeout: 45000 });
|
||||
await page.evaluate(() => window.highway.addDrawHook(() => { window.__drew = true; }));
|
||||
// Start playback so draw() leaves its paused-throttle path; confirm the clock advances.
|
||||
await page.evaluate(async () => { const a = window.highway.getAudioElement?.(); if (a) a.muted = true; await a?.play?.(); });
|
||||
await page.waitForTimeout(1500);
|
||||
const t1 = await page.evaluate(() => window.highway.getTime());
|
||||
await page.evaluate(() => { window.__f.length = 0; });
|
||||
await page.waitForTimeout(FRAME_S * 1000);
|
||||
({ f, t2, notes } = await page.evaluate(() => ({
|
||||
f: window.__f.slice(), t2: window.highway.getTime(), notes: window.highway.getNotes().length,
|
||||
})));
|
||||
if (!(t2 > t1 + 1)) throw new Error(`clock did not advance (${t1}→${t2}) — measured the paused path`);
|
||||
} finally {
|
||||
// Always close, even when an await above throws — otherwise a failing
|
||||
// run leaks its page until the final browser.close().
|
||||
await page.close();
|
||||
}
|
||||
const drew = f.filter((x) => x.drew).map((x) => x.ms);
|
||||
return { drew, total: f.length, notes };
|
||||
}
|
||||
|
||||
async function frameTime() {
|
||||
const browser = await chromium.launch({ args: ['--autoplay-policy=no-user-gesture-required'] });
|
||||
const rows = [];
|
||||
for (let i = 0; i < RUNS; i++) {
|
||||
try {
|
||||
const r = await frameTimeOnce(browser);
|
||||
rows.push({
|
||||
p50: pct(r.drew, 50), p95: pct(r.drew, 95), p99: pct(r.drew, 99),
|
||||
max: Math.max(...r.drew), n: r.drew.length, total: r.total, notes: r.notes,
|
||||
});
|
||||
} catch (e) { rows.push({ error: String(e.message || e) }); }
|
||||
}
|
||||
await browser.close();
|
||||
return rows;
|
||||
}
|
||||
|
||||
const server = await serverLatency([
|
||||
'/api/version',
|
||||
'/api/plugins',
|
||||
@@ -157,21 +86,9 @@ out += `| Cold boot → networkidle | ${client.bootMs} ms |\n`;
|
||||
out += `| JS heap after load | ${client.memStartMB ? client.memStartMB.toFixed(1) + ' MB' : '—'} |\n`;
|
||||
out += `| JS heap after ${SOAK_S}s idle soak | ${client.memSoakMB ? client.memSoakMB.toFixed(1) + ' MB' : '—'} |\n`;
|
||||
out += `| Plugin scripts injected | ${client.scripts} |\n`;
|
||||
if (SONG) {
|
||||
const frames = await frameTime();
|
||||
out += `\n### 2D highway per-frame draw cost — \`${SONG}\` (${RUNS} runs × ${FRAME_S}s)\n\n`;
|
||||
out += `| run | draw frames | p50 | p95 | p99 | max |\n|---|---|---|---|---|---|\n`;
|
||||
for (let i = 0; i < frames.length; i++) {
|
||||
const r = frames[i];
|
||||
if (r.error) { out += `| ${i + 1} | — | \`${r.error}\` | | | |\n`; continue; }
|
||||
out += `| ${i + 1} | ${r.n}/${r.total} | ${ms(r.p50)} | ${ms(r.p95)} | ${ms(r.p99)} | ${ms(r.max)} |\n`;
|
||||
}
|
||||
const p95s = frames.filter((r) => !r.error).map((r) => r.p95);
|
||||
if (p95s.length) out += `\n**p95 spread across runs: ${ms(Math.min(...p95s))}–${ms(Math.max(...p95s))} ms**\n`;
|
||||
} else {
|
||||
out += `\n> **Requires a seeded library** (not captured by this run): playback frame-time p95\n`;
|
||||
out += `> on the 2D highway — pass \`--song "<filename in DLC_DIR>"\` to capture it.\n`;
|
||||
out += `> (3D highway_3d + screen-entry timings are a separate R4 concern.)\n`;
|
||||
}
|
||||
out += `\n> **Requires a seeded library** (not captured by this run): playback frame-time p95\n`;
|
||||
out += `> on the 2D + 3D highway, and screen-entry (plugin inject → interactive) for\n`;
|
||||
out += `> editor / notedetect / highway_3d with a real chart loaded. Run this harness\n`;
|
||||
out += `> against an environment with charts in \`DLC_DIR\` to fill those in.\n`;
|
||||
|
||||
console.log(out);
|
||||
|
||||
@@ -4877,15 +4877,6 @@ window.jucePlayer = jucePlayer;
|
||||
// value change (this runs on a 350ms poll — logging every tick would
|
||||
// flood the diagnostics buffer).
|
||||
let _loggedOutputType;
|
||||
// [asio-diag] verbose diagnostics, gated on --debug (preload exposes
|
||||
// audio.debugEnabled). Resolved once at install; until it resolves the
|
||||
// flag stays false and verbose lines are skipped. Shared with the
|
||||
// renderer-bus feeder below via window._asioDiagEnabled.
|
||||
let _asioDiag = false;
|
||||
if (typeof juceApi.debugEnabled === 'function') {
|
||||
juceApi.debugEnabled().then((v) => { _asioDiag = !!v; }).catch(() => {});
|
||||
}
|
||||
window._asioDiagEnabled = () => _asioDiag;
|
||||
async function _outputIsExclusive() {
|
||||
if (typeof juceApi.getCurrentDevice !== 'function') {
|
||||
if (_loggedOutputType !== '<no-getCurrentDevice>') {
|
||||
@@ -4901,15 +4892,6 @@ window.jucePlayer = jucePlayer;
|
||||
if (t !== _loggedOutputType) {
|
||||
_loggedOutputType = t;
|
||||
console.log('[feedpak-route] outputType=', JSON.stringify(t), '→ exclusive=', excl);
|
||||
// [asio-diag] full device object on every type change — shows
|
||||
// the exact strings the predicate saw (inputType vs outputType,
|
||||
// device names, duplex), so a driver reporting a non-'ASIO'
|
||||
// type name is visible in tester logs.
|
||||
if (_asioDiag) {
|
||||
try {
|
||||
console.log('[asio-diag] getCurrentDevice=', JSON.stringify(dev));
|
||||
} catch (_) { /* circular/hostile object — skip */ }
|
||||
}
|
||||
}
|
||||
return excl;
|
||||
} catch (e) {
|
||||
@@ -5394,13 +5376,6 @@ window.jucePlayer = jucePlayer;
|
||||
if (typeof ctx.setSinkId !== 'function') throw new Error('setSinkId unsupported');
|
||||
await ctx.setSinkId(exclusive ? { type: 'none' } : '');
|
||||
if (ctx.state !== 'running') await ctx.resume().catch(() => {});
|
||||
// [asio-diag] a context left on the default sink while the bus is
|
||||
// engaged is exactly the "song on the wrong device" symptom — record
|
||||
// every successful sink flip (failures throw and are logged upstream).
|
||||
if (window._asioDiagEnabled?.()) {
|
||||
console.log('[asio-diag] setSink:', exclusive ? 'null-sink' : 'default',
|
||||
'state=', ctx.state, 'rate=', ctx.sampleRate);
|
||||
}
|
||||
}
|
||||
|
||||
async function _disengage() {
|
||||
@@ -5471,24 +5446,6 @@ window.jucePlayer = jucePlayer;
|
||||
else if (elementSong) want = 'element';
|
||||
}
|
||||
|
||||
// [asio-diag] full decision vector, change-gated (500ms poll —
|
||||
// steady state must not flood the buffer). This is the feeder-side
|
||||
// counterpart of the watcher's [feedpak-route] decision line: it
|
||||
// shows WHY the bus did or didn't engage (exclusive predicate,
|
||||
// stems graph presence, native transport ownership, element song).
|
||||
if (window._asioDiagEnabled?.()) {
|
||||
const d = 'running=' + running + ' exclusive=' + exclusive
|
||||
+ ' stems=' + !!stems + ' songAudio=' + !!songAudio
|
||||
+ ' juceMode=' + !!window._juceMode
|
||||
+ ' elementSong=' + elementSong
|
||||
+ ' want=' + want + ' mode=' + _mode;
|
||||
if (d !== window._lastRendererBusDecision) {
|
||||
window._lastRendererBusDecision = d;
|
||||
console.log('[asio-diag] renderer-bus:', d);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const stemsGraphChanged = _mode === 'stems' && stems !== _stemsGraph;
|
||||
if (want !== _mode || stemsGraphChanged) {
|
||||
await _disengage();
|
||||
|
||||
+891
-894
File diff suppressed because it is too large
Load Diff
@@ -42,7 +42,7 @@ test('beats:loaded emit is wired into the WS beats case', () => {
|
||||
);
|
||||
assert.match(
|
||||
block,
|
||||
/count:\s*hwState\.beats\.length/,
|
||||
/count:\s*beats\.length/,
|
||||
'beats:loaded payload must include count = beats.length',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -33,7 +33,7 @@ test('handshapes WS case accumulates incoming chunks into handShapes', () => {
|
||||
const block = getCaseBlock(src, 'handshapes');
|
||||
assert.match(
|
||||
block,
|
||||
/hwState\.handShapes\s*=\s*hwState\.handShapes\.concat\(\s*msg\.data\s*\)/,
|
||||
/handShapes\s*=\s*handShapes\.concat\(\s*msg\.data\s*\)/,
|
||||
'handshapes case must concat msg.data into the handShapes accumulator',
|
||||
);
|
||||
});
|
||||
@@ -62,7 +62,7 @@ test('bundle exposes handShapes to renderers with flat-list fallback', () => {
|
||||
const src = fs.readFileSync(HIGHWAY_JS, 'utf8');
|
||||
assert.match(
|
||||
src,
|
||||
/\bhandShapes\s*[:=]\s*\([^)]*hwState\._filteredHandShapes[^)]*\)\s*\?\s*hwState\._filteredHandShapes\s*:\s*hwState\.handShapes\b/,
|
||||
/\bhandShapes\s*[:=]\s*\([^)]*_filteredHandShapes[^)]*\)\s*\?\s*_filteredHandShapes\s*:\s*handShapes\b/,
|
||||
'bundle must expose handShapes with the _filteredHandShapes-vs-handShapes ternary fallback',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -20,7 +20,7 @@ function src(file) {
|
||||
test('highway renderer bundles surface the core lefty flag', () => {
|
||||
assert.match(
|
||||
src(HIGHWAY_JS),
|
||||
/lefty\s*[:=]\s*hwState\._lefty/,
|
||||
/lefty\s*[:=]\s*_lefty/,
|
||||
'custom renderer bundles must include lefty: _lefty',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -49,12 +49,12 @@ test('core _makeBundle exposes isPlaying derived from the chart-clock anchor', (
|
||||
// within the interp cap.
|
||||
assert.match(
|
||||
fn,
|
||||
/isPlaying\s*[:=]\s*!Number\.isNaN\(\s*hwState\._chartAnchorPerfNow\s*\)/,
|
||||
/isPlaying\s*[:=]\s*!Number\.isNaN\(\s*_chartAnchorPerfNow\s*\)/,
|
||||
'isPlaying must gate on a live anchor (_chartAnchorPerfNow not NaN)',
|
||||
);
|
||||
assert.match(
|
||||
fn,
|
||||
/hwState\._chartLastAdvanceAt\s*\)\s*<=\s*_CHART_MAX_INTERP_MS/,
|
||||
/_chartLastAdvanceAt\s*\)\s*<=\s*_CHART_MAX_INTERP_MS/,
|
||||
'isPlaying must require the clock advanced within _CHART_MAX_INTERP_MS',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -29,7 +29,7 @@ function extractBlock(src, signature) {
|
||||
|
||||
test('highway declares adaptive-scale state with a floor', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
assert.match(src, /hwState\._autoScale\s*=\s*1/, 'missing _autoScale multiplier');
|
||||
assert.match(src, /let\s+_autoScale\s*=\s*1/, 'missing _autoScale multiplier');
|
||||
assert.match(src, /const\s+_AUTO_SCALE_MIN\s*=\s*0?\.25/, 'missing _AUTO_SCALE_MIN floor (0.25)');
|
||||
assert.match(src, /const\s+_DRAW_BUDGET_HI_MS\s*=\s*\d+/, 'missing high draw budget');
|
||||
assert.match(src, /const\s+_DRAW_BUDGET_LO_MS\s*=\s*\d+/, 'missing low draw budget');
|
||||
@@ -49,18 +49,18 @@ test('_effectiveRenderScale clamps user ceiling * auto factor to [MIN, 1]', () =
|
||||
test('min render scale floor is user-configurable + exposed on the api (#654)', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
// Hard floor constant kept; configurable floor read from localStorage.
|
||||
assert.match(src, /hwState\._autoScaleMin\s*=/, 'missing configurable _autoScaleMin');
|
||||
assert.match(src, /let\s+_autoScaleMin\s*=/, 'missing configurable _autoScaleMin');
|
||||
assert.match(src, /localStorage\.getItem\('highwayMinRenderScale'\)/,
|
||||
'configurable floor must load from localStorage.highwayMinRenderScale');
|
||||
assert.match(src, /setMinRenderScale\(/, 'api.setMinRenderScale missing');
|
||||
assert.match(src, /getMinRenderScale\(\)\s*\{\s*return\s+hwState\._autoScaleMin/, 'api.getMinRenderScale missing');
|
||||
assert.match(src, /getMinRenderScale\(\)\s*\{\s*return\s+_autoScaleMin/, 'api.getMinRenderScale missing');
|
||||
// Floor is clamped to the user ceiling so it can never exceed the manual cap.
|
||||
const eff = extractBlock(src, 'function _effectiveRenderScale()');
|
||||
assert.match(eff, /Math\.min\(\s*hwState\._autoScaleMin\s*,\s*user\s*\)/,
|
||||
assert.match(eff, /Math\.min\(\s*_autoScaleMin\s*,\s*user\s*\)/,
|
||||
'effective scale must clamp the floor to the user ceiling');
|
||||
// _adaptRenderScale must cap the lo bound at 1 so _autoScale stays in [_,1].
|
||||
const adapt = extractBlock(src, 'function _adaptRenderScale(');
|
||||
assert.match(adapt, /Math\.min\(\s*1\s*,\s*hwState\._autoScaleMin\s*\/\s*hwState\._renderScale\s*\)/,
|
||||
assert.match(adapt, /Math\.min\(\s*1\s*,\s*_autoScaleMin\s*\/\s*_renderScale\s*\)/,
|
||||
'lo bound must be capped at 1 to keep _autoScale a [0,1] multiplier');
|
||||
});
|
||||
|
||||
|
||||
@@ -34,9 +34,9 @@ test('_ensureChordRenderCache keys off src, _inverted, AND chordTemplates', () =
|
||||
const neqEither = (a, b) => new RegExp(
|
||||
`\\b${a}\\b\\s*!==\\s*\\b${b}\\b|\\b${b}\\b\\s*!==\\s*\\b${a}\\b`
|
||||
);
|
||||
assert.match(src, eqEither('hwState\\._chordRenderCacheSrc', 'src'), 'cache must key on src');
|
||||
assert.match(src, eqEither('hwState\\._chordRenderCacheInverted', 'hwState\\._inverted'), 'cache must key on _inverted');
|
||||
assert.match(src, neqEither('hwState\\._chordRenderCacheTemplates', 'hwState\\.chordTemplates'),
|
||||
assert.match(src, eqEither('_chordRenderCacheSrc', 'src'), 'cache must key on src');
|
||||
assert.match(src, eqEither('_chordRenderCacheInverted', '_inverted'), 'cache must key on _inverted');
|
||||
assert.match(src, neqEither('_chordRenderCacheTemplates', 'chordTemplates'),
|
||||
'cache must key on chordTemplates (detected via !== for change-flag)');
|
||||
});
|
||||
|
||||
@@ -50,9 +50,9 @@ test('chordTemplates change resets fretline preview and frame-mismatch warner',
|
||||
// block inside the `if (templatesChanged) { … }` branch (e.g. an
|
||||
// inner conditional reset) doesn't break the match by introducing
|
||||
// a `}` before the symbol we're checking for.
|
||||
assert.match(src, /if\s*\(\s*templatesChanged\s*\)\s*\{[\s\S]*?hwState\._chordFretLineNotes\s*=\s*\[\][\s\S]*?\}/,
|
||||
assert.match(src, /if\s*\(\s*templatesChanged\s*\)\s*\{[\s\S]*?_chordFretLineNotes\s*=\s*\[\][\s\S]*?\}/,
|
||||
'templatesChanged branch must reset _chordFretLineNotes');
|
||||
assert.match(src, /if\s*\(\s*templatesChanged\s*\)\s*\{[\s\S]*?hwState\._lastChordOnFretLine\s*=\s*null[\s\S]*?\}/,
|
||||
assert.match(src, /if\s*\(\s*templatesChanged\s*\)\s*\{[\s\S]*?_lastChordOnFretLine\s*=\s*null[\s\S]*?\}/,
|
||||
'templatesChanged branch must null _lastChordOnFretLine');
|
||||
assert.match(src, /if\s*\(\s*templatesChanged\s*\)\s*\{[\s\S]*?_frameMismatchWarned\.clear\(\)[\s\S]*?\}/,
|
||||
'templatesChanged branch must clear _frameMismatchWarned');
|
||||
|
||||
@@ -23,7 +23,7 @@ test('getFilteredNotes falls through to notes when _filteredNotes is null', () =
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
assert.match(
|
||||
src,
|
||||
/getFilteredNotes\s*\(\s*\)\s*\{[^}]*_filteredNotes[^}]*:\s*hwState\.notes/,
|
||||
/getFilteredNotes\s*\(\s*\)\s*\{[^}]*_filteredNotes[^}]*:\s*notes/,
|
||||
'getFilteredNotes must return notes as fallback',
|
||||
);
|
||||
});
|
||||
@@ -41,7 +41,7 @@ test('getFilteredChords falls through to chords when _filteredChords is null', (
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
assert.match(
|
||||
src,
|
||||
/getFilteredChords\s*\(\s*\)\s*\{[^}]*_filteredChords[^}]*:\s*hwState\.chords/,
|
||||
/getFilteredChords\s*\(\s*\)\s*\{[^}]*_filteredChords[^}]*:\s*chords/,
|
||||
'getFilteredChords must return chords as fallback',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -35,10 +35,7 @@ function extractBlock(src, signature) {
|
||||
// + getTime methods so behavioral tests can exercise the real
|
||||
// implementation in isolation.
|
||||
function buildClockSandbox(perfNowImpl) {
|
||||
// The lifted per-instance state now lives on `hwState` (the R3c H lift);
|
||||
// the extracted setTime/getTime bodies reference hwState.<slot>. The const
|
||||
// _CHART_MAX_INTERP_MS was NOT lifted, so it stays a top-level global here.
|
||||
const hwState = {
|
||||
const sandbox = {
|
||||
chartTime: 0,
|
||||
currentTime: 0,
|
||||
avOffsetSec: 0,
|
||||
@@ -53,9 +50,6 @@ function buildClockSandbox(perfNowImpl) {
|
||||
_chartAnchorPerfNow: NaN,
|
||||
_chartLastAdvanceAt: 0,
|
||||
_chartObservedRate: 1,
|
||||
};
|
||||
const sandbox = {
|
||||
hwState,
|
||||
_CHART_MAX_INTERP_MS: 100,
|
||||
performance: { now: perfNowImpl },
|
||||
};
|
||||
@@ -78,10 +72,10 @@ test('highway declares chart anchor + stall-detect + rate state', () => {
|
||||
// particular MUST start as NaN, not 0, otherwise setTime(0) on the
|
||||
// very first 60 Hz tick fails the `t !== _chartAnchorAudioT` check
|
||||
// and never re-anchors, leaving the clock uninitialized.
|
||||
assert.match(src, /hwState\._chartAnchorAudioT\s*=\s*NaN/, 'missing _chartAnchorAudioT (NaN sentinel)');
|
||||
assert.match(src, /hwState\._chartAnchorPerfNow\s*=\s*NaN/, 'missing _chartAnchorPerfNow (NaN sentinel)');
|
||||
assert.match(src, /hwState\._chartLastAdvanceAt\s*=\s*0/, 'missing _chartLastAdvanceAt (pause detection)');
|
||||
assert.match(src, /hwState\._chartObservedRate\s*=\s*1/, 'missing _chartObservedRate (playback rate awareness)');
|
||||
assert.match(src, /let\s+_chartAnchorAudioT\s*=\s*NaN/, 'missing _chartAnchorAudioT (NaN sentinel)');
|
||||
assert.match(src, /let\s+_chartAnchorPerfNow\s*=\s*NaN/, 'missing _chartAnchorPerfNow (NaN sentinel)');
|
||||
assert.match(src, /let\s+_chartLastAdvanceAt\s*=\s*0/, 'missing _chartLastAdvanceAt (pause detection)');
|
||||
assert.match(src, /let\s+_chartObservedRate\s*=\s*1/, 'missing _chartObservedRate (playback rate awareness)');
|
||||
assert.match(src, /const\s+_CHART_MAX_INTERP_MS\s*=\s*100/, 'missing _CHART_MAX_INTERP_MS cap');
|
||||
});
|
||||
|
||||
@@ -92,7 +86,7 @@ test('getTime scales interpolation by _chartObservedRate (speed-slider safe)', (
|
||||
const slice = m[0];
|
||||
assert.match(
|
||||
slice,
|
||||
/hwState\._chartObservedRate\s*\*\s*elapsedMs/,
|
||||
/_chartObservedRate\s*\*\s*elapsedMs/,
|
||||
'getTime must scale interpolation by observed rate so audio.playbackRate != 1 stays accurate',
|
||||
);
|
||||
});
|
||||
@@ -106,12 +100,12 @@ test('setTime re-anchors and updates _chartLastAdvanceAt only when t actually ch
|
||||
// The implementation may capture performance.now() into a local
|
||||
// (e.g. newPerfNow) and assign that to both fields; accept either
|
||||
// direct or via-local writes.
|
||||
const m = src.match(/if\s*\(\s*t\s*!==\s*hwState\._chartAnchorAudioT\s*\)\s*\{[\s\S]+?\}\s*\},/);
|
||||
const m = src.match(/if\s*\(\s*t\s*!==\s*_chartAnchorAudioT\s*\)\s*\{[\s\S]+?\}\s*\},/);
|
||||
assert.ok(m, 'if (t !== _chartAnchorAudioT) block not found inside setTime');
|
||||
const block = m[0];
|
||||
assert.match(block, /hwState\._chartAnchorAudioT\s*=\s*t/, 'must assign _chartAnchorAudioT = t');
|
||||
assert.match(block, /hwState\._chartAnchorPerfNow\s*=/, 'must assign _chartAnchorPerfNow');
|
||||
assert.match(block, /hwState\._chartLastAdvanceAt\s*=/, 'must assign _chartLastAdvanceAt');
|
||||
assert.match(block, /_chartAnchorAudioT\s*=\s*t/, 'must assign _chartAnchorAudioT = t');
|
||||
assert.match(block, /_chartAnchorPerfNow\s*=/, 'must assign _chartAnchorPerfNow');
|
||||
assert.match(block, /_chartLastAdvanceAt\s*=/, 'must assign _chartLastAdvanceAt');
|
||||
});
|
||||
|
||||
test('getTime falls back to chartTime when audio has stalled (paused)', () => {
|
||||
@@ -125,7 +119,7 @@ test('getTime falls back to chartTime when audio has stalled (paused)', () => {
|
||||
// Must check stall-since-last-advance against the cap.
|
||||
assert.match(
|
||||
slice,
|
||||
/nowP\s*-\s*hwState\._chartLastAdvanceAt\s*>\s*_CHART_MAX_INTERP_MS/,
|
||||
/nowP\s*-\s*_chartLastAdvanceAt\s*>\s*_CHART_MAX_INTERP_MS/,
|
||||
'getTime must short-circuit when audio has stalled past the cap',
|
||||
);
|
||||
// Must interpolate when active.
|
||||
@@ -133,7 +127,7 @@ test('getTime falls back to chartTime when audio has stalled (paused)', () => {
|
||||
// Rate-scaled formula: _chartAnchorAudioT + (_chartObservedRate * elapsedMs) / 1000
|
||||
assert.match(
|
||||
slice,
|
||||
/_chartAnchorAudioT\s*\+\s*\(\s*hwState\._chartObservedRate\s*\*\s*elapsedMs\s*\)\s*\/\s*1000/,
|
||||
/_chartAnchorAudioT\s*\+\s*\(\s*_chartObservedRate\s*\*\s*elapsedMs\s*\)\s*\/\s*1000/,
|
||||
'getTime must compute anchor + rate-scaled elapsed during play',
|
||||
);
|
||||
});
|
||||
@@ -144,10 +138,10 @@ test('api.stop() clears the chart anchor state so re-init starts fresh', () => {
|
||||
// the actual stop() body — a fixed-size slice would falsely match
|
||||
// resets that landed in an adjacent method.
|
||||
const stopBlock = extractBlock(src, 'stop() {');
|
||||
assert.match(stopBlock, /hwState\._chartAnchorAudioT\s*=\s*NaN/, 'stop() must reset _chartAnchorAudioT to the NaN sentinel');
|
||||
assert.match(stopBlock, /hwState\._chartAnchorPerfNow\s*=\s*NaN/, 'stop() must reset _chartAnchorPerfNow to the NaN sentinel');
|
||||
assert.match(stopBlock, /hwState\._chartLastAdvanceAt\s*=\s*0/, 'stop() must reset _chartLastAdvanceAt');
|
||||
assert.match(stopBlock, /hwState\._chartObservedRate\s*=\s*1/, 'stop() must reset _chartObservedRate to 1x');
|
||||
assert.match(stopBlock, /_chartAnchorAudioT\s*=\s*NaN/, 'stop() must reset _chartAnchorAudioT to the NaN sentinel');
|
||||
assert.match(stopBlock, /_chartAnchorPerfNow\s*=\s*NaN/, 'stop() must reset _chartAnchorPerfNow to the NaN sentinel');
|
||||
assert.match(stopBlock, /_chartLastAdvanceAt\s*=\s*0/, 'stop() must reset _chartLastAdvanceAt');
|
||||
assert.match(stopBlock, /_chartObservedRate\s*=\s*1/, 'stop() must reset _chartObservedRate to 1x');
|
||||
});
|
||||
|
||||
// ── Behavioral tests (run extracted setTime/getTime in vm sandbox) ──────
|
||||
@@ -201,12 +195,12 @@ test('behavior: seek discontinuity resets observed rate to 1x', () => {
|
||||
sb.setTime(10);
|
||||
now = 50;
|
||||
sb.setTime(10.025); // observed rate ≈ 0.5
|
||||
assert.ok(Math.abs(sb.hwState._chartObservedRate - 0.5) < 0.001, `prior segment must measure ≈0.5, got ${sb.hwState._chartObservedRate}`);
|
||||
assert.ok(Math.abs(sb._chartObservedRate - 0.5) < 0.001, `prior segment must measure ≈0.5, got ${sb._chartObservedRate}`);
|
||||
// Seek: large t jump in same perf delta — observed-rate clamp
|
||||
// rejects this segment, resets to 1.
|
||||
now = 70;
|
||||
sb.setTime(120); // dPerf=20ms, dT=110s → observed=5500 (out of clamp)
|
||||
assert.equal(sb.hwState._chartObservedRate, 1, 'seek must reset rate to 1x');
|
||||
assert.equal(sb._chartObservedRate, 1, 'seek must reset rate to 1x');
|
||||
});
|
||||
|
||||
test('behavior: getTime caps interpolation at _CHART_MAX_INTERP_MS', () => {
|
||||
@@ -231,8 +225,8 @@ test('behavior: setTime(0) on first tick anchors correctly (boot edge case)', ()
|
||||
const sb = buildClockSandbox(() => now);
|
||||
sb.setTime(0);
|
||||
// Anchor must now be initialized.
|
||||
assert.equal(sb.hwState._chartAnchorAudioT, 0, 'setTime(0) on first tick must set anchor.audioT');
|
||||
assert.equal(sb.hwState._chartAnchorPerfNow, 16, 'setTime(0) on first tick must set anchor.perfNow');
|
||||
assert.equal(sb._chartAnchorAudioT, 0, 'setTime(0) on first tick must set anchor.audioT');
|
||||
assert.equal(sb._chartAnchorPerfNow, 16, 'setTime(0) on first tick must set anchor.perfNow');
|
||||
// getTime should return a finite value, not NaN.
|
||||
const t = sb.getTime();
|
||||
assert.ok(!Number.isNaN(t), `getTime must not return NaN after setTime(0); got ${t}`);
|
||||
@@ -258,10 +252,10 @@ test('behavior: long anchor gap resets observed rate to 1x', () => {
|
||||
sb.setTime(10);
|
||||
now = 50;
|
||||
sb.setTime(10.025); // observed rate ≈ 0.5
|
||||
assert.ok(Math.abs(sb.hwState._chartObservedRate - 0.5) < 0.001, 'first segment measured 0.5x');
|
||||
assert.ok(Math.abs(sb._chartObservedRate - 0.5) < 0.001, 'first segment measured 0.5x');
|
||||
// Long gap (1 second) before next setTime — out of the dPerf < 0.5
|
||||
// window, so the rate must reset to 1.
|
||||
now = 1100;
|
||||
sb.setTime(10.5);
|
||||
assert.equal(sb.hwState._chartObservedRate, 1, 'long anchor gap must reset rate to 1x');
|
||||
assert.equal(sb._chartObservedRate, 1, 'long anchor gap must reset rate to 1x');
|
||||
});
|
||||
|
||||
@@ -34,16 +34,16 @@ function extractBlock(src, signature) {
|
||||
|
||||
test('highway declares the note-state provider slot', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
assert.match(src, /hwState\._noteStateProvider\s*=\s*null/, 'missing _noteStateProvider (provider slot, null = none)');
|
||||
assert.match(src, /let\s+_noteStateProvider\s*=\s*null/, 'missing _noteStateProvider (provider slot, null = none)');
|
||||
});
|
||||
|
||||
test('public API exposes setNoteStateProvider / getNoteStateProvider / getNoteState / isDefaultRenderer', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
assert.match(src, /setNoteStateProvider\s*\(\s*fn\s*\)\s*\{[^}]*hwState\._noteStateProvider\s*=/, 'setNoteStateProvider must assign _noteStateProvider');
|
||||
assert.match(src, /setNoteStateProvider\s*\(\s*fn\s*\)\s*\{[^}]*_noteStateProvider\s*=/, 'setNoteStateProvider must assign _noteStateProvider');
|
||||
assert.match(src, /setNoteStateProvider\s*\(\s*fn\s*\)\s*\{[^}]*typeof\s+fn\s*===\s*['"]function['"][^}]*:\s*null/, 'setNoteStateProvider must coerce non-functions (incl. null) to null');
|
||||
assert.match(src, /getNoteStateProvider\s*\(\s*\)\s*\{\s*return\s+hwState\._noteStateProvider/, 'getNoteStateProvider must return the slot');
|
||||
assert.match(src, /getNoteStateProvider\s*\(\s*\)\s*\{\s*return\s+_noteStateProvider/, 'getNoteStateProvider must return the slot');
|
||||
assert.match(src, /getNoteState\s*\(\s*note\s*,\s*chartTime\s*\)\s*\{\s*return\s+_noteState\s*\(/, 'getNoteState must delegate to _noteState');
|
||||
assert.match(src, /isDefaultRenderer\s*\(\s*\)\s*\{\s*return\s+hwState\._renderer\s*===\s*_defaultRenderer\s*\|\|\s*hwState\._renderer\s*==\s*null/, 'isDefaultRenderer must be (_renderer === _defaultRenderer || _renderer == null)');
|
||||
assert.match(src, /isDefaultRenderer\s*\(\s*\)\s*\{\s*return\s+_renderer\s*===\s*_defaultRenderer\s*\|\|\s*_renderer\s*==\s*null/, 'isDefaultRenderer must be (_renderer === _defaultRenderer || _renderer == null)');
|
||||
});
|
||||
|
||||
test('_makeBundle exposes getNoteState (stable reference, no per-frame alloc)', () => {
|
||||
@@ -72,7 +72,7 @@ test('_makeBundle exposes getNoteStateProvider as a stable reference (feedBack#2
|
||||
// slot, so renderers see a live "is a provider registered?" view.
|
||||
assert.match(
|
||||
src,
|
||||
/function\s+_getNoteStateProvider\s*\(\s*\)\s*\{\s*return\s+hwState\._noteStateProvider\s*;?\s*\}/,
|
||||
/function\s+_getNoteStateProvider\s*\(\s*\)\s*\{\s*return\s+_noteStateProvider\s*;?\s*\}/,
|
||||
'_getNoteStateProvider must be defined as a stable named function returning _noteStateProvider'
|
||||
);
|
||||
});
|
||||
@@ -80,7 +80,7 @@ test('_makeBundle exposes getNoteStateProvider as a stable reference (feedBack#2
|
||||
test('_noteState normalizes provider output as documented', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const fn = extractBlock(src, 'function _noteState(note, chartTime)');
|
||||
assert.match(fn, /if\s*\(\s*!hwState\._noteStateProvider\s*\)\s*return\s+null/, 'must short-circuit when no provider is registered');
|
||||
assert.match(fn, /if\s*\(\s*!_noteStateProvider\s*\)\s*return\s+null/, 'must short-circuit when no provider is registered');
|
||||
assert.match(fn, /try\s*\{[\s\S]*_noteStateProvider\s*\([\s\S]*catch[\s\S]*return\s+null/, 'must call the provider inside try/catch and return null on throw');
|
||||
assert.match(fn, /state\s*!==\s*['"]hit['"]\s*&&\s*state\s*!==\s*['"]active['"]\s*&&\s*state\s*!==\s*['"]miss['"]/, 'must reject states other than hit/active/miss');
|
||||
assert.match(fn, /Math\.max\(\s*0\s*,\s*Math\.min\(\s*1\s*,\s*raw\.alpha\s*\)\s*\)/, 'must clamp alpha to [0,1]');
|
||||
|
||||
@@ -32,7 +32,7 @@ function extractBlock(src, signature) {
|
||||
test('highway declares the paused-render throttle state', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
assert.match(src, /const\s+_PAUSED_FRAME_INTERVAL_MS\s*=\s*\d+/, 'missing _PAUSED_FRAME_INTERVAL_MS cap');
|
||||
assert.match(src, /hwState\._lastPausedDrawAt\s*=\s*0/, 'missing _lastPausedDrawAt accumulator');
|
||||
assert.match(src, /let\s+_lastPausedDrawAt\s*=\s*0/, 'missing _lastPausedDrawAt accumulator');
|
||||
});
|
||||
|
||||
test('draw() throttles full renders while the audio clock is stalled', () => {
|
||||
@@ -42,7 +42,7 @@ test('draw() throttles full renders while the audio clock is stalled', () => {
|
||||
assert.match(fn, /_chartLastAdvanceAt/, 'throttle must key off _chartLastAdvanceAt (the advance timestamp)');
|
||||
assert.match(fn, /_CHART_MAX_INTERP_MS/, 'throttle must reuse the _CHART_MAX_INTERP_MS pause threshold');
|
||||
assert.match(fn, /_PAUSED_FRAME_INTERVAL_MS/, 'throttle must cap paused draws to _PAUSED_FRAME_INTERVAL_MS');
|
||||
assert.match(fn, /hwState\._lastPausedDrawAt\s*=\s*_nowP/, 'throttle must record the last paused draw time');
|
||||
assert.match(fn, /_lastPausedDrawAt\s*=\s*_nowP/, 'throttle must record the last paused draw time');
|
||||
});
|
||||
|
||||
test('throttle runs after the ready gate, before bundle/draw', () => {
|
||||
@@ -51,7 +51,7 @@ test('throttle runs after the ready gate, before bundle/draw', () => {
|
||||
// Regex landmarks (not exact-string indexOf) so harmless spacing /
|
||||
// semicolon changes don't break the ordering guard — matches the
|
||||
// search-based style of the other highway source-guard tests.
|
||||
const readyIdx = fn.search(/if\s*\(\s*!hwState\.ready\s*\)\s*return;/);
|
||||
const readyIdx = fn.search(/if\s*\(\s*!ready\s*\)\s*return;/);
|
||||
const throttleIdx = fn.search(/_PAUSED_FRAME_INTERVAL_MS/);
|
||||
const drawIdx = fn.search(/_renderer\.draw\s*\(/);
|
||||
assert.ok(readyIdx !== -1, 'ready gate not found');
|
||||
|
||||
@@ -22,7 +22,7 @@ test('getPhrases returns null when _phrases is falsy or empty', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
assert.match(
|
||||
src,
|
||||
/getPhrases\s*\(\s*\)\s*\{[^}]*!\s*hwState\._phrases[^}]*return null/,
|
||||
/getPhrases\s*\(\s*\)\s*\{[^}]*!\s*_phrases[^}]*return null/,
|
||||
'getPhrases must return null when no phrase data is available',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -44,9 +44,9 @@ test('_setRenderer captures the outgoing renderer before overwriting it', () =>
|
||||
// prev must be captured BEFORE _destroyCurrentIfInited and the
|
||||
// `_renderer = next` assignment, otherwise the swap detection below
|
||||
// would always compare next against itself.
|
||||
const prevIdx = fn.search(/const\s+prev\s*=\s*hwState\._renderer/);
|
||||
const prevIdx = fn.search(/const\s+prev\s*=\s*_renderer/);
|
||||
const destroyIdx = fn.search(/_destroyCurrentIfInited\(\)/);
|
||||
const assignIdx = fn.search(/^\s*hwState\._renderer\s*=\s*next\s*;/m);
|
||||
const assignIdx = fn.search(/^\s*_renderer\s*=\s*next\s*;/m);
|
||||
assert.ok(prevIdx !== -1, 'must capture `const prev = _renderer`');
|
||||
assert.ok(destroyIdx !== -1, 'must call _destroyCurrentIfInited');
|
||||
assert.ok(assignIdx !== -1, 'must assign `_renderer = next`');
|
||||
@@ -67,7 +67,7 @@ test('_setRenderer replaces the canvas on a context-type change OR a viz change'
|
||||
);
|
||||
assert.match(
|
||||
fn,
|
||||
/if\s*\(\s*nextType\s*!==\s*hwState\._currentCanvasContextType\s*\|\|\s*_vizChanged\s*\)\s*\{\s*_replaceCanvas\(nextType\)/,
|
||||
/if\s*\(\s*nextType\s*!==\s*_currentCanvasContextType\s*\|\|\s*_vizChanged\s*\)\s*\{\s*_replaceCanvas\(nextType\)/,
|
||||
'replace guard must be `nextType !== _currentCanvasContextType || _vizChanged`',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -40,20 +40,20 @@ test('2D palette arrays are mutable (let) with frozen DEFAULT_* originals', () =
|
||||
assert.match(src, /const\s+DEFAULT_STRING_COLORS\s*=/, 'DEFAULT_STRING_COLORS must exist for reset');
|
||||
assert.match(src, /const\s+DEFAULT_STRING_DIM\s*=/, 'DEFAULT_STRING_DIM must exist for reset');
|
||||
assert.match(src, /const\s+DEFAULT_STRING_BRIGHT\s*=/, 'DEFAULT_STRING_BRIGHT must exist for reset');
|
||||
assert.match(src, /hwState\.STRING_COLORS\s*=\s*DEFAULT_STRING_COLORS\.slice\(\)/, 'STRING_COLORS must be a mutable copy of the defaults');
|
||||
assert.match(src, /hwState\.STRING_DIM\s*=\s*DEFAULT_STRING_DIM\.slice\(\)/, 'STRING_DIM must be a mutable copy of the defaults');
|
||||
assert.match(src, /hwState\.STRING_BRIGHT\s*=\s*DEFAULT_STRING_BRIGHT\.slice\(\)/, 'STRING_BRIGHT must be a mutable copy of the defaults');
|
||||
assert.match(src, /let\s+STRING_COLORS\s*=\s*DEFAULT_STRING_COLORS\.slice\(\)/, 'STRING_COLORS must be a mutable copy of the defaults');
|
||||
assert.match(src, /let\s+STRING_DIM\s*=\s*DEFAULT_STRING_DIM\.slice\(\)/, 'STRING_DIM must be a mutable copy of the defaults');
|
||||
assert.match(src, /let\s+STRING_BRIGHT\s*=\s*DEFAULT_STRING_BRIGHT\.slice\(\)/, 'STRING_BRIGHT must be a mutable copy of the defaults');
|
||||
});
|
||||
|
||||
test('2D public API exposes getStringColors / setStringColors', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
assert.match(src, /getStringColors\s*\(\s*\)\s*\{\s*return\s+hwState\.STRING_COLORS\.slice\(\)/, 'getStringColors must return a copy');
|
||||
assert.match(src, /getStringColors\s*\(\s*\)\s*\{\s*return\s+STRING_COLORS\.slice\(\)/, 'getStringColors must return a copy');
|
||||
const fn = extractBlock(src, 'setStringColors(arr)');
|
||||
// Each provided index sets base + derived dim/bright; missing → default.
|
||||
assert.match(fn, /hwState\.STRING_COLORS\[i\]\s*=\s*base/, 'setStringColors must set the base color');
|
||||
assert.match(fn, /hwState\.STRING_DIM\[i\]\s*=\s*_darken\(/, 'setStringColors must derive the dim variant');
|
||||
assert.match(fn, /hwState\.STRING_BRIGHT\[i\]\s*=\s*_lighten\(/, 'setStringColors must derive the bright variant');
|
||||
assert.match(fn, /hwState\.STRING_COLORS\[i\]\s*=\s*DEFAULT_STRING_COLORS\[i\]/, 'setStringColors must restore defaults for missing/invalid indices');
|
||||
assert.match(fn, /STRING_COLORS\[i\]\s*=\s*base/, 'setStringColors must set the base color');
|
||||
assert.match(fn, /STRING_DIM\[i\]\s*=\s*_darken\(/, 'setStringColors must derive the dim variant');
|
||||
assert.match(fn, /STRING_BRIGHT\[i\]\s*=\s*_lighten\(/, 'setStringColors must derive the bright variant');
|
||||
assert.match(fn, /STRING_COLORS\[i\]\s*=\s*DEFAULT_STRING_COLORS\[i\]/, 'setStringColors must restore defaults for missing/invalid indices');
|
||||
});
|
||||
|
||||
// ── 3D highway (plugins/highway_3d/screen.js) ─────────────────────────────
|
||||
|
||||
@@ -32,14 +32,14 @@ function extractBlock(src, signature) {
|
||||
|
||||
test('highway declares visibility state (_visibleOverride + _lastVisible)', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
assert.match(src, /hwState\._visibleOverride\s*=\s*null/, 'missing _visibleOverride (override sentinel)');
|
||||
assert.match(src, /hwState\._lastVisible\s*=\s*null/, 'missing _lastVisible (last-emitted state)');
|
||||
assert.match(src, /let\s+_visibleOverride\s*=\s*null/, 'missing _visibleOverride (override sentinel)');
|
||||
assert.match(src, /let\s+_lastVisible\s*=\s*null/, 'missing _lastVisible (last-emitted state)');
|
||||
});
|
||||
|
||||
test('_isHighwayVisible respects _visibleOverride and falls back to offsetParent', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const fn = extractBlock(src, 'function _isHighwayVisible()');
|
||||
assert.match(fn, /hwState\._visibleOverride\s*!==\s*null/, 'must check the override before the DOM');
|
||||
assert.match(fn, /_visibleOverride\s*!==\s*null/, 'must check the override before the DOM');
|
||||
assert.match(fn, /canvas\.offsetParent\s*!==\s*null/, 'DOM fallback must use offsetParent !== null');
|
||||
});
|
||||
|
||||
@@ -47,9 +47,9 @@ test('_emitVisibilityIfChanged is transition-only (no per-frame spam)', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const fn = extractBlock(src, 'function _emitVisibilityIfChanged()');
|
||||
// Must short-circuit when the current state equals the cached one.
|
||||
assert.match(fn, /v\s*===\s*hwState\._lastVisible/, 'must compare current vs _lastVisible and bail when equal');
|
||||
assert.match(fn, /v\s*===\s*_lastVisible/, 'must compare current vs _lastVisible and bail when equal');
|
||||
// Must update the cache and emit the event with the documented payload shape.
|
||||
assert.match(fn, /hwState\._lastVisible\s*=\s*v/, 'must update _lastVisible after a transition');
|
||||
assert.match(fn, /_lastVisible\s*=\s*v/, 'must update _lastVisible after a transition');
|
||||
assert.match(
|
||||
fn,
|
||||
/window\.feedBack\.emit\(\s*['"]highway:visibility['"][\s\S]*?visible:\s*v[\s\S]*?canvas/,
|
||||
@@ -66,7 +66,7 @@ test('rAF draw() loop calls _emitVisibilityIfChanged and skips when hidden', ()
|
||||
// transitions during loading/reconnect windows still propagate.
|
||||
const emitIdx = fn.search(/_emitVisibilityIfChanged\(\)/);
|
||||
const skipIdx = fn.search(/if\s*\(\s*!_rendering\s*\)\s*return/);
|
||||
const readyIdx = fn.search(/if\s*\(\s*!hwState\.ready\s*\)\s*return/);
|
||||
const readyIdx = fn.search(/if\s*\(\s*!ready\s*\)\s*return/);
|
||||
const drawIdx = fn.search(/_renderer\.draw\(/);
|
||||
assert.ok(emitIdx !== -1 && skipIdx !== -1 && readyIdx !== -1 && drawIdx !== -1, 'all four landmarks must be present');
|
||||
assert.ok(emitIdx < readyIdx, 'emit must run BEFORE the !ready gate (transitions during loading must still fire)');
|
||||
@@ -84,19 +84,19 @@ test('draw() keeps an active custom renderer painting through an override-hide (
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const fn = extractBlock(src, 'function draw()');
|
||||
// Single render decision drives both the perf-HUD reset and the gate.
|
||||
assert.match(fn, /let\s+_rendering\s*=\s*hwState\._lastVisible/, 'must derive a single _rendering decision from _lastVisible');
|
||||
assert.match(fn, /let\s+_rendering\s*=\s*_lastVisible/, 'must derive a single _rendering decision from _lastVisible');
|
||||
// Assert the exact boolean RELATIONSHIP, not just the tokens (CodeRabbit):
|
||||
// the exemption must AND together override-hide, an active custom renderer,
|
||||
// and the canvas still in layout. A weakened guard (e.g. `||`, or a dropped
|
||||
// offsetParent clause) must fail this — that's the regression being fixed.
|
||||
assert.match(
|
||||
fn,
|
||||
/!_rendering\s*&&\s*hwState\._visibleOverride\s*===\s*false\s*&&\s*hwState\._renderer\s*!==\s*_defaultRenderer\s*&&\s*hwState\.canvas\s*&&\s*hwState\.canvas\.offsetParent\s*!==\s*null/,
|
||||
/!_rendering\s*&&\s*_visibleOverride\s*===\s*false\s*&&\s*_renderer\s*!==\s*_defaultRenderer\s*&&\s*canvas\s*&&\s*canvas\.offsetParent\s*!==\s*null/,
|
||||
'exemption must AND override-hide + active custom renderer + canvas-in-layout (genuine off-screen still pauses, #246)',
|
||||
);
|
||||
// Both the HUD reset and the gate key off _rendering, not _lastVisible,
|
||||
// so the HUD doesn\'t churn while the custom renderer is actually drawing.
|
||||
assert.match(fn, /hwState\._perfHud\s*&&\s*\(\s*!_rendering/, 'perf-HUD reset must key off _rendering, not _lastVisible');
|
||||
assert.match(fn, /_perfHud\s*&&\s*\(\s*!_rendering/, 'perf-HUD reset must key off _rendering, not _lastVisible');
|
||||
assert.match(fn, /if\s*\(\s*!_rendering\s*\)\s*return/, 'the draw gate must bail on !_rendering');
|
||||
});
|
||||
|
||||
|
||||
@@ -16,8 +16,6 @@ import pytest
|
||||
import yaml
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from routers.ws_highway import _sanitize_authors
|
||||
|
||||
|
||||
# ── _sanitize_authors unit tests ────────────────────────────────────────────
|
||||
|
||||
@@ -37,7 +35,7 @@ def server_mod(monkeypatch, tmp_path):
|
||||
|
||||
|
||||
def test_sanitize_authors_valid(server_mod):
|
||||
out = _sanitize_authors(
|
||||
out = server_mod._sanitize_authors(
|
||||
{
|
||||
"authors": [
|
||||
{"name": "Azure", "role": "charter", "email": "a@b.c", "url": "x"},
|
||||
@@ -55,7 +53,7 @@ def test_sanitize_authors_valid(server_mod):
|
||||
|
||||
|
||||
def test_sanitize_authors_skips_malformed(server_mod):
|
||||
out = _sanitize_authors(
|
||||
out = server_mod._sanitize_authors(
|
||||
{
|
||||
"authors": [
|
||||
{"name": ""}, # blank name → skipped
|
||||
@@ -71,7 +69,7 @@ def test_sanitize_authors_skips_malformed(server_mod):
|
||||
|
||||
@pytest.mark.parametrize("manifest", [None, {}, {"authors": None}, {"authors": "x"}, "nope"])
|
||||
def test_sanitize_authors_absent_or_nonlist(server_mod, manifest):
|
||||
assert _sanitize_authors(manifest) == []
|
||||
assert server_mod._sanitize_authors(manifest) == []
|
||||
|
||||
|
||||
# ── song_info WS integration ────────────────────────────────────────────────
|
||||
@@ -120,9 +118,6 @@ def make_client(tmp_path, monkeypatch):
|
||||
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")
|
||||
# ws_highway reads the cache dir through the appstate seam now.
|
||||
import appstate as _appstate
|
||||
monkeypatch.setattr(_appstate, "sloppak_cache_dir", tmp_path / "cache")
|
||||
return server
|
||||
|
||||
(tmp_path / "dlc").mkdir()
|
||||
|
||||
@@ -96,9 +96,6 @@ def make_client(tmp_path, monkeypatch):
|
||||
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")
|
||||
# ws_highway reads the cache dir through the appstate seam now.
|
||||
import appstate as _appstate
|
||||
monkeypatch.setattr(_appstate, "sloppak_cache_dir", tmp_path / "cache")
|
||||
return server
|
||||
|
||||
(tmp_path / "dlc").mkdir()
|
||||
|
||||
@@ -123,9 +123,6 @@ def make_client(tmp_path, monkeypatch):
|
||||
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")
|
||||
# ws_highway reads the cache dir through the appstate seam now.
|
||||
import appstate as _appstate
|
||||
monkeypatch.setattr(_appstate, "sloppak_cache_dir", tmp_path / "cache")
|
||||
return server
|
||||
|
||||
(tmp_path / "dlc").mkdir()
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
"""Concurrent unnamed loop saves must get unique names.
|
||||
|
||||
`save_loop` auto-names an unnamed loop `Loop {count+1}`. If the `COUNT(*)` runs
|
||||
outside the DB lock (as it did before the fix), two simultaneous unnamed POSTs
|
||||
read the same count and both mint the same name. A `threading.Barrier` releases
|
||||
all workers into `save_loop` at once to force that interleave.
|
||||
|
||||
Single-user app, so this race is unlikely in practice — but the fix is one lock
|
||||
scope, and the test pins it.
|
||||
"""
|
||||
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
|
||||
import appstate
|
||||
from metadata_db import MetadataDB
|
||||
from routers import loops
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def meta_db(tmp_path):
|
||||
prev = appstate.meta_db
|
||||
db = MetadataDB(tmp_path)
|
||||
appstate.configure(meta_db=db)
|
||||
yield db
|
||||
db.conn.close()
|
||||
appstate.configure(meta_db=prev)
|
||||
|
||||
|
||||
def test_concurrent_unnamed_saves_get_unique_names(meta_db):
|
||||
workers = 16
|
||||
barrier = threading.Barrier(workers)
|
||||
names, errors = [], []
|
||||
lock = threading.Lock()
|
||||
|
||||
def save():
|
||||
try:
|
||||
barrier.wait() # release all at once
|
||||
r = loops.save_loop({"filename": "song.feedpak", "start": 0.0, "end": 1.0})
|
||||
with lock:
|
||||
names.append(r["name"])
|
||||
except Exception as e: # noqa: BLE001 — surface any thread error
|
||||
with lock:
|
||||
errors.append(e)
|
||||
|
||||
threads = [threading.Thread(target=save) for _ in range(workers)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
assert not errors, errors
|
||||
assert len(names) == workers
|
||||
# The load-bearing assertion: no two auto-named loops collide.
|
||||
assert len(set(names)) == workers, f"duplicate loop names: {sorted(names)}"
|
||||
# And the DB agrees — every insert landed.
|
||||
stored = meta_db.conn.execute(
|
||||
"SELECT COUNT(*) FROM loops WHERE filename = ?", ("song.feedpak",)
|
||||
).fetchone()[0]
|
||||
assert stored == workers
|
||||
|
||||
|
||||
def test_list_and_save_do_not_error_under_interleave(meta_db):
|
||||
"""A read overlapping writes must not raise (shared connection, one lock)."""
|
||||
stop = threading.Event()
|
||||
errors = []
|
||||
|
||||
def reader():
|
||||
while not stop.is_set():
|
||||
try:
|
||||
loops.list_loops("song.feedpak")
|
||||
except Exception as e: # noqa: BLE001
|
||||
errors.append(e)
|
||||
|
||||
r = threading.Thread(target=reader)
|
||||
r.start()
|
||||
try:
|
||||
for i in range(40):
|
||||
loops.save_loop({"filename": "song.feedpak", "name": f"n{i}", "start": 0.0, "end": 1.0})
|
||||
finally:
|
||||
stop.set()
|
||||
r.join()
|
||||
assert not errors, errors
|
||||
@@ -1,107 +0,0 @@
|
||||
"""Playlist cover upload: client vs server errors, no temp litter, no leak.
|
||||
|
||||
The pre-split handler caught decode AND persistence failures in one `except`,
|
||||
returned 400 for both, and echoed the exception (`Invalid image: {e}`) — so a
|
||||
disk/permission failure was mislabeled as a client error and could leak a
|
||||
filesystem path. These pin the split.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import io
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from PIL import Image
|
||||
|
||||
import appstate
|
||||
from metadata_db import MetadataDB
|
||||
from routers import playlists
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(tmp_path):
|
||||
prev = (appstate.meta_db, appstate.config_dir)
|
||||
db = MetadataDB(tmp_path)
|
||||
appstate.configure(meta_db=db, config_dir=tmp_path)
|
||||
app_ = __import__("fastapi").FastAPI()
|
||||
app_.include_router(playlists.router)
|
||||
try:
|
||||
yield TestClient(app_), tmp_path
|
||||
finally:
|
||||
db.conn.close()
|
||||
appstate.configure(meta_db=prev[0], config_dir=prev[1])
|
||||
|
||||
|
||||
def _png_b64():
|
||||
buf = io.BytesIO()
|
||||
Image.new("RGB", (8, 8), (10, 20, 30)).save(buf, "PNG")
|
||||
return base64.b64encode(buf.getvalue()).decode()
|
||||
|
||||
|
||||
def _make_playlist(client):
|
||||
return client.post("/api/playlists", json={"name": "P"}).json()["id"]
|
||||
|
||||
|
||||
def test_valid_cover_saves_and_leaves_no_temp(client):
|
||||
c, tmp_path = client
|
||||
pid = _make_playlist(c)
|
||||
r = c.post(f"/api/playlists/{pid}/cover", json={"image": _png_b64()})
|
||||
assert r.status_code == 200
|
||||
cover_dir = tmp_path / "playlist_covers"
|
||||
assert (cover_dir / f"{pid}.png").exists()
|
||||
# The atomic-publish temp file must not linger.
|
||||
assert not list(cover_dir.glob("*.tmp"))
|
||||
|
||||
|
||||
def test_undecodable_image_is_a_400_without_leaking(client):
|
||||
c, _ = client
|
||||
pid = _make_playlist(c)
|
||||
# valid base64, not a valid image
|
||||
r = c.post(f"/api/playlists/{pid}/cover", json={"image": base64.b64encode(b"not an image").decode()})
|
||||
assert r.status_code == 400
|
||||
body = r.json()["error"]
|
||||
assert body == "Invalid image" # generic — no exception detail echoed
|
||||
assert "playlist_covers" not in body # no filesystem path leak
|
||||
|
||||
|
||||
def test_save_failure_is_a_500_not_a_400(client, monkeypatch):
|
||||
"""A persistence failure (here: Image.save raising) must be a logged 500,
|
||||
not a 400 — the whole point of the decode/persist split. Negative-checks
|
||||
against the pre-fix behavior, which returned 400 for exactly this."""
|
||||
c, tmp_path = client
|
||||
pid = _make_playlist(c)
|
||||
payload = _png_b64() # build BEFORE patching save
|
||||
|
||||
def boom(self, fp, *a, **k):
|
||||
raise OSError("disk full")
|
||||
|
||||
monkeypatch.setattr(Image.Image, "save", boom)
|
||||
r = c.post(f"/api/playlists/{pid}/cover", json={"image": payload})
|
||||
|
||||
assert r.status_code == 500
|
||||
assert "disk full" not in r.json()["error"] # no internal detail
|
||||
assert not list((tmp_path / "playlist_covers").glob("*.tmp")) # temp cleaned up
|
||||
|
||||
|
||||
def test_temp_creation_failure_is_a_500(client, monkeypatch):
|
||||
"""mkstemp raising (unwritable dir / full disk) must hit the same logged 500
|
||||
path as a save failure, not escape as an unhandled server error."""
|
||||
import tempfile as _tempfile
|
||||
|
||||
c, _ = client
|
||||
pid = _make_playlist(c)
|
||||
payload = _png_b64()
|
||||
|
||||
def boom(*a, **k):
|
||||
raise OSError("read-only file system")
|
||||
|
||||
monkeypatch.setattr(_tempfile, "mkstemp", boom)
|
||||
r = c.post(f"/api/playlists/{pid}/cover", json={"image": payload})
|
||||
assert r.status_code == 500
|
||||
assert "read-only" not in r.json()["error"]
|
||||
|
||||
|
||||
def test_upload_to_missing_playlist_is_404(client):
|
||||
c, _ = client
|
||||
r = c.post("/api/playlists/9999/cover", json={"image": _png_b64()})
|
||||
assert r.status_code == 404
|
||||
@@ -6,10 +6,6 @@ import sys
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
# Moved to routers/playlists in R3; reads appstate.config_dir, which the
|
||||
# `server` fixture configures via CONFIG_DIR before this is called.
|
||||
from routers.playlists import _playlist_cover_path
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def server(tmp_path, monkeypatch, isolate_logging):
|
||||
@@ -172,6 +168,6 @@ def test_cover_rejects_non_string_image_with_400_not_500(client):
|
||||
def test_deleting_playlist_removes_custom_cover(client, server):
|
||||
pid = client.post("/api/playlists", json={"name": "Doomed"}).json()["id"]
|
||||
client.post(f"/api/playlists/{pid}/cover", json={"image": _png_b64()})
|
||||
assert _playlist_cover_path(pid).exists()
|
||||
assert server._playlist_cover_path(pid).exists()
|
||||
client.delete(f"/api/playlists/{pid}")
|
||||
assert not _playlist_cover_path(pid).exists()
|
||||
assert not server._playlist_cover_path(pid).exists()
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
"""A client that disconnects mid-stream is routine, not a logged error.
|
||||
|
||||
The highway WS streams ~15 message batches before its keep-alive loop. A
|
||||
disconnect during that streaming used to fall through to the blanket
|
||||
`except Exception` and log `highway_ws unhandled error`. The dedicated
|
||||
`except WebSocketDisconnect: return` makes it quiet.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import importlib
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
from fastapi import WebSocketDisconnect
|
||||
|
||||
|
||||
def _write_sloppak(dlc_root):
|
||||
pak = dlc_root / "disc.sloppak"
|
||||
pak.mkdir(parents=True)
|
||||
(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}],
|
||||
}))
|
||||
(pak / "manifest.yaml").write_text(yaml.safe_dump({
|
||||
"title": "Disc", "artist": "T", "album": "", "year": 2026, "duration": 10.0,
|
||||
"arrangements": [{"id": "lead", "name": "Lead", "file": "arrangements/lead.json"}],
|
||||
"stems": [],
|
||||
}, sort_keys=False))
|
||||
return pak
|
||||
|
||||
|
||||
class _DisconnectingWS:
|
||||
"""Accepts, then raises WebSocketDisconnect on the first streamed send —
|
||||
i.e. a client that drops mid-stream."""
|
||||
def __init__(self):
|
||||
self.sends = 0
|
||||
|
||||
async def accept(self):
|
||||
pass
|
||||
|
||||
async def send_json(self, data):
|
||||
self.sends += 1
|
||||
# Raise only on the FIRST send. If the handler wrongly kept streaming
|
||||
# after catching the disconnect, later sends would succeed and `sends`
|
||||
# would climb past 1 — the exactly-one assertion catches that.
|
||||
if self.sends == 1:
|
||||
raise WebSocketDisconnect(code=1001)
|
||||
|
||||
async def receive_text(self):
|
||||
raise WebSocketDisconnect(code=1001)
|
||||
|
||||
async def close(self):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def server(tmp_path, monkeypatch):
|
||||
(tmp_path / "dlc").mkdir()
|
||||
monkeypatch.setenv("CONFIG_DIR", str(tmp_path / "config"))
|
||||
monkeypatch.setenv("DLC_DIR", str(tmp_path / "dlc"))
|
||||
monkeypatch.setenv("FEEDBACK_SKIP_STARTUP_TASKS", "1")
|
||||
sys.modules.pop("server", None)
|
||||
mod = importlib.import_module("server")
|
||||
yield mod, tmp_path / "dlc"
|
||||
conn = getattr(getattr(mod, "meta_db", None), "conn", None)
|
||||
if conn is not None:
|
||||
getattr(mod, "_join_background_db_threads", lambda: None)()
|
||||
conn.close()
|
||||
sys.modules.pop("server", None)
|
||||
|
||||
|
||||
class _Capture(logging.Handler):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.messages = []
|
||||
|
||||
def emit(self, record):
|
||||
self.messages.append(record.getMessage())
|
||||
|
||||
|
||||
def test_midstream_disconnect_is_not_logged_as_error(server):
|
||||
"""The first streamed send (`loading`) raises WebSocketDisconnect; the
|
||||
handler must return quietly, not log `highway_ws unhandled error`.
|
||||
|
||||
A raw handler on `feedBack.server` is used rather than pytest's `caplog`
|
||||
because `configure_logging()` (run at server import) reroutes that logger
|
||||
through the structlog pipeline, which caplog's fixture doesn't observe.
|
||||
"""
|
||||
_server, dlc = server
|
||||
_write_sloppak(dlc)
|
||||
from routers.ws_highway import highway_ws
|
||||
|
||||
cap = _Capture()
|
||||
cap.setLevel(logging.ERROR)
|
||||
lg = logging.getLogger("feedBack.server")
|
||||
lg.addHandler(cap)
|
||||
try:
|
||||
ws = _DisconnectingWS()
|
||||
asyncio.run(highway_ws(ws, "disc.sloppak", arrangement=0)) # must not raise
|
||||
finally:
|
||||
lg.removeHandler(cap)
|
||||
|
||||
assert ws.sends == 1, f"handler kept streaming after the disconnect ({ws.sends} sends)"
|
||||
unhandled = [m for m in cap.messages if "highway_ws unhandled error" in m]
|
||||
assert not unhandled, f"disconnect logged as unhandled error: {unhandled}"
|
||||
Reference in New Issue
Block a user