mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-10-02 19:21:50 +00:00
* Tuner: passive "different tuning" badge cue that names the retune
Building on the coverage check: when you enter a song your current
instrument doesn't cover, the topbar tuner badge gets an amber ring + a
tooltip naming the change (e.g. "retune B->A", or "the reference pitch"
for an A440 vs A432 mismatch). Advisory only -- it never auto-opens the
panel; recomputed on song:ready, cleared on song-load / leaving the
player.
Refactors the coverage check into a structured report
(window._tunerAutoOpen.coverageReport -> { covered, retune:[{from,to}],
reference, cantCover }); the boolean gate now wraps it. The cue is
CSS-free (inline ring + native tooltip, no Tailwind rebuild) and no-ops
when the tuner plugin is absent.
Touches static/v3/badges.js (cue) + plugins/tuner/screen.js (report).
v3-only. Stacked on #656 (issue E stage 2.5/3). The splitscreen-suppress
and no-usable-input guards move to E2 (the playback gate).
Tests: tests/js/tuner_auto_open.test.js (report names the strings,
reference mismatch, badge wiring).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF
* feat(tuner): read/write the live per-instrument working tuning — both-directions retune prompt (working-tuning PR 3) (#660)
The §4 coverage check compared each song against the player's fixed
instrument-profile tuning, so the tuner only ever prompted *away* from a "home"
tuning (E -> Drop C#) and stayed silent coming back (Drop C# -> E), even though
the player had physically retuned.
_playerTuning() now reads the host's live per-instrument working tuning
(window.feedBack.workingTuning, keyed by the selected instrument from
/api/settings) instead of re-deriving from the static settings tuning, so
coverage is measured against what the instrument is ACTUALLY in and prompts both
directions. On clearing an auto-opened tuner, _publishWorkingTuning() writes that
song's tuning as the instrument's live working tuning ('assumed' — PR 4's
explicit "I tuned / Skip" refines the write-point), so the next song is judged
against where the player now is.
Per-instrument (guitar vs bass tracked separately). Feature-detected: falls back
to the static /api/settings tuning when the working-tuning capability is absent,
so the 27 existing coverage tests are unchanged. Builds on PR 1 (host
workingTuning) + PR 2 (instrument->chart routing).
Tests: tests/js/tuner_auto_open.test.js — +2 (both-directions coverage via a live
Drop-D working tuning; publish-on-clear targets the right instrument slot); 29
pass total.
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* fix(tuner): transactional open + fail-closed auto-open config (tuner-E #655 review) (#681)
Two review fixes for the auto-open opt-in+persist stage:
- enable() wasn't transactional. The panel (with the ×/Skip buttons) is shown
before `await _tunerAudio.start()`, and `_state.enabled` was only set after it.
A ×/Skip dismiss during that await hit disable() with wasEnabled=false, then
enable() completed and flipped enabled on — an enabled-but-hidden zombie. Guard
the open with an `_openGen` token bumped on every enable()/disable(); after the
audio-start await, bail if superseded instead of enabling. Closes #675.
- Config wasn't fail-closed. routes.py normalized the opt-in with
bool(data.get("autoOpenOnTuningChange", False)), so "false"/"0"/junk coerced to
True. Accept only a real JSON boolean. Closes #676.
Tests: tuner_auto_open.test.js (dismiss-mid-open stays disabled — fails without
the token guard), test_config.py (auto-open default-false + fail-closed on
non-bool). 34 JS + 24 config tests green.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(tuner): coverage stays conservative when the instrument is unknown (tuner-E #656 review) (#682)
_playerTuning() is documented as conservative ("missing data → not covered → still
prompt"), but when /api/settings carried no instrument identity (a fresh profile:
_default_settings() omits instrument/string_count/tuning) it invented guitar/6/440/
standard, so an unconfigured player was treated as 6-string E-standard and coverage
suppressed the auto-open (and badge cue) for matching songs. The post-#660 rewrite
only returned null when the whole fetch failed (!s), not when settings existed but
lacked an instrument.
Now return null unless there's a confident identity — any of instrument/string_count/
tuning in settings, or live working-tuning offsets. A configured standard guitar still
covers a standard song (no regression). Closes #677.
Tests: tuner_auto_open.test.js — empty-settings → not covered (fails without the fix);
configured standard guitar → still covered.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(tuner): badge coverage cue staleness + unknown-as-warning + dedupe (tuner-E #657 review) (#683)
Three review fixes for the passive "different tuning" badge cue stage:
- Stale async cue (#678): _refreshCoverageCue awaited coverageReport then wrote the
DOM unconditionally, so a slow /api/settings fetch could restore the previous
song's amber ring after song:loading / leaving the player. Add a monotonic token
bumped on every refresh and both clear paths; apply the awaited report only if the
token still matches.
- "Unknown" rendered as "needs retune" (#679): the plugin returns a conservative
all-false report on a fetch hiccup; the cue painted that as an amber "retune the
reference pitch" ring. Collapse a no-signal report (not covered, no reference /
retune / cantCover) to null (no cue) via _meaningfulReport(). A genuine not-covered
report always carries reference / retune / cantCover, so real cues are preserved.
- Duplicate /api/settings fetch (#680): the auto-open gate and the badge cue both
call coverageReport() per song:ready. Cache the coverage promise per song (keyed by
session + tuning + centOffset) so they share one fetch; invalidate on song:loading,
instrument:changed, and working-tuning-changed so it can't go stale within a song.
Tests: tuner_auto_open.test.js — concurrent reports share one fetch, a new song
refetches (fails without the cache). 34 JS tests green. Codex-reviewed.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Byron Gamatos <xasiklas@gmail.com>
157 lines
7.7 KiB
Python
157 lines
7.7 KiB
Python
"""Unit tests for config logic (module-level helpers) and config read/write via HTTP."""
|
|
|
|
import json
|
|
import pytest
|
|
import routes
|
|
|
|
|
|
# ── _migrate_custom_tuning ────────────────────────────────────────────────────
|
|
|
|
class TestMigrateCustomTuning:
|
|
def test_old_flat_list_guitar6(self):
|
|
result = routes._migrate_custom_tuning("My Tuning", [82.41, 110.00, 146.83, 196.00, 246.94, 329.63])
|
|
assert result == {"instrument": "guitar-6", "strings": [82.41, 110.00, 146.83, 196.00, 246.94, 329.63]}
|
|
|
|
def test_old_flat_list_bass4(self):
|
|
result = routes._migrate_custom_tuning("Drop D Bass", [36.71, 55.00, 73.42, 98.00])
|
|
assert result["instrument"] == "bass-4"
|
|
assert result["strings"] == [36.71, 55.00, 73.42, 98.00]
|
|
|
|
def test_old_flat_list_bass5(self):
|
|
result = routes._migrate_custom_tuning("5-String", [30.87, 41.20, 55.00, 73.42, 98.00])
|
|
assert result["instrument"] == "bass-5"
|
|
|
|
def test_old_flat_list_guitar7(self):
|
|
result = routes._migrate_custom_tuning("7-String", [61.74, 82.41, 110.00, 146.83, 196.00, 246.94, 329.63])
|
|
assert result["instrument"] == "guitar-7"
|
|
|
|
def test_old_flat_list_guitar8(self):
|
|
strings = [46.25, 61.74, 82.41, 110.00, 146.83, 196.00, 246.94, 329.63]
|
|
result = routes._migrate_custom_tuning("8-String", strings)
|
|
assert result["instrument"] == "guitar-8"
|
|
|
|
def test_old_flat_list_unknown_count_defaults_guitar6(self):
|
|
result = routes._migrate_custom_tuning("3-String", [100.0, 200.0, 300.0])
|
|
assert result["instrument"] == "guitar-6"
|
|
|
|
def test_new_dict_format_passthrough(self):
|
|
value = {"instrument": "bass-4", "strings": [41.20, 55.00, 73.42, 98.00]}
|
|
result = routes._migrate_custom_tuning("My Bass", value)
|
|
assert result == value
|
|
|
|
def test_malformed_dict_returns_empty_guitar6(self):
|
|
result = routes._migrate_custom_tuning("Bad", {"foo": "bar"})
|
|
assert result == {"instrument": "guitar-6", "strings": []}
|
|
|
|
|
|
# ── Config read/write via HTTP ────────────────────────────────────────────────
|
|
|
|
class TestConfigDefaults:
|
|
def test_get_returns_all_default_keys(self, client):
|
|
r = client.get("/api/plugins/tuner/config")
|
|
assert r.status_code == 200
|
|
body = r.json()
|
|
assert body["lastTuning"] == "Standard"
|
|
assert body["lastInstrument"] == "guitar-6"
|
|
assert body["audioInputMode"] == "auto"
|
|
assert body["showFloatingButton"] is True
|
|
assert body["visualizationMode"] == "default"
|
|
assert body["customTunings"] == {}
|
|
assert body["disabledTunings"] == []
|
|
|
|
def test_get_does_not_include_default_tunings(self, client):
|
|
# defaultTunings moved to GET /api/tunings (core tuning.read capability).
|
|
body = client.get("/api/plugins/tuner/config").json()
|
|
assert "defaultTunings" not in body
|
|
|
|
|
|
class TestConfigPersistence:
|
|
def test_partial_update_persisted(self, client):
|
|
client.post("/api/plugins/tuner/config", json={"lastTuning": "Drop D"})
|
|
r = client.get("/api/plugins/tuner/config")
|
|
assert r.json()["lastTuning"] == "Drop D"
|
|
|
|
def test_unmodified_fields_survive_partial_update(self, client):
|
|
client.post("/api/plugins/tuner/config", json={"lastTuning": "Drop D"})
|
|
client.post("/api/plugins/tuner/config", json={"visualizationMode": "strobe"})
|
|
body = client.get("/api/plugins/tuner/config").json()
|
|
assert body["lastTuning"] == "Drop D"
|
|
assert body["visualizationMode"] == "strobe"
|
|
|
|
def test_default_tunings_not_written_to_file(self, client, config_dir):
|
|
client.post("/api/plugins/tuner/config", json={
|
|
"lastTuning": "Open G",
|
|
"defaultTunings": {"guitar-6": {"Standard": []}},
|
|
})
|
|
saved = json.loads((config_dir / "tuner.json").read_text())
|
|
assert "defaultTunings" not in saved
|
|
|
|
def test_invalid_audio_mode_resets_to_auto(self, client):
|
|
client.post("/api/plugins/tuner/config", json={"audioInputMode": "invalid"})
|
|
body = client.get("/api/plugins/tuner/config").json()
|
|
assert body["audioInputMode"] == "auto"
|
|
|
|
def test_valid_audio_mode_browser_accepted(self, client):
|
|
client.post("/api/plugins/tuner/config", json={"audioInputMode": "browser"})
|
|
assert client.get("/api/plugins/tuner/config").json()["audioInputMode"] == "browser"
|
|
|
|
def test_auto_open_defaults_false(self, client):
|
|
assert client.get("/api/plugins/tuner/config").json()["autoOpenOnTuningChange"] is False
|
|
|
|
def test_auto_open_true_accepted(self, client):
|
|
client.post("/api/plugins/tuner/config", json={"autoOpenOnTuningChange": True})
|
|
assert client.get("/api/plugins/tuner/config").json()["autoOpenOnTuningChange"] is True
|
|
|
|
def test_auto_open_fail_closed_on_non_bool(self, client):
|
|
# A hand-edited / bad-client non-boolean (e.g. the string "false") must NOT be
|
|
# coerced to True by bool() — the opt-in stays off.
|
|
for bad in ("false", "0", "1", "yes", 1, {}):
|
|
client.post("/api/plugins/tuner/config", json={"autoOpenOnTuningChange": bad})
|
|
assert client.get("/api/plugins/tuner/config").json()["autoOpenOnTuningChange"] is False, bad
|
|
|
|
def test_disabled_tunings_strips_entries_without_colon(self, client):
|
|
client.post("/api/plugins/tuner/config", json={
|
|
"disabledTunings": ["guitar-6:Drop D", "legacy-entry", "bass-4:Standard"]
|
|
})
|
|
body = client.get("/api/plugins/tuner/config").json()
|
|
assert "legacy-entry" not in body["disabledTunings"]
|
|
assert "guitar-6:Drop D" in body["disabledTunings"]
|
|
assert "bass-4:Standard" in body["disabledTunings"]
|
|
|
|
def test_custom_tuning_old_format_migrated_on_read(self, client, config_dir):
|
|
(config_dir / "tuner.json").write_text(json.dumps({
|
|
"customTunings": {"My Tuning": [82.41, 110.0, 146.83, 196.0, 246.94, 329.63]}
|
|
}))
|
|
body = client.get("/api/plugins/tuner/config").json()
|
|
assert body["customTunings"]["My Tuning"]["instrument"] == "guitar-6"
|
|
assert isinstance(body["customTunings"]["My Tuning"]["strings"], list)
|
|
|
|
def test_malformed_config_file_returns_defaults(self, client, config_dir):
|
|
(config_dir / "tuner.json").write_text("not json at all {{")
|
|
r = client.get("/api/plugins/tuner/config")
|
|
assert r.status_code == 200
|
|
assert r.json()["lastTuning"] == "Standard"
|
|
|
|
|
|
# ── referencePitch — now a core setting, not a tuner config field ─────────────
|
|
# referencePitch moved to GET/POST /api/settings (core tuning.read capability).
|
|
# The tuner config must not expose or persist it.
|
|
|
|
class TestReferencePitchNotInTunerConfig:
|
|
def test_not_present_in_default_response(self, client):
|
|
body = client.get("/api/plugins/tuner/config").json()
|
|
assert "referencePitch" not in body
|
|
|
|
def test_posting_reference_pitch_is_ignored(self, client):
|
|
# POSTing referencePitch must not break the request or leak the field back.
|
|
r = client.post("/api/plugins/tuner/config", json={"referencePitch": 432, "lastTuning": "Drop D"})
|
|
assert r.status_code == 200
|
|
body = client.get("/api/plugins/tuner/config").json()
|
|
assert "referencePitch" not in body
|
|
assert body["lastTuning"] == "Drop D"
|
|
|
|
def test_reference_pitch_not_written_to_file(self, client, config_dir):
|
|
client.post("/api/plugins/tuner/config", json={"referencePitch": 443})
|
|
saved = json.loads((config_dir / "tuner.json").read_text())
|
|
assert "referencePitch" not in saved
|