diff --git a/CHANGELOG.md b/CHANGELOG.md index 79be67f..4660265 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **v3 settings page redesigned as a tabbed, card-row layout.** The single long scrolling settings screen becomes a horizontal tab bar (Gameplay / Audio / Graphics / Keybinds / Progression / Mic / Plugins / System) over card rows — each a leading icon + title + description with the control (toggle/dropdown/slider) on the right, plus a per-category "Reset" action. The markup lives in `static/v3/index.html` (so existing element ids keep hydrating through the unchanged `app.js` `loadSettings()`/`persistSetting()` path); a new `static/v3/settings.js` owns tab switching + active-tab persistence (`localStorage 'v3-settings-tab'`), the per-category reset, and a read-only **Keybinds** reference built from the live shortcut registry (`window.getAllShortcuts()`); styling is plain CSS in `static/v3/v3.css` (no Tailwind rebuild). **Plugins choose their settings tab** via a new optional `settings.category` field in `plugin.json` (`plugins/__init__.py` surfaces it as `settings_category`; `app.js` mounts each plugin's `
` panel into `#plugin-settings-`, falling back to the generic Plugins tab) — `highway_3d` ships `category: "graphics"`; the out-of-repo notedetect/progression plugins should declare `"mic"` / `"progression"`. **New gameplay settings:** **Countdown before song** (a four-beat count-in before playback, wired end-to-end via the existing count-in engine + the song-start autostart path; key `countdown_before_song`, default off); **Miss penalty** (`miss_penalty`) and **Fail behavior** (`fail_behavior`) are persisted now but not yet consumed by scoring (shown with a "Not yet active" badge). "Note highway speed" surfaces the existing `master_difficulty` and stays in sync with the player-popover difficulty slider. New `POST /api/settings/reset` clears chosen keys back to defaults. Tests: `tests/test_settings_api.py` (new keys + reset), `tests/test_plugins.py::test_settings_category_parsed_from_manifest`, `tests/browser/settings-tabbed.spec.ts`. - **Full-mix audio exposed alongside stems for the stem mixer's auto-switch.** `lib/sloppak.py::load_song` now parses the optional manifest `original_audio:` key (the single pre-separation mixdown, e.g. `original/full.ogg`) into a new `LoadedSloppak.original_audio` field, with the same path-traversal guard and permissive "missing → disabled" posture as the `drum_tab` loader. The highway WS `song_info` frame additively carries three new fields next to `stems`: `original_audio_url` (served by the existing `/api/sloppak/{filename}/file/{rel_path}` endpoint, `None` when the pack ships stems only), `has_original_audio`, and `has_stems` (mirroring the `has_drum_tab`/`has_keys` flag convention). The stems plugin consumes `original_audio_url` to play the untouched single file while every stem slider is at unity and switch to the separate stems the moment one drops below 100%. **Migration notes:** the `song_info` message shape is a stable contract — these are purely additive; all existing fields are unchanged. `audio_url` still points at stem[0] when stems exist (it is only the degraded native fallback); the one behavioural change is that a stem-less, full-mix-only sloppak now sets `audio_url` to the full mix instead of emitting `audio_error`, so it plays natively. - **Autoplay & auto-exit — a global "click it, it plays; finish, you're back at the menu" option (default ON).** New single Settings toggle (`autoplayExit` in `localStorage`, surfaced in both the v3 and classic settings screens; absence of the key = enabled) that closes the friction at both ends of the play loop. **Autoplay:** `playSong()` previously loaded a chart paused, requiring a Play press; a one-shot flag armed per fresh load is now consumed by the next `song:ready` (highway.js) to auto-start via the existing `togglePlay()` path (HTML5 + `_juceMode` + count-in). Arrangement switches / seeks reuse the same `song:ready` event but never arm the flag, so they don't auto-restart. **Auto-exit:** on `song:ended`, core returns to the launching menu after a short grace delay — unless a visible full-screen results/dialog overlay is on top (detected via `[role=dialog][aria-modal]` / `.fixed.inset-0` with a `getClientRects()` visibility test that works for `position:fixed`), in which case the return is deferred so that score screen's own Close button (calling `window.closeCurrentSong()`) drives the exit. A plugin can also defer explicitly via the new `window.feedBack.holdAutoExit()` (called synchronously from its own `song:ended` handler — core's listener runs first). Both paths mean **no external plugin PR is required** for a results screen to be respected. **Context-aware destination:** the player's remembered origin (`_playerOriginScreen`) now honours any real launch screen instead of clamping to library/home/favorites, and a one-shot `window.feedBack.setReturnScreen(id)` override lets the lessons catalog (`static/v3/lessons.js`) send a finished lesson back to the lessons screen — not the song library — even though the external tutorials plugin owns the `playSong` call. Also exposes a read-only `window.feedBack.autoplayExit` getter for plugins. Songs and lessons share the same `playSong` → highway path, so both inherit the behaviour. Core-only (`static/app.js`, `static/v3/lessons.js`, both `index.html`s); the end-of-song score screen itself remains a plugin. Optional polish (not required — the overlay heuristic already covers it): external scoring/note-detection plugins (e.g. SlopScale) may call `holdAutoExit()` + `closeCurrentSong()` for an exact, heuristic-free handoff. - **"Song Editor" promoted to a first-class v3 sidebar item.** The editor diff --git a/plugins/__init__.py b/plugins/__init__.py index aa8a5b3..a5cef3c 100644 --- a/plugins/__init__.py +++ b/plugins/__init__.py @@ -1330,6 +1330,18 @@ def load_plugins(app: FastAPI, context: dict, progress_cb=None, route_setup_fn=N _category = manifest.get("category") if not isinstance(_category, str) or not _category: _category = None + # Settings-tab placement (tabbed settings page). When `settings` is a + # dict, an optional `category` field names which settings tab the + # plugin's panel mounts under (e.g. "graphics", "mic", "progression"). + # Distinct from the top-level `category` above (which drives Pedalboard + # grouping) so the two don't collide. Absent/blank → None → the + # frontend falls back to the generic "Plugins" tab. + _settings_manifest = manifest.get("settings") + _settings_category = None + if isinstance(_settings_manifest, dict): + _sc = _settings_manifest.get("category") + if isinstance(_sc, str) and _sc: + _settings_category = _sc _icon = manifest.get("icon") if not isinstance(_icon, str) or not _icon: _icon = None @@ -1355,6 +1367,7 @@ def load_plugins(app: FastAPI, context: dict, progress_cb=None, route_setup_fn=N "has_screen": bool(manifest.get("screen")), "has_script": bool(manifest.get("script")), "has_settings": bool(manifest.get("settings")), + "settings_category": _settings_category, "has_tour": _is_valid_tour_manifest(manifest.get("tour")), # `styles` is an optional relpath (under the plugin's assets/) to a # compiled, preflight-off stylesheet the frontend injects as a @@ -2067,6 +2080,9 @@ def register_plugin_api(app: FastAPI): "has_screen": p["has_screen"], "has_script": p["has_script"], "has_settings": p["has_settings"], + # Settings-tab placement; None when the manifest's `settings` + # is absent, a bare string, or omits `category`. + "settings_category": p.get("settings_category"), "has_tour": p.get("has_tour", False), # `.get()` fallbacks keep stubbed test entries (built without # _nav_entry) working — styles is None when unset. @@ -2115,6 +2131,7 @@ def register_plugin_api(app: FastAPI): "has_screen": e.get("has_screen", False), "has_script": e.get("has_script", False), "has_settings": e.get("has_settings", False), + "settings_category": e.get("settings_category"), "has_tour": e.get("has_tour", False), "has_styles": e.get("has_styles", False), "styles": e.get("styles"), diff --git a/plugins/highway_3d/plugin.json b/plugins/highway_3d/plugin.json index b9b1e9b..d24e70b 100644 --- a/plugins/highway_3d/plugin.json +++ b/plugins/highway_3d/plugin.json @@ -6,7 +6,7 @@ "bundled": true, "script": "screen.js", "styles": "assets/plugin.css", - "settings": { "html": "settings.html", "server_files": ["plugin_uploads/highway_3d/current.mp4", "plugin_uploads/highway_3d/current.webm"] }, + "settings": { "html": "settings.html", "category": "graphics", "server_files": ["plugin_uploads/highway_3d/current.mp4", "plugin_uploads/highway_3d/current.webm"] }, "routes": "routes.py", "tour": "tour.json" } diff --git a/server.py b/server.py index d6b99f9..03033cf 100644 --- a/server.py +++ b/server.py @@ -173,6 +173,7 @@ def _run_janitor_hook(hook) -> None: _DEMO_BLOCKED: list[tuple[str, re.Pattern]] = [ ("POST", re.compile(r"^/api/settings$")), ("POST", re.compile(r"^/api/settings/import$")), + ("POST", re.compile(r"^/api/settings/reset$")), ("POST", re.compile(r"^/api/rescan$")), ("POST", re.compile(r"^/api/rescan/full$")), ("POST", re.compile(r"^/api/songs/upload$")), @@ -5379,6 +5380,14 @@ def _default_settings(): # silently undoing the env-var fix on the next load. return { "dlc_dir": str(DLC_DIR) if (_DLC_DIR_ENV and DLC_DIR.is_dir()) else "", + # fee[dB]ack v0.3.0 gameplay settings (tabbed settings page). Each + # defaults to its neutral / off value so existing users see no + # behaviour change until they opt in. countdown_before_song is wired + # into the song-start path; miss_penalty / fail_behavior are persisted + # but not yet consumed by scoring (stub rows on the Gameplay tab). + "countdown_before_song": False, + "miss_penalty": "none", + "fail_behavior": "continue", } @@ -5505,6 +5514,29 @@ def save_settings(data: dict): except (TypeError, ValueError, OverflowError): return {"error": "av_offset_ms must be a number between -1000 and 1000"} + # fee[dB]ack v0.3.0 gameplay settings (tabbed settings page). null is a + # no-op per the merge contract; bad shapes return a structured error + # rather than 500. countdown_before_song is consumed by the song-start + # count-in; miss_penalty / fail_behavior are persisted-only stubs. + if "countdown_before_song" in data: + raw = data["countdown_before_song"] + if raw is not None: + if not isinstance(raw, bool): + return {"error": "countdown_before_song must be a boolean"} + updates["countdown_before_song"] = raw + if "miss_penalty" in data: + raw = data["miss_penalty"] + if raw is not None: + if not isinstance(raw, str) or raw not in ("none", "low", "medium", "high"): + return {"error": "miss_penalty must be one of none, low, medium, high"} + updates["miss_penalty"] = raw + if "fail_behavior" in data: + raw = data["fail_behavior"] + if raw is not None: + if not isinstance(raw, str) or raw not in ("continue", "restart", "stop"): + return {"error": "fail_behavior must be one of continue, restart, stop"} + updates["fail_behavior"] = raw + # fee[dB]ack v0.3.0 — tuner reference pitch + instrument selection. # These drive the topbar tuner/instrument badges and (when installed) the # note_detect scoring tuning tables. null is a no-op per the merge contract. @@ -5574,6 +5606,41 @@ def save_settings(data: dict): return {"message": ". ".join(messages) if messages else "Settings saved"} +# Keys a client "Reset {category}" action may clear. Resetting removes the key +# from config.json so the next GET falls back to the _default_settings() value +# (or the frontend's own default when the key is then absent). Restricting to a +# known set means a malformed or hostile body can't wipe unrelated config. +_RESETTABLE_SETTINGS_KEYS = frozenset({ + "default_arrangement", "demucs_server_url", "master_difficulty", + "av_offset_ms", "countdown_before_song", "miss_penalty", "fail_behavior", + "reference_pitch", "instrument", "string_count", "tuning", +}) + + +@app.post("/api/settings/reset") +def reset_settings(data: dict): + """Clear the given settings keys back to their defaults — backs the + per-category "Reset" buttons on the tabbed settings page. Unknown keys are + ignored (not an error) so a newer client asking to reset a key an older + server doesn't recognise degrades gracefully. Shares _settings_lock with + save_settings()/import for the same read-merge-write atomicity reason.""" + raw_keys = data.get("keys") + if not isinstance(raw_keys, list): + return {"error": "keys must be a list of setting names"} + keys = [k for k in raw_keys if isinstance(k, str) and k in _RESETTABLE_SETTINGS_KEYS] + config_file = CONFIG_DIR / "config.json" + with _settings_lock: + cfg = _load_config(config_file) + if cfg is None: + # Nothing persisted yet — already at defaults. + return {"message": "Settings reset", "reset": []} + removed = [k for k in keys if k in cfg] + for k in removed: + del cfg[k] + _atomic_write_file(config_file, json.dumps(cfg, indent=2).encode("utf-8")) + return {"message": "Settings reset", "reset": removed} + + # ── Settings export/import (feedBack#113) ─────────────────────────────────── # Bumped only when the bundle JSON shape changes incompatibly. Importer diff --git a/static/app.js b/static/app.js index e8d44df..a95259e 100644 --- a/static/app.js +++ b/static/app.js @@ -3234,10 +3234,16 @@ async function loadSettings() { setupAppUpdates(); const resp = await fetch('/api/settings'); const data = await resp.json(); - document.getElementById('dlc-path').value = data.dlc_dir || ''; + // Null-guard the form fields: on the v3 tabbed settings page the markup is + // rendered by settings.js, so a control may be absent if that render hasn't + // run yet (or on a follower window). The optional-chaining keeps loadSettings + // from throwing and aborting the rest of the hydration. + const dlcEl = document.getElementById('dlc-path'); + if (dlcEl) dlcEl.value = data.dlc_dir || ''; _defaultArrangement = data.default_arrangement || ''; _syncDefaultArrangementSelect(_defaultArrangement); - document.getElementById('demucs-server-url').value = data.demucs_server_url || ''; + const demucsEl = document.getElementById('demucs-server-url'); + if (demucsEl) demucsEl.value = data.demucs_server_url || ''; const leftyEl = document.getElementById('setting-lefty'); if (leftyEl) leftyEl.checked = highway.getLefty(); const autoplayExitEl = document.getElementById('setting-autoplay-exit'); @@ -3248,14 +3254,11 @@ async function loadSettings() { const masteryPct = typeof data.master_difficulty === 'number' ? Math.max(0, Math.min(100, data.master_difficulty)) : 100; - const masterySlider = document.getElementById('mastery-slider'); - const masteryLabel = document.getElementById('mastery-label'); - if (masterySlider) { - masterySlider.value = masteryPct; - handleSliderInput(masterySlider); - } - if (masteryLabel) masteryLabel.textContent = masteryPct + '%'; - highway.setMastery(masteryPct / 100); + // Drives both the player-popover slider (#mastery-slider) and the + // Gameplay-tab "Note highway speed" slider (#setting-highway-speed), which + // share the master_difficulty key. skipPersist so loading the value doesn't + // echo it back to the server. + _applyMastery(masteryPct, { skipPersist: true }); // Route the loaded value through setAvOffsetMs so the highway's // render clock, the Settings slider, the HUD readout, and the // module variable all pick it up consistently. Pass skipPersist @@ -3264,6 +3267,18 @@ async function loadSettings() { // Arrangement naming mode is localStorage-only (client preference). const namingModeEl = document.getElementById('arrangement-naming-mode'); if (namingModeEl) namingModeEl.value = _getArrangementNamingMode(); + // Gameplay-tab settings (tabbed settings page). Countdown is mirrored to + // localStorage so the song-start path reads it synchronously without an + // async /api/settings fetch on the play hot path. Miss penalty / fail + // behavior are persist-only stubs (not yet consumed by scoring). + const countdownOn = data.countdown_before_song === true; + try { localStorage.setItem('countdownBeforeSong', countdownOn ? '1' : '0'); } catch (_) { /* private mode */ } + const countdownEl = document.getElementById('setting-countdown-before-song'); + if (countdownEl) countdownEl.checked = countdownOn; + const missEl = document.getElementById('setting-miss-penalty'); + if (missEl) missEl.value = typeof data.miss_penalty === 'string' ? data.miss_penalty : 'none'; + const failEl = document.getElementById('setting-fail-behavior'); + if (failEl) failEl.value = typeof data.fail_behavior === 'string' ? data.fail_behavior : 'continue'; // Native folder picker — only present when running inside feedBack-desktop. if (window.feedBackDesktop && typeof window.feedBackDesktop.pickDirectory === 'function') { document.getElementById('btn-pick-dlc')?.classList.remove('hidden'); @@ -5720,6 +5735,22 @@ window.setAutoplayExit = function (on) { Object.defineProperty(window.feedBack, 'autoplayExit', { get: _autoplayExitEnabled, configurable: true, }); + +// "Countdown before song" (Gameplay tab). Mirrored to localStorage by +// loadSettings so the song-start path can read it synchronously here — no +// async /api/settings fetch on the play hot path. Defaults off. +function _countdownBeforeSongEnabled() { + try { return localStorage.getItem('countdownBeforeSong') === '1'; } catch (_) { return false; } +} +// Settings checkbox setter (onchange="setCountdownBeforeSong(this.checked)"). +// Writes localStorage for the synchronous read above AND persists to the +// server so it survives a reload / rides along in the settings export bundle. +window.setCountdownBeforeSong = function (on) { + try { localStorage.setItem('countdownBeforeSong', on ? '1' : '0'); } catch (_) { /* private mode */ } + const el = document.getElementById('setting-countdown-before-song'); + if (el && el.checked !== !!on) el.checked = !!on; + persistSetting('countdown_before_song', !!on); +}; // One-shot launcher override for the player's return destination. window.feedBack.setReturnScreen = function (id) { window.feedBack._nextReturnScreen = id || null; @@ -5755,8 +5786,13 @@ window.feedBack.on('song:ready', () => { if (!_pendingAutostart) return; _pendingAutostart = false; if (!_autoplayExitEnabled() || isPlaying) return; - // Reuse the Play button's start path (handles HTML5 + _juceMode + count-in). - Promise.resolve(togglePlay()).catch((err) => console.warn('[app] autoplay failed:', err)); + // "Countdown before song": play a 4-beat count-in, then start. Otherwise + // reuse the Play button's start path directly (handles HTML5 + _juceMode). + if (_countdownBeforeSongEnabled()) { + Promise.resolve(startSongCountIn()).catch((err) => console.warn('[app] song count-in failed:', err)); + } else { + Promise.resolve(togglePlay()).catch((err) => console.warn('[app] autoplay failed:', err)); + } }); // Editor → Highway handoff (Editor ⇄ 3D Highway region round-trip). The @@ -6290,16 +6326,42 @@ function _persistMastery(pct) { }, 300); } function setMastery(v) { - // Guard + clamp: v might be a slider string, a programmatic call - // from a plugin, or a restored settings value with a bad shape. - // Don't let NaN hit the label (would show "NaN%") or the POST. + _applyMastery(v); +} +// Shared mastery applier. Master difficulty has two controls that write the +// same master_difficulty key: the player-popover slider (#mastery-slider) and +// the Gameplay-tab "Note highway speed" slider (#setting-highway-speed). Route +// both — and loadSettings' hydration — through here so their positions, +// labels, and track fills stay in sync regardless of which the user touches, +// plus the live highway re-filter and the debounced persist. All element reads +// are null-guarded since either control may be absent (follower window, or the +// settings markup not yet rendered). +function _applyMastery(v, opts = {}) { + // Guard + clamp: v might be a slider string, a programmatic call from a + // plugin, or a restored settings value with a bad shape. Don't let NaN + // reach a label (would show "NaN%") or the POST. const parsed = parseInt(v, 10); if (!Number.isFinite(parsed)) return; const pct = Math.max(0, Math.min(100, parsed)); - document.getElementById('mastery-label').textContent = pct + '%'; - handleSliderInput(document.getElementById('mastery-slider')); + const popLabel = document.getElementById('mastery-label'); + if (popLabel) popLabel.textContent = pct + '%'; + const popSlider = document.getElementById('mastery-slider'); + if (popSlider) { + if (String(popSlider.value) !== String(pct)) popSlider.value = pct; + handleSliderInput(popSlider); + } + const setSlider = document.getElementById('setting-highway-speed'); + if (setSlider) { + if (String(setSlider.value) !== String(pct)) setSlider.value = pct; + handleSliderInput(setSlider); + } + // The Gameplay-tab label markup appends a literal "%" after this span + // (matching the av-offset "ms" pattern), so write the number alone here — + // unlike #mastery-label above, whose markup carries no trailing unit. + const setLabel = document.getElementById('setting-highway-speed-val'); + if (setLabel) setLabel.textContent = pct; highway.setMastery(pct / 100); - _persistMastery(pct); + if (!opts.skipPersist) _persistMastery(pct); } // Reflect phrase-data availability on the slider after every `ready`. // The server omits the `phrases` message entirely for single-level @@ -8834,6 +8896,49 @@ async function startCountIn(opts = {}) { } } +// Start-of-song count-in: a 4-beat click before playback begins, gated by the +// "Countdown before song" setting (Gameplay tab). Mirrors the loop count-in's +// overlay + click + gen-token cancellation, but counts from the song's current +// position (0 at song start) with no loop A/B rewind. startCountIn() is loop- +// coupled (early-returns when loopA/loopB are null), so this is a sibling +// rather than an overload. Hands off to togglePlay() once the count completes. +async function startSongCountIn() { + if (_countingIn) return; + _countingIn = true; + // Snapshot the gen so a teardown (showScreen/playSong calls _cancelCountIn) + // bumps it and every delayed callback below bails. + const gen = _countInGen; + if (window._juceMode) { + await jucePlayer.pause().catch((err) => console.error('[app] jucePlayer.pause error in song count-in:', err)); + } else { + audio.pause(); + } + if (gen !== _countInGen) return; // teardown during pause + const startT = lastAudioTime || 0; + let bpm = highway.getBPM(startT); + // Pre-chart / malformed-tempo fallback: 4 beats at 120 BPM (500 ms each). + if (!Number.isFinite(bpm) || bpm <= 0) bpm = 120; + const beatInterval = 60 / bpm; + let count = 0; + function tick() { + if (gen !== _countInGen) return; // teardown mid-count + count++; + if (count > 4) { + hideCountOverlay(); + _countingIn = false; + // Hand off to the normal play path — togglePlay() flips isPlaying, + // updates the button, and emits song:play/resume for plugins. + Promise.resolve(togglePlay()).catch((err) => console.warn('[app] play after count-in failed:', err)); + return; + } + showCountOverlay(count); + playClick(count === 1); + _countInTimer = setTimeout(tick, beatInterval * 1000); + } + // First beat after a short lead-in, matching the loop count-in's 500 ms. + _countInTimer = setTimeout(tick, 500); +} + // Time display + highway sync let lastAudioTime = 0; setInterval(() => { @@ -9044,6 +9149,33 @@ window.registerShortcut = (options) => { panel.registerShortcut(options); }; +// Flat, read-only snapshot of every registered shortcut across all panels, +// for the Settings → Keybinds reference tab. Dedupes by combo+scope (the same +// shortcut can live in both the active panel and the default panel) and uses +// the same modifier-prefix formatting as the shortcuts modal. Returns +// [{ combo, description, scope }]; remapping is not supported, so this is +// purely informational. +window.getAllShortcuts = () => { + const fmt = (s) => { + const m = s.modifiers || {}; + return (m.ctrl ? 'Ctrl+' : '') + (m.alt ? 'Alt+' : '') + + (m.shift ? 'Shift+' : '') + (m.meta ? 'Meta+' : '') + s.key; + }; + const seen = new Set(); + const out = []; + for (const [, panel] of _panels) { + if (!panel || !panel.shortcuts) continue; + for (const [, s] of panel.shortcuts) { + const combo = fmt(s); + const dedupe = combo + '|' + (s.scope || ''); + if (seen.has(dedupe)) continue; + seen.add(dedupe); + out.push({ combo, description: s.description || '', scope: s.scope || 'global' }); + } + } + return out; +}; + window.unregisterShortcut = (key, scope) => { // Try the active panel first to preserve panel isolation; fall back to // other panels so a shortcut registered before a panel switch is still @@ -9908,6 +10040,35 @@ async function _registerLegacyPluginUiContributions(plugin) { } } +// Settings-tab containers that can host plugin
panels on the v3 +// tabbed settings page. '#plugin-settings' is the fallback bucket (and the +// only container in the classic v2 settings page); the per-tab containers map +// to a plugin manifest's settings.category. A plugin with no category, or one +// whose tab container is absent (v2, or render not yet run), falls back to +// '#plugin-settings'. Body divs injected per plugin use id +// `plugin-settings-` and live INSIDE a
, so they are never +// direct children of these containers — no id collision in the scans below. +const _PLUGIN_SETTINGS_CONTAINER_IDS = [ + 'plugin-settings', 'plugin-settings-graphics', + 'plugin-settings-mic', 'plugin-settings-progression', +]; +function _pluginSettingsContainers() { + const out = []; + for (const id of _PLUGIN_SETTINGS_CONTAINER_IDS) { + const el = document.getElementById(id); + if (el) out.push(el); + } + return out; +} +function _pluginSettingsTarget(plugin) { + const cat = plugin && plugin.settings_category; + if (cat) { + const el = document.getElementById('plugin-settings-' + cat); + if (el) return el; + } + return document.getElementById('plugin-settings'); +} + async function loadPlugins() { if (_loadPluginsInFlight) { console.log('[feedBack] loadPlugins: in-flight, skipping'); return null; } _loadPluginsInFlight = true; @@ -9959,7 +10120,8 @@ async function loadPlugins() { console.warn('[feedBack] capability manifest registration failed:', e); } - const settingsContainer = document.getElementById('plugin-settings'); + // Plugin settings panels mount into one of several tab containers — + // see _pluginSettingsContainers()/_pluginSettingsTarget() above. // Plugins whose screen.js has already been evaluated this session // at the current version AND whose DOM is still in the document. @@ -10097,8 +10259,8 @@ async function loadPlugins() { } }; const existingSettingsByPluginId = new Map(); - if (settingsContainer) { - for (const child of settingsContainer.children) { + for (const container of _pluginSettingsContainers()) { + for (const child of container.children) { const pid = child.dataset ? child.dataset.pluginId : null; if (pid) existingSettingsByPluginId.set(pid, child); } @@ -10127,8 +10289,8 @@ async function loadPlugins() { // so always rebuild them. navContainer.innerHTML = ''; mobileNavContainer.innerHTML = 'Plugins'; - if (settingsContainer) { - [...settingsContainer.children].forEach((el) => { + for (const container of _pluginSettingsContainers()) { + [...container.children].forEach((el) => { const pid = el.dataset ? el.dataset.pluginId : null; if (!pid || !alreadyHydrated.has(pid)) el.remove(); }); @@ -10290,7 +10452,10 @@ async function loadPlugins() { // Skip for already-hydrated plugins — preserved details element // still carries listeners wired by its inline settings script // and by screen.js on first load. - if (plugin.has_settings && settingsContainer && !alreadyHydrated.has(plugin.id)) { + // Resolve which settings tab this plugin's panel mounts under + // (manifest settings.category), falling back to '#plugin-settings'. + const settingsTarget = plugin.has_settings ? _pluginSettingsTarget(plugin) : null; + if (plugin.has_settings && settingsTarget && !alreadyHydrated.has(plugin.id)) { const details = document.createElement('details'); details.className = 'bg-dark-700/40 border border-gray-800 rounded-xl overflow-hidden group'; details.dataset.pluginId = plugin.id; @@ -10382,7 +10547,7 @@ async function loadPlugins() { body.className = 'px-4 py-4 border-t border-gray-800 space-y-4'; details.appendChild(body); - settingsContainer.appendChild(details); + settingsTarget.appendChild(details); const settingsResp = await fetch(`/api/plugins/${plugin.id}/settings.html`); body.innerHTML = await settingsResp.text(); diff --git a/static/v3/index.html b/static/v3/index.html index dc3d980..1e9a4bf 100644 --- a/static/v3/index.html +++ b/static/v3/index.html @@ -343,76 +343,60 @@
-
- -

Settings

+

Settings

-
- - + +
+
+ + +
+

System

+
+ + + +
+
+
Library folder path
+
The folder fee[dB]ack scans for your song library.
+
+
+ + + +
+
+ +
+
+
Library
+
Rescan checks for new songs. Full Rescan clears the cache and re-imports everything.
+
+
+ + + +
+
+ +
+
+
Backup
+
Export bundles server config, browser preferences, and opted-in plugin data into one JSON file. Import overwrites current settings and reloads.
+
+
+ + + + +
+
+ +
+
+
Diagnostics
+
Bundles server logs, hardware info, plugin inventory, and the browser console transcript into one zip for bug reports. Redaction strips DLC paths, song filenames, and IP addresses by default.
+
+
+ + + + + + +
+
+ + + +
+ +
+ +
+
+
About
+
+ fee[dB]ack · Licensed under + GNU AGPL v3.0 · + Source code repository. + Free software — if you run a modified version that interacts with users over a network, you must make the modified source available to those users. +
+
+
+
+
@@ -900,6 +1071,7 @@ + diff --git a/static/v3/settings.js b/static/v3/settings.js new file mode 100644 index 0000000..d125fb8 --- /dev/null +++ b/static/v3/settings.js @@ -0,0 +1,208 @@ +// ════════════════════════════════════════════════════════════════════════ +// v3 tabbed settings page — behaviour layer (feat/v3-settings-tabbed) +// +// The markup (tab bar, card rows, per-tab plugin mount containers) lives +// statically in static/v3/index.html so the element ids exist before app.js's +// loadSettings() hydrates them. This module owns the *behaviour*: +// • tab switching + active-tab persistence (localStorage 'v3-settings-tab') +// • the per-category "Reset" button(s) +// • the read-only Keybinds reference (from window.getAllShortcuts()) +// • empty-state notes for plugin tabs with no installed plugins +// +// It is a plain non-module script (matches the rest of static/v3/*). All +// reads are null-guarded so it no-ops gracefully on the classic v2 page (which +// ships its own settings markup and never creates #settings-tabbar). +// ════════════════════════════════════════════════════════════════════════ +(function () { + 'use strict'; + + var TAB_KEY = 'v3-settings-tab'; + var DEFAULT_TAB = 'gameplay'; + + // Per-category reset descriptors. `server` keys are cleared via + // POST /api/settings/reset (so the next GET falls back to defaults); + // `local` keys are client-only localStorage prefs; `after` re-applies any + // live-object default that won't pick itself back up from a cleared key. + // Only tabs with a [data-reset] button in the markup need an entry — today + // that's Gameplay; others can be added alongside a button later. + var RESET_MAP = { + gameplay: { + server: ['master_difficulty', 'av_offset_ms', 'miss_penalty', + 'fail_behavior', 'countdown_before_song', 'default_arrangement'], + local: ['lefty', 'autoplayExit', 'arrangementNamingMode', 'countdownBeforeSong'], + after: function () { + // Left-handed is held on the highway object, not re-derived + // from localStorage on load — flip it back to the default. + try { if (window.highway && window.highway.setLefty) window.highway.setLefty(false); } catch (_) { /* noop */ } + }, + }, + }; + + function esc(s) { + return String(s == null ? '' : s) + .replace(/&/g, '&').replace(//g, '>'); + } + + // ── Tab switching ──────────────────────────────────────────────────── + function knownTabs() { + var out = []; + document.querySelectorAll('#settings-tabbar .fb-tab').forEach(function (b) { + if (b.dataset.tab) out.push(b.dataset.tab); + }); + return out; + } + + function activateTab(tab) { + var tabs = knownTabs(); + if (tabs.indexOf(tab) === -1) tab = tabs.length ? tabs[0] : DEFAULT_TAB; + document.querySelectorAll('#settings-tabbar .fb-tab').forEach(function (b) { + b.classList.toggle('active', b.dataset.tab === tab); + }); + document.querySelectorAll('#settings .fb-tabpanel').forEach(function (p) { + p.classList.toggle('active', p.dataset.tab === tab); + }); + try { localStorage.setItem(TAB_KEY, tab); } catch (_) { /* private mode */ } + } + + function wireTabs() { + var bar = document.getElementById('settings-tabbar'); + if (!bar || bar.dataset.wired === '1') return; + bar.dataset.wired = '1'; + bar.addEventListener('click', function (e) { + var btn = e.target.closest ? e.target.closest('.fb-tab') : null; + if (btn && btn.dataset.tab) activateTab(btn.dataset.tab); + }); + var saved = DEFAULT_TAB; + try { saved = localStorage.getItem(TAB_KEY) || DEFAULT_TAB; } catch (_) { /* noop */ } + activateTab(saved); + } + + // ── Per-category reset ──────────────────────────────────────────────── + function wireResets() { + document.querySelectorAll('#settings [data-reset]').forEach(function (btn) { + if (btn.dataset.wired === '1') return; + btn.dataset.wired = '1'; + btn.addEventListener('click', function () { resetCategory(btn.dataset.reset); }); + }); + } + + function resetCategory(cat) { + var map = RESET_MAP[cat]; + if (!map) return; + var confirmFn = (typeof window._confirmDialog === 'function') + ? window._confirmDialog({ + title: 'Reset ' + cat.charAt(0).toUpperCase() + cat.slice(1) + ' Settings', + body: '

Restore these settings to their defaults? This can\'t be undone.

', + confirmText: 'Reset', cancelText: 'Cancel', danger: true, + }) + : Promise.resolve(window.confirm('Reset ' + cat + ' settings to defaults?')); + confirmFn.then(function (ok) { + if (!ok) return; + var done = Promise.resolve(); + if (map.server && map.server.length) { + done = fetch('/api/settings/reset', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ keys: map.server }), + }).catch(function () { /* best-effort */ }); + } + done.then(function () { + (map.local || []).forEach(function (k) { + try { localStorage.removeItem(k); } catch (_) { /* noop */ } + }); + if (typeof map.after === 'function') { try { map.after(); } catch (_) { /* noop */ } } + // Re-hydrate every control from the now-default server + local state. + if (typeof window.loadSettings === 'function') { + try { window.loadSettings(); } catch (_) { /* noop */ } + } + }); + }); + } + + // ── Keybinds reference (read-only) ──────────────────────────────────── + var SCOPE_TITLES = { + global: 'Global', player: 'Player', library: 'Library', settings: 'Settings', + }; + function scopeTitle(scope) { + if (SCOPE_TITLES[scope]) return SCOPE_TITLES[scope]; + if (scope && scope.indexOf('plugin-') === 0) return 'Plugin: ' + scope.slice(7); + return scope || 'Other'; + } + + function renderKeybinds() { + var host = document.getElementById('settings-keybinds'); + if (!host) return; + var list = []; + try { if (typeof window.getAllShortcuts === 'function') list = window.getAllShortcuts() || []; } catch (_) { list = []; } + if (!list.length) { + host.innerHTML = '

No keyboard shortcuts are registered yet.

'; + return; + } + // Group by scope, stable scope order with anything unknown last. + var order = ['global', 'player', 'library', 'settings']; + var groups = {}; + list.forEach(function (s) { + (groups[s.scope] = groups[s.scope] || []).push(s); + }); + var scopes = Object.keys(groups).sort(function (a, b) { + var ia = order.indexOf(a), ib = order.indexOf(b); + if (ia === -1) ia = order.length; + if (ib === -1) ib = order.length; + return ia - ib || a.localeCompare(b); + }); + var html = ''; + scopes.forEach(function (scope) { + html += '
' + esc(scopeTitle(scope)) + '
'; + html += '
'; + groups[scope].forEach(function (s) { + html += '
' + + '
' + esc(s.description || s.combo) + '
' + + '
' + esc(s.combo) + '
' + + '
'; + }); + html += '
'; + }); + html += '

Remapping shortcuts is not yet supported.

'; + host.innerHTML = html; + } + + // ── Empty-state notes for plugin tabs ───────────────────────────────── + function refreshEmptyStates() { + document.querySelectorAll('#settings [data-empty-for]').forEach(function (note) { + var target = document.getElementById(note.dataset.emptyFor); + var empty = !target || target.children.length === 0; + note.style.display = empty ? '' : 'none'; + }); + } + + // ── Boot + refresh on settings entry ────────────────────────────────── + function init() { + if (!document.getElementById('settings-tabbar')) return; // not the v3 page + wireTabs(); + wireResets(); + renderKeybinds(); + refreshEmptyStates(); + // Safety net for plugin-panel injection ordering: tell app.js the + // settings containers exist now (it injects plugin
into the + // per-category containers). Harmless if no listener is attached. + try { document.dispatchEvent(new CustomEvent('v3:settings-rendered')); } catch (_) { /* noop */ } + } + + // Re-derive the dynamic bits whenever the user enters Settings: shortcuts + // and plugin panels may have registered/mounted since the last visit. + if (window.feedBack && typeof window.feedBack.on === 'function') { + window.feedBack.on('screen:changed', function (e) { + if (e && e.id === 'settings') { + // Plugin panels (and shortcuts) may have mounted since the last + // visit — re-derive the dynamic bits on every Settings entry. + wireTabs(); wireResets(); renderKeybinds(); refreshEmptyStates(); + } + }); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init, { once: true }); + } else { + init(); + } +})(); diff --git a/static/v3/v3.css b/static/v3/v3.css index e60e2d6..3867b3c 100644 --- a/static/v3/v3.css +++ b/static/v3/v3.css @@ -968,3 +968,109 @@ body.font-display { font-family: Rubik, system-ui, sans-serif; } color: #ddd6fe; background: rgba(76, 29, 149, .6); border: 1px solid rgba(139, 92, 246, .4); border-radius: .3rem; padding: .04rem .28rem; } + +/* ════════════════════════════════════════════════════════════════════════ + Tabbed settings page (v3) — feat/v3-settings-tabbed + Card-row layout: leading icon + title/description + right-aligned control, + grouped under a horizontal tab bar. Pure CSS (no Tailwind rebuild); colors + mirror the fb-* design tokens so the page matches the rest of the v3 shell. + ════════════════════════════════════════════════════════════════════════ */ +#settings .fb-settings { max-width: 56rem; margin: 0 auto; padding: 6rem 1.5rem 4rem; } +.fb-settings-back { + display: inline-flex; align-items: center; gap: .25rem; + font-size: .8rem; color: #94a3b8; background: none; border: none; cursor: pointer; + padding: 0; margin-bottom: 1rem; transition: color .15s; +} +.fb-settings-back:hover { color: #f8fafc; } +.fb-settings-back svg { width: 1rem; height: 1rem; } +.fb-settings-title { font-size: 1.875rem; font-weight: 800; color: #f8fafc; } + +/* Tab bar */ +.fb-tabbar { + display: flex; flex-wrap: wrap; gap: .25rem; + border-bottom: 1px solid rgba(51, 65, 85, .6); + margin: 1.25rem 0 1.5rem; +} +.fb-tab { + appearance: none; background: none; border: none; cursor: pointer; + padding: .55rem .85rem; font-size: .85rem; font-weight: 600; + color: #94a3b8; border-bottom: 2px solid transparent; + margin-bottom: -1px; transition: color .15s, border-color .15s; white-space: nowrap; +} +.fb-tab:hover { color: #e2e8f0; } +.fb-tab.active { color: #f8fafc; border-bottom-color: #0ea5e9; } + +/* Panels */ +.fb-tabpanel { display: none; } +.fb-tabpanel.active { display: block; } +.fb-tabpanel-head { display: flex; align-items: center; justify-content: space-between; gap: 1rem; margin-bottom: 1rem; } +.fb-tabpanel-head h3 { font-size: 1.1rem; font-weight: 700; color: #f8fafc; } + +/* Card rows */ +.fb-srows { display: flex; flex-direction: column; gap: .6rem; } +.fb-srow { + display: flex; align-items: center; gap: 1rem; + background: #1e293b; border: 1px solid rgba(51, 65, 85, .55); + border-radius: .75rem; padding: .85rem 1rem; +} +.fb-srow-stack { flex-direction: column; align-items: stretch; gap: .65rem; } +.fb-srow-icon { + flex: none; width: 2.25rem; height: 2.25rem; border-radius: .6rem; + display: flex; align-items: center; justify-content: center; + background: rgba(14, 165, 233, .12); color: #38bdf8; +} +.fb-srow-icon svg { width: 1.15rem; height: 1.15rem; } +.fb-srow-main { flex: 1 1 auto; min-width: 0; } +.fb-srow-title { font-size: .9rem; font-weight: 600; color: #e2e8f0; display: flex; align-items: center; } +.fb-srow-desc { font-size: .75rem; color: #94a3b8; margin-top: .15rem; } +.fb-srow-control { flex: none; display: flex; align-items: center; gap: .5rem; } +.fb-srow-control select, +.fb-srow-control input[type="text"] { min-width: 11rem; } +.fb-srow-stack .fb-srow-control { width: 100%; } +.fb-srow-stack .fb-srow-control input[type="text"] { flex: 1 1 auto; min-width: 0; } +.fb-srow-wide { width: 100%; } + +/* Toggle switch */ +.fb-switch { position: relative; display: inline-block; width: 2.6rem; height: 1.5rem; flex: none; } +.fb-switch input { position: absolute; opacity: 0; width: 0; height: 0; } +.fb-switch .fb-switch-track { + position: absolute; inset: 0; cursor: pointer; + background: #334155; border-radius: 999px; transition: background .15s; +} +.fb-switch .fb-switch-track::before { + content: ""; position: absolute; height: 1.1rem; width: 1.1rem; left: .2rem; top: .2rem; + background: #f8fafc; border-radius: 50%; transition: transform .15s; +} +.fb-switch input:checked + .fb-switch-track { background: #0ea5e9; } +.fb-switch input:checked + .fb-switch-track::before { transform: translateX(1.1rem); } +.fb-switch input:focus-visible + .fb-switch-track { box-shadow: 0 0 0 2px rgba(56, 189, 248, .5); } + +/* "Not yet active" stub badge */ +.fb-stub-note { + font-size: .6rem; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; + color: #fde68a; background: rgba(234, 179, 8, .12); + border-radius: 999px; padding: .1rem .45rem; margin-left: .5rem; white-space: nowrap; +} + +/* Per-category reset */ +.fb-reset-btn { + display: inline-flex; align-items: center; gap: .35rem; + font-size: .8rem; color: #94a3b8; background: none; border: none; cursor: pointer; + padding: .35rem .5rem; border-radius: .5rem; transition: color .15s, background .15s; +} +.fb-reset-btn:hover { color: #fca5a5; background: rgba(239, 68, 68, .08); } +.fb-reset-btn svg { width: .9rem; height: .9rem; } + +/* Keybinds reference */ +.fb-kbd { + display: inline-block; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: .72rem; color: #e2e8f0; background: #0b1220; + border: 1px solid rgba(51, 65, 85, .8); border-bottom-width: 2px; + border-radius: .35rem; padding: .1rem .4rem; min-width: 1.4rem; text-align: center; +} +.fb-kbd-group-title { + font-size: .7rem; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; + color: #94a3b8; margin: 1.25rem 0 .5rem; +} +.fb-settings-note { font-size: .75rem; color: #64748b; margin-top: 1rem; } +.fb-tabpanel-empty { font-size: .8rem; color: #64748b; padding: .5rem 0; } diff --git a/tests/browser/settings-tabbed.spec.ts b/tests/browser/settings-tabbed.spec.ts new file mode 100644 index 0000000..e76a5e9 --- /dev/null +++ b/tests/browser/settings-tabbed.spec.ts @@ -0,0 +1,138 @@ +import { test, expect } from '@playwright/test'; + +// Verifies the v3 tabbed settings page (feat/v3-settings-tabbed): the tab bar +// renders, tabs switch panels, the active tab persists, existing controls +// still hydrate from /api/settings, the new countdown toggle persists, and the +// per-category reset hits /api/settings/reset. + +interface SettingsPayload { + dlc_dir: string; + default_arrangement: string; + demucs_server_url: string; + master_difficulty: number; + av_offset_ms: number; + countdown_before_song: boolean; + miss_penalty: string; + fail_behavior: string; +} + +const basePayload: SettingsPayload = { + dlc_dir: '', + default_arrangement: 'Rhythm', + demucs_server_url: '', + master_difficulty: 70, + av_offset_ms: 0, + countdown_before_song: false, + miss_penalty: 'none', + fail_behavior: 'continue', +}; + +// A fresh profile shows the blocking onboarding overlay; onboard via the API +// so the tab clicks below aren't intercepted (idempotent once onboarded). +test.beforeEach(async ({ request }) => { + await request.post('/api/profile', { data: { display_name: 'Settings Tester' } }); + await request.post('/api/progression/paths', { data: { add: ['guitar'] } }); + await request.post('/api/progression/onboarding', { data: { action: 'skip' } }); +}); + +// Open the v3 settings screen with the first-run onboarding overlay neutralised +// (the API skip in beforeEach handles the common path; this also hides the +// overlay element so a slow async profile render can't intercept tab clicks). +async function openSettings(page) { + await page.goto('/'); + await page.waitForSelector('#settings-tabbar', { state: 'attached' }); + await page.addStyleTag({ content: '#v3-onboarding{display:none!important;pointer-events:none!important}' }); + await page.evaluate(() => (window as any).showScreen('settings')); +} + +async function mockSettings(page, posts: any[], resets: any[]) { + await page.route('**/api/settings', async route => { + if (route.request().method() === 'GET') { + await route.fulfill({ json: basePayload }); + return; + } + posts.push(route.request().postDataJSON()); + await route.fulfill({ json: { message: 'Settings saved' } }); + }); + await page.route('**/api/settings/reset', async route => { + resets.push(route.request().postDataJSON()); + await route.fulfill({ json: { message: 'Settings reset', reset: [] } }); + }); +} + +test('tab bar renders the settings tabs and Gameplay is default', async ({ page }) => { + await mockSettings(page, [], []); + await openSettings(page); + + const tabs = await page.locator('#settings-tabbar .fb-tab').allTextContents(); + expect(tabs).toEqual(['Gameplay', 'Audio', 'Graphics', 'Keybinds', 'Progression', 'Mic', 'Plugins', 'System']); + + // Gameplay panel is active by default and its controls are present. + await expect(page.locator('.fb-tabpanel[data-tab="gameplay"]')).toHaveClass(/active/); + await expect(page.locator('#setting-lefty')).toBeAttached(); + await expect(page.locator('#setting-countdown-before-song')).toBeAttached(); +}); + +test('clicking a tab switches the visible panel', async ({ page }) => { + await mockSettings(page, [], []); + await openSettings(page); + + await page.locator('#settings-tabbar .fb-tab[data-tab="audio"]').click(); + await expect(page.locator('.fb-tabpanel[data-tab="audio"]')).toHaveClass(/active/); + await expect(page.locator('.fb-tabpanel[data-tab="gameplay"]')).not.toHaveClass(/active/); + await expect(page.locator('#setting-live-guitar-tone-source')).toBeVisible(); +}); + +test('active tab persists across reload', async ({ page }) => { + await mockSettings(page, [], []); + await openSettings(page); + await page.locator('#settings-tabbar .fb-tab[data-tab="system"]').click(); + await expect(page.locator('.fb-tabpanel[data-tab="system"]')).toHaveClass(/active/); + + await page.reload(); + await page.waitForSelector('#settings-tabbar', { state: 'attached' }); + // Restored from localStorage even before navigating back to settings. + await expect(page.locator('#settings-tabbar .fb-tab[data-tab="system"]')).toHaveClass(/active/); +}); + +test('existing controls hydrate from /api/settings', async ({ page }) => { + await mockSettings(page, [], []); + await openSettings(page); + + await expect(page.locator('#default-arrangement')).toHaveValue('Rhythm'); + // Note highway speed shares master_difficulty (70 in the mock). + await expect(page.locator('#setting-highway-speed')).toHaveValue('70'); + await expect(page.locator('#setting-highway-speed-val')).toHaveText('70'); // span holds number; '%' is literal in markup +}); + +test('countdown toggle persists countdown_before_song', async ({ page }) => { + const posts: any[] = []; + await mockSettings(page, posts, []); + await openSettings(page); + + await page.locator('label.fb-switch:has(#setting-countdown-before-song) .fb-switch-track').click(); + await expect.poll(() => posts.some(p => p && p.countdown_before_song === true)).toBe(true); +}); + +test('reset gameplay posts to /api/settings/reset', async ({ page }) => { + const resets: any[] = []; + await mockSettings(page, [], resets); + await openSettings(page); + + await page.locator('[data-reset="gameplay"]').click(); + // _confirmDialog modal — confirm it. + await page.locator('.slopsmith-modal [data-confirm]').click(); + + await expect.poll(() => resets.length).toBeGreaterThan(0); + expect(resets[0].keys).toContain('countdown_before_song'); + expect(resets[0].keys).toContain('master_difficulty'); +}); + +test('keybinds tab renders the shortcut reference', async ({ page }) => { + await mockSettings(page, [], []); + await openSettings(page); + + await page.locator('#settings-tabbar .fb-tab[data-tab="keybinds"]').click(); + // Either real shortcuts (kbd chips) or the empty-state note — never blank. + await expect(page.locator('#settings-keybinds')).not.toBeEmpty(); +}); diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 29b136d..fc3e519 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -4006,3 +4006,32 @@ def test_loader_honors_persisted_disable_without_memory_flip(tmp_path, reset_plu assert not (config_dir / "z_marker").exists() assert plugins.PENDING_PLUGINS["z_target"]["status"] == "disabled" assert plugins.PENDING_PLUGINS["z_target"]["enabled"] is False + + +def test_settings_category_parsed_from_manifest(tmp_path, reset_plugin_state): + """A plugin manifest's settings.category is parsed into settings_category + on the loaded entry (drives the v3 settings-tab placement). Absent or a + bare-string `settings` value yields None → the frontend's fallback tab.""" + plugins = reset_plugin_state + + def _write(pid, settings_value): + d = tmp_path / pid + d.mkdir() + (d / "plugin.json").write_text(json.dumps({ + "id": pid, "name": pid, "routes": "routes.py", "settings": settings_value, + })) + (d / "routes.py").write_text("def setup(app, ctx):\n pass\n") + + _write("graphy", {"html": "settings.html", "category": "graphics"}) + _write("plainset", {"html": "settings.html"}) # dict, no category + _write("noset", None) # no settings at all + + _run_load_plugins(plugins, type("FakeApp", (), {})(), tmp_path) + + rows = {p["id"]: p for p in plugins.LOADED_PLUGINS} + assert rows["graphy"]["settings_category"] == "graphics" + assert rows["graphy"]["has_settings"] is True + assert rows["plainset"]["settings_category"] is None + assert rows["plainset"]["has_settings"] is True + assert rows["noset"]["settings_category"] is None + assert rows["noset"]["has_settings"] is False diff --git a/tests/test_settings_api.py b/tests/test_settings_api.py index 197cfe8..d995a95 100644 --- a/tests/test_settings_api.py +++ b/tests/test_settings_api.py @@ -35,9 +35,11 @@ class _DirectSettingsClient: return _DirectResponse(self._server.get_settings()) def post(self, path, json): - if path != "/api/settings": - raise ValueError(f"unsupported path: {path}") - return _DirectResponse(self._server.save_settings(json)) + if path == "/api/settings": + return _DirectResponse(self._server.save_settings(json)) + if path == "/api/settings/reset": + return _DirectResponse(self._server.reset_settings(json)) + raise ValueError(f"unsupported path: {path}") def close(self): pass @@ -682,3 +684,91 @@ def test_skip_startup_tasks_clears_stale_plugin_registry(tmp_path, monkeypatch, if conn is not None: conn.close() _restore_loaded_plugins(plugins_snapshot) + + +# ── v0.3.0 gameplay settings (tabbed settings page) ───────────────────────── + +def test_countdown_before_song_persists_bool(client, tmp_path): + r = client.post("/api/settings", json={"countdown_before_song": True}) + assert r.status_code == 200 + assert _read_cfg(tmp_path)["countdown_before_song"] is True + client.post("/api/settings", json={"countdown_before_song": False}) + assert _read_cfg(tmp_path)["countdown_before_song"] is False + + +@pytest.mark.parametrize("bad_value", [1, 0, "true", "yes", [], {}]) +def test_countdown_before_song_rejects_non_bool(client, tmp_path, bad_value): + (tmp_path / "config.json").write_text(json.dumps({"countdown_before_song": True})) + r = client.post("/api/settings", json={"countdown_before_song": bad_value}) + assert "error" in r.json() + # Previous value preserved on bad input. + assert _read_cfg(tmp_path)["countdown_before_song"] is True + + +@pytest.mark.parametrize("key,good,bad", [ + ("miss_penalty", "high", "extreme"), + ("fail_behavior", "restart", "explode"), +]) +def test_enum_settings_validate(client, tmp_path, key, good, bad): + r = client.post("/api/settings", json={key: good}) + assert r.status_code == 200 + assert _read_cfg(tmp_path)[key] == good + # Bad enum value is rejected and doesn't clobber the persisted good one. + r = client.post("/api/settings", json={key: bad}) + assert "error" in r.json() + assert _read_cfg(tmp_path)[key] == good + + +def test_defaults_include_gameplay_keys(client, tmp_path): + # Fresh install (no config.json) — GET should expose the new keys at their + # neutral defaults so the frontend hydrates predictably. + data = client.get("/api/settings").json() + assert data["countdown_before_song"] is False + assert data["miss_penalty"] == "none" + assert data["fail_behavior"] == "continue" + + +# ── /api/settings/reset ───────────────────────────────────────────────────── + +def test_reset_clears_requested_keys(client, tmp_path): + (tmp_path / "config.json").write_text(json.dumps({ + "master_difficulty": 40, + "countdown_before_song": True, + "default_arrangement": "Lead", + "demucs_server_url": "http://demucs.example:9000", + })) + r = client.post("/api/settings/reset", + json={"keys": ["master_difficulty", "countdown_before_song"]}) + assert r.status_code == 200 + body = r.json() + assert set(body["reset"]) == {"master_difficulty", "countdown_before_song"} + cfg = _read_cfg(tmp_path) + # Reset removes the key so GET falls back to the default. + assert "master_difficulty" not in cfg + assert "countdown_before_song" not in cfg + # Unlisted keys are untouched. + assert cfg["default_arrangement"] == "Lead" + assert cfg["demucs_server_url"] == "http://demucs.example:9000" + + +def test_reset_ignores_unknown_keys(client, tmp_path): + (tmp_path / "config.json").write_text(json.dumps({"master_difficulty": 40})) + # Unknown / non-resettable keys are silently ignored, not an error, and + # can't be used to delete arbitrary config. + r = client.post("/api/settings/reset", + json={"keys": ["dlc_dir", "not_a_real_key", "master_difficulty"]}) + assert r.status_code == 200 + assert r.json()["reset"] == ["master_difficulty"] + assert "master_difficulty" not in _read_cfg(tmp_path) + + +def test_reset_bad_body_returns_error(client, tmp_path): + r = client.post("/api/settings/reset", json={"keys": "master_difficulty"}) + assert "error" in r.json() + + +def test_reset_with_no_config_is_noop(client, tmp_path): + # No config.json yet — already at defaults, nothing to remove. + r = client.post("/api/settings/reset", json={"keys": ["master_difficulty"]}) + assert r.status_code == 200 + assert r.json()["reset"] == []