mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-11 19:29:33 +00:00
feat(v3): tabbed, card-row settings page + per-plugin settings category (#584)
Replace the single long scrolling v3 settings screen with a horizontal tab bar (Gameplay / Audio / Graphics / Keybinds / Progression / Mic / Plugins / System) over card rows (icon + title + description, control on the right) with a per-category Reset. - static/v3/index.html: tab bar + card-row markup (ids keep hydrating through the unchanged app.js loadSettings()/persistSetting() path). - static/v3/settings.js (new): tab switching + active-tab persistence (localStorage 'v3-settings-tab'), per-category reset, read-only Keybinds reference from window.getAllShortcuts(). - static/v3/v3.css: plain CSS, no Tailwind rebuild. - Per-plugin settings tab: new optional settings.category in plugin.json → plugins/__init__.py surfaces settings_category; app.js mounts each plugin <details> into #plugin-settings-<category> (fallback: Plugins tab). highway_3d ships category: "graphics". - New gameplay settings: countdown_before_song (wired end-to-end, default off); miss_penalty + fail_behavior (persist-only stubs); "Note highway speed" surfaces existing master_difficulty. - New POST /api/settings/reset clears whitelisted keys back to defaults. Tests: test_settings_api.py, test_plugins.py::test_settings_category_parsed_from_manifest, tests/browser/settings-tabbed.spec.ts. 179 passed locally. Ported from the pre-rename feat/v3-settings-tabbed WIP onto current main (slopsmith→feedBack rename applied; settings-screen markup conflict resolved in favour of the new tabbed layout — all prior setting ids preserved). Closes #579 Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
f3a5cb9ed3
commit
3b485fe62b
@@ -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();
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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"] == []
|
||||
|
||||
Reference in New Issue
Block a user