Compare commits

..
Author SHA1 Message Date
byrongamatosandClaude Opus 4.8 4525a9201a refactor(server): extract the wishlist routes into routers/wanted.py (R3)
Free after _clean_str moved to lib (#841): wanted's deps are JSONResponse,
_clean_str (reqfields), app + meta_db (seam). 3 routes (list/add/remove),
0 setattr targets, 0 helpers to relocate.

Bodies verbatim; @app -> @router, meta_db -> appstate.meta_db. include_router at
the original site; 143-route table identical. No test retargeting.

server.py: 8,003 -> 7,974 (this branch is independent of the chart PR).

Verified: pyflakes clean; route table identical; pytest 2401 passed (52 in
test_wanted_api); eslint 0. Boot smoke: add wishlist entry -> list -> delete,
400 on missing artist+title (_clean_str path).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 22:16:31 +02:00
23 changed files with 1064 additions and 1243 deletions
-1
View File
@@ -26,7 +26,6 @@ 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/` — 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
-25
View 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.73.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
+2 -2
View File
@@ -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) ·
(7,880 — was 14,037; ratcheted by the R3 `MetadataDB` + `AudioEffectsMappingDB`
extractions and seven `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`
-6
View File
@@ -82,16 +82,10 @@ 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",
})
-75
View File
@@ -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()
-62
View File
@@ -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)}
+4 -87
View File
@@ -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);
+97 -15
View File
@@ -55,7 +55,7 @@ from dlc_paths import _get_dlc_dir, _resolve_dlc_path
# Lives in lib/ because that is the one core dir every packaging path copies.
import appstate
# Extracted route modules. They import `appstate`, never `server` — one-way graph.
from routers import audio_effects, artist_aliases, loops, playlists, ws_highway, chart, wanted, library_extras, shop
from routers import audio_effects, artist_aliases, loops, playlists, ws_highway, chart, wanted
import sloppak as sloppak_mod
import loosefolder as loosefolder_mod
# Pure text-matching engine for MusicBrainz enrichment (P8): denoise/score/
@@ -1164,13 +1164,6 @@ def _get_progression_content() -> dict:
return _progression_content
# Publish the progression-content accessor into the seam now that it's defined
# (the main configure() at import-top runs before this def). The cache global +
# lock stay in server.py, so the `setattr(server, "_progression_content")` test
# path is unchanged; routers call `appstate.get_progression_content()`.
appstate.configure(get_progression_content=_get_progression_content)
def _copy_builtin_packs(
root: Path,
dest_dir: Path,
@@ -4236,10 +4229,31 @@ async def list_library(q: str = "", page: int = 0, size: int = 24, sort: str = "
# views is deferred to P5d — there's no server-side library event bus today, and
# the drawer updates itself from these responses.
# ── Small library / user-state endpoints (work prefs, favorites, tags, saved, session)
# Mounted here; implementation in lib/routers/library_extras.py. Paths are all
# distinct, so registering them together does not change routing.
app.include_router(library_extras.router)
@app.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 meta_db.work_charts(work_key)
@app.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 meta_db.work_charts(work_key)["charts"]}
if fn not in members:
return JSONResponse({"error": "filename is not a chart of this work"}, 400)
meta_db.set_chart_preferred(work_key, fn)
return meta_db.work_charts(work_key)
@app.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)."""
meta_db.clear_chart_preferred(work_key)
return meta_db.work_charts(work_key)
# ── Chart-level endpoints (split/work/fileinfo) ──────────────────────────────
@@ -4380,6 +4394,14 @@ async def list_tuning_names(provider: str = "local"):
return await _call_library_provider_async(library_provider, "tuning_names")
@app.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 = meta_db.toggle_favorite(filename)
return {"favorite": new_state}
# ── Personal per-song metadata (difficulty / notes / tags) ───────────────────
@@ -4542,6 +4564,11 @@ def batch_song_user_meta(data: dict):
return {"updated": n, "tags": meta_db.all_tags()}
@app.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": meta_db.all_tags()}
# ── Artist aliases / Tidy-up (P4) ────────────────────────────────────────────
@@ -5012,9 +5039,51 @@ def api_progression_events(data: dict):
return {"ok": True, "progression": summary}
# ── Cosmetics shop (spec 010) ────────────────────────────────────────────────
# Mounted here (registration order). Implementation in lib/routers/shop.py.
app.include_router(shop.router)
@app.get("/api/shop")
def api_shop():
content = _get_progression_content()
owned = meta_db.get_owned_items()
equipped = 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": meta_db.get_wallet()}
@app.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 = _get_progression_content()["shop"].get(item_id)
if not item:
return JSONResponse({"error": f"unknown item: {item_id!r}"}, status_code=400)
status, wallet = 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}
@app.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 = _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 meta_db.get_owned_items():
return JSONResponse({"error": "item not owned"}, status_code=403)
return {"ok": True, "equipped": meta_db.equip_item(slot, item_id)}
# ── Per-song practice stats (fee[dB]ack v0.3.0) ───────────────────────────────
@@ -5290,6 +5359,19 @@ def api_delete_collection(pid: int):
return {"ok": True}
@app.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": meta_db.toggle_saved(filename)}
@app.get("/api/session/continue")
def api_session_continue():
"""The Continue-Playing card's song (most recent play) or null."""
return meta_db.continue_session()
# ── Wishlist / "wanted" API (feedBack#636 item 4) ─────────────────────────────
+891 -894
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -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',
);
});
+2 -2
View File
@@ -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',
);
});
+1 -1
View File
@@ -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',
);
});
+5 -5
View File
@@ -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');
});
+5 -5
View File
@@ -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');
+2 -2
View File
@@ -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',
);
});
+22 -28
View File
@@ -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');
});
+6 -6
View File
@@ -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]');
+3 -3
View File
@@ -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');
+1 -1
View File
@@ -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`',
);
});
+8 -8
View File
@@ -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) ─────────────────────────────
+9 -9
View File
@@ -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');
});