mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-16 21:53:23 +00:00
feat(core): route the highway chart to the selected instrument's part (working-tuning PR 2) (#659)
When a song loads without an explicit arrangement, highway_ws now reads the player's selected `instrument` from config.json (the same file it already reads for the default-arrangement preference) and picks the arrangement that matches: bass -> the Bass part. Guitar — and any unknown/future instrument (drums, keys) — falls through to the existing preference/most-notes default, which already lands on a guitar part. Previously the instrument selector only fed the tuner, so a bass player was handed the default Lead/guitar chart, and the working-tuning coverage check then compared a 4-string bass against a 6-string part (always "can't cover"). This is the instrument->chart routing the working-tuning series leans on. Server-only (every launch path flows through the WS, so no client change). An explicit arrangement request always wins, so only the default part chosen on load changes. Tests: tests/test_highway_ws_instrument_routing.py (bass->Bass, guitar->default, explicit-wins) — 3 new, existing highway WS tests still green. Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
491039a12d
commit
c4bb58233d
@@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
- **The highway now loads the part that matches your selected instrument — a bass player gets the Bass arrangement, not the default Lead/guitar chart.** When you open a song without an explicit arrangement, the WebSocket handler (`server.py` `highway_ws`) reads your selected `instrument` from `config.json` (the same file it already reads for your default-arrangement preference) and routes to the matching part: **bass → the Bass arrangement**; guitar — and any unknown/future instrument (drums, keys) — falls through to the existing preference/most-notes default, which already lands on a guitar part. Previously the instrument selector only fed the tuner, so a bass player was handed a guitar chart (and a tune/coverage check then compared a 4-string bass against a 6-string part). An **explicit arrangement request always wins** (a manual arrangement switch is untouched), and a bass player's saved default-arrangement preference is still honored **within** the bass parts (so a preferred `Bass 2` / `Alt. Bass` wins over the canonical Bass), so this only changes the *default* part chosen on load. Server-only — every launch path already flows through the WS, so there's no client change. This is the instrument↔chart-routing piece the working-tuning series leans on (otherwise coverage compares across instruments). Tests: `tests/test_highway_ws_instrument_routing.py` (bass→Bass, bass-honors-pref, bass-no-bass-part→guitar, guitar→default, explicit-wins).
|
||||||
- **Host "working tuning" — a live, app-wide record of what tuning your instrument is *actually* in right now (foundation; no behavior change yet).** Introduces `window.feedBack.workingTuning`, a host-owned, session-lived state distinct from any one song's tuning and from a soft opt-in default: the offsets + string-count + reference pitch the player's instrument is currently in, plus an `assumed`/`verified` provenance flag. It's **per-instrument** — your guitar's current tuning and your bass's are kept *separately* (keyed like the instrument selector, e.g. `guitar-6` / `bass-4`), so switching instruments surfaces that instrument's own remembered tuning and you only ever deal with the one you've selected. It exists so a retune — or an instrument swap mid-session — is reflected **everywhere** (the highway, the library/song-picker, and plugins like the tuner, Virtuoso, and the minigames) instead of being re-derived per surface or wrongly assumed from a fixed profile. Modeled on the shipped `tuning` capability + the `feedBack.theme` read-API: a **synchronous `get(instrument?)`** (returns the selected instrument's state, defaulting to the seed until known), a `set(state, {provenance, instrument})` mutator (the tuner becomes the sole writer in the next change), `setCurrentInstrument()` for the selector, `resetToDefault()`, and a `working-tuning-changed` event that fires on every change **and once on hydration** (carrying which instrument changed) so a late-mounting consumer is never stuck on stale state. State is **in-memory, seeded from `/api/settings` on boot and reset on restart** — a stale "you're in drop-A" assumption is worse than re-asking. Registered as a separate `working-tuning` **exclusive-owner** capability (tuner = writer, the rest = requesters). This is the foundation (plumbing only — nothing writes to it yet) of the working-tuning series, which fixes the tuner gate only ever prompting *away from* a fixed "home" tuning (never back) and makes the current tuning a first-class signal the whole app shares. Offsets use the same per-string semitone vocabulary as song tunings, so fully custom/extended tunings (e.g. a drop-A 8-string) are first-class. Frontend-only: new `static/capabilities/working-tuning.js`, loaded from `static/index.html` + `static/v3/index.html`.
|
- **Host "working tuning" — a live, app-wide record of what tuning your instrument is *actually* in right now (foundation; no behavior change yet).** Introduces `window.feedBack.workingTuning`, a host-owned, session-lived state distinct from any one song's tuning and from a soft opt-in default: the offsets + string-count + reference pitch the player's instrument is currently in, plus an `assumed`/`verified` provenance flag. It's **per-instrument** — your guitar's current tuning and your bass's are kept *separately* (keyed like the instrument selector, e.g. `guitar-6` / `bass-4`), so switching instruments surfaces that instrument's own remembered tuning and you only ever deal with the one you've selected. It exists so a retune — or an instrument swap mid-session — is reflected **everywhere** (the highway, the library/song-picker, and plugins like the tuner, Virtuoso, and the minigames) instead of being re-derived per surface or wrongly assumed from a fixed profile. Modeled on the shipped `tuning` capability + the `feedBack.theme` read-API: a **synchronous `get(instrument?)`** (returns the selected instrument's state, defaulting to the seed until known), a `set(state, {provenance, instrument})` mutator (the tuner becomes the sole writer in the next change), `setCurrentInstrument()` for the selector, `resetToDefault()`, and a `working-tuning-changed` event that fires on every change **and once on hydration** (carrying which instrument changed) so a late-mounting consumer is never stuck on stale state. State is **in-memory, seeded from `/api/settings` on boot and reset on restart** — a stale "you're in drop-A" assumption is worse than re-asking. Registered as a separate `working-tuning` **exclusive-owner** capability (tuner = writer, the rest = requesters). This is the foundation (plumbing only — nothing writes to it yet) of the working-tuning series, which fixes the tuner gate only ever prompting *away from* a fixed "home" tuning (never back) and makes the current tuning a first-class signal the whole app shares. Offsets use the same per-string semitone vocabulary as song tunings, so fully custom/extended tunings (e.g. a drop-A 8-string) are first-class. Frontend-only: new `static/capabilities/working-tuning.js`, loaded from `static/index.html` + `static/v3/index.html`.
|
||||||
- **The v3 Songs grid is now DOM-virtualized — card-node count stays bounded no matter how big the library is or how far you scroll.** The grid used to append every scrolled page and never let go, so a 2000-song library grew the DOM from 24 → 624 → 2001 card nodes as you scrolled (layout/memory cost scaling with depth). It now renders only the **visible window** of cards (± a small overscan); a sizer element sized to the whole library (`ceil(total/cols) × rowH`) gives the scrollbar its full geometry while the grid is absolutely positioned to the first visible row. `state.songs` is a sparse, absolutely-indexed store fetched a page at a time on demand — using the stage-1 **keyset cursor** for contiguous forward scroll (O(page)) and falling back to `OFFSET page=` for jumps/restore/non-keyset providers (collections, remote). Verified bounded (~60 nodes for a 2001-song library while the count still reads "2001 songs"). The **A–Z rail now seeks directly**: `sort_letters` gives a letter's first-row index (cumulative of prior buckets), converted to a scrollTop in O(1) — no more paging through every intervening row (a bounded forward scan covers the rare legacy provider without `sort_letters`). Select-mode selections, accuracy badges, the ⋮ card menu, plugin card actions, scroll-restore (now scrollTop-based, since geometry is stable), and the tree/folder views all survive cards leaving and re-entering the DOM. Plugins that decorate cards get a stable `window.v3Songs.visibleCards()` accessor + a `v3:library-window-rendered` event instead of assuming every card is present (the highway-stutter lesson). Stage 2 of the virtualized-grid project (got-feedback/feedBack#636 item 3), building on the stage-1 keyset data layer below. Frontend-only: `static/v3/songs.js`, `static/v3/v3.css`. Tests: `tests/browser/v3-grid-virtualization.spec.ts` (bounded-DOM invariant across a 2001-song scroll + direct rail jump), updated `tests/js/v3_az_rail.test.js` + `tests/js/v3_songs_scroll.test.js`.
|
- **The v3 Songs grid is now DOM-virtualized — card-node count stays bounded no matter how big the library is or how far you scroll.** The grid used to append every scrolled page and never let go, so a 2000-song library grew the DOM from 24 → 624 → 2001 card nodes as you scrolled (layout/memory cost scaling with depth). It now renders only the **visible window** of cards (± a small overscan); a sizer element sized to the whole library (`ceil(total/cols) × rowH`) gives the scrollbar its full geometry while the grid is absolutely positioned to the first visible row. `state.songs` is a sparse, absolutely-indexed store fetched a page at a time on demand — using the stage-1 **keyset cursor** for contiguous forward scroll (O(page)) and falling back to `OFFSET page=` for jumps/restore/non-keyset providers (collections, remote). Verified bounded (~60 nodes for a 2001-song library while the count still reads "2001 songs"). The **A–Z rail now seeks directly**: `sort_letters` gives a letter's first-row index (cumulative of prior buckets), converted to a scrollTop in O(1) — no more paging through every intervening row (a bounded forward scan covers the rare legacy provider without `sort_letters`). Select-mode selections, accuracy badges, the ⋮ card menu, plugin card actions, scroll-restore (now scrollTop-based, since geometry is stable), and the tree/folder views all survive cards leaving and re-entering the DOM. Plugins that decorate cards get a stable `window.v3Songs.visibleCards()` accessor + a `v3:library-window-rendered` event instead of assuming every card is present (the highway-stutter lesson). Stage 2 of the virtualized-grid project (got-feedback/feedBack#636 item 3), building on the stage-1 keyset data layer below. Frontend-only: `static/v3/songs.js`, `static/v3/v3.css`. Tests: `tests/browser/v3-grid-virtualization.spec.ts` (bounded-DOM invariant across a 2001-song scroll + direct rail jump), updated `tests/js/v3_az_rail.test.js` + `tests/js/v3_songs_scroll.test.js`.
|
||||||
- **Keyset (cursor) pagination for the library grid — the data layer for an upcoming virtualized grid, and a latent paging bug fixed along the way.** Every library sort now carries a unique `filename` tiebreak, making the order **total** — which fixes a latent bug where rows sharing a sort key (e.g. two songs by the same artist) could be skipped or duplicated across `OFFSET` pages. `GET /api/library` gains an opaque `after` cursor + a `next_cursor` in the response: passing the cursor back fetches the next page with a **WHERE-seek** instead of `OFFSET`, so deep paging is O(page) regardless of depth. The seek is NULL-aware and exactly `OFFSET`-equivalent (verified across artist/title/recent, ascending + descending, including the legacy `dir=desc` shape and NULL sort keys); unknown/compound sorts and bad cursors fall back to `OFFSET`, and only the local provider is handed a cursor (collections/remote page by `OFFSET`). New composite `(artist NOCASE, filename)` / `(title NOCASE, filename)` / `(mtime, filename)` indexes cover the order. This is stage 1 of the virtualized-grid project (got-feedback/feedBack#636 item 3); the DOM-recycling render window builds on it next. Tests: `tests/test_library_keyset.py` (keyset==OFFSET parity, stable tiebreak, dir=desc, NULL keys, cursor fallback).
|
- **Keyset (cursor) pagination for the library grid — the data layer for an upcoming virtualized grid, and a latent paging bug fixed along the way.** Every library sort now carries a unique `filename` tiebreak, making the order **total** — which fixes a latent bug where rows sharing a sort key (e.g. two songs by the same artist) could be skipped or duplicated across `OFFSET` pages. `GET /api/library` gains an opaque `after` cursor + a `next_cursor` in the response: passing the cursor back fetches the next page with a **WHERE-seek** instead of `OFFSET`, so deep paging is O(page) regardless of depth. The seek is NULL-aware and exactly `OFFSET`-equivalent (verified across artist/title/recent, ascending + descending, including the legacy `dir=desc` shape and NULL sort keys); unknown/compound sorts and bad cursors fall back to `OFFSET`, and only the local provider is handed a cursor (collections/remote page by `OFFSET`). New composite `(artist NOCASE, filename)` / `(title NOCASE, filename)` / `(mtime, filename)` indexes cover the order. This is stage 1 of the virtualized-grid project (got-feedback/feedBack#636 item 3); the DOM-recycling render window builds on it next. Tests: `tests/test_library_keyset.py` (keyset==OFFSET parity, stable tiebreak, dir=desc, NULL keys, cursor fallback).
|
||||||
|
|||||||
@@ -7842,15 +7842,63 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
|
|||||||
if 0 <= arrangement < len(song.arrangements):
|
if 0 <= arrangement < len(song.arrangements):
|
||||||
best = arrangement
|
best = arrangement
|
||||||
else:
|
else:
|
||||||
# Check user's default arrangement preference
|
# Read the user's config once: their selected instrument (route the chart
|
||||||
|
# to the matching part) and their default-arrangement preference.
|
||||||
pref = ""
|
pref = ""
|
||||||
|
sel_instrument = ""
|
||||||
config_file = CONFIG_DIR / "config.json"
|
config_file = CONFIG_DIR / "config.json"
|
||||||
if config_file.exists():
|
if config_file.exists():
|
||||||
try:
|
try:
|
||||||
pref = json.loads(config_file.read_text(encoding="utf-8")).get("default_arrangement", "")
|
_cfg = json.loads(config_file.read_text(encoding="utf-8"))
|
||||||
|
pref = _cfg.get("default_arrangement", "")
|
||||||
|
sel_instrument = (_cfg.get("instrument", "") or "")
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
if pref:
|
# Instrument routing: load the part that matches the selected instrument so
|
||||||
|
# "your instrument" and "the chart you play" line up. The default ordering
|
||||||
|
# is Lead/guitar-first, so without this a bass player gets handed a guitar
|
||||||
|
# chart (and any tune-check then compares a 4-string bass against a 6-string
|
||||||
|
# part). Currently routes bass -> a Bass arrangement; guitar — and any
|
||||||
|
# unknown/future instrument (drums, keys) — falls through to the
|
||||||
|
# preference/most-notes logic below, which already lands on a guitar part.
|
||||||
|
# Drums/keys get their own match when those arrangement types + selector
|
||||||
|
# entries land. Only applies when no explicit arrangement was requested, so
|
||||||
|
# a manual arrangement switch is always respected.
|
||||||
|
if sel_instrument.lower() == "bass":
|
||||||
|
# Candidate bass parts, preferring the structured pathBass flag; the
|
||||||
|
# normalized smart name (itself pathBass-derived) and raw name are
|
||||||
|
# fallbacks for sources without the flag.
|
||||||
|
bass_idxs = [
|
||||||
|
i
|
||||||
|
for i, a in enumerate(song.arrangements)
|
||||||
|
if getattr(a, "path_bass", False)
|
||||||
|
or (smart_names[i] or "").lower().startswith("bass")
|
||||||
|
or "bass" in (getattr(a, "name", "") or "").lower()
|
||||||
|
]
|
||||||
|
if bass_idxs:
|
||||||
|
# Among the bass parts: (1) honor the saved default-arrangement
|
||||||
|
# preference if it names one of them (so a bass player who prefers
|
||||||
|
# "Bass 2"/"Alt. Bass" keeps it), (2) else the canonical main "Bass",
|
||||||
|
# (3) else the first bass part in order.
|
||||||
|
pref_bass = -1
|
||||||
|
if pref:
|
||||||
|
for i in bass_idxs:
|
||||||
|
nm = (smart_names[i] if naming_mode == "smart" and i < len(smart_names)
|
||||||
|
else getattr(song.arrangements[i], "name", ""))
|
||||||
|
if nm == pref:
|
||||||
|
pref_bass = i
|
||||||
|
break
|
||||||
|
if pref_bass >= 0:
|
||||||
|
best = pref_bass
|
||||||
|
else:
|
||||||
|
best = next(
|
||||||
|
(i for i in bass_idxs
|
||||||
|
if (smart_names[i] if i < len(smart_names) else "") == "Bass"),
|
||||||
|
bass_idxs[0],
|
||||||
|
)
|
||||||
|
# User's default arrangement preference (only when instrument routing did not
|
||||||
|
# already resolve a part — i.e. guitar, or a bass player with no bass part).
|
||||||
|
if best < 0 and pref:
|
||||||
if naming_mode == "smart":
|
if naming_mode == "smart":
|
||||||
best = _pick_smart_arrangement(song.arrangements, smart_names, pref)
|
best = _pick_smart_arrangement(song.arrangements, smart_names, pref)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
"""Tests for instrument->chart arrangement routing in the highway WS.
|
||||||
|
|
||||||
|
When no explicit arrangement is requested, the WS picks the arrangement matching
|
||||||
|
the player's selected instrument (config.json `instrument`) so a bass player gets
|
||||||
|
the Bass part instead of the default Lead/guitar chart. An explicit arrangement
|
||||||
|
request always wins.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import yaml
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
def _arr(notes):
|
||||||
|
return {
|
||||||
|
"notes": notes,
|
||||||
|
"chords": [],
|
||||||
|
"anchors": [],
|
||||||
|
"handshapes": [],
|
||||||
|
"templates": [],
|
||||||
|
"beats": [{"time": 0.0, "measure": 1}],
|
||||||
|
"sections": [{"name": "intro", "number": 1, "time": 0.0}],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _write_multi_arr_sloppak(dlc_root):
|
||||||
|
"""A song with a Lead (guitar) and a Bass arrangement, Lead first (index 0)."""
|
||||||
|
pak = dlc_root / "multi.sloppak"
|
||||||
|
pak.mkdir()
|
||||||
|
(pak / "arrangements").mkdir()
|
||||||
|
(pak / "arrangements" / "lead.json").write_text(json.dumps(_arr([])))
|
||||||
|
(pak / "arrangements" / "bass.json").write_text(json.dumps(_arr([])))
|
||||||
|
manifest = {
|
||||||
|
"title": "Multi",
|
||||||
|
"artist": "Tester",
|
||||||
|
"album": "",
|
||||||
|
"year": 2026,
|
||||||
|
"duration": 10.0,
|
||||||
|
"arrangements": [
|
||||||
|
{"id": "lead", "name": "Lead", "file": "arrangements/lead.json"},
|
||||||
|
{"id": "bass", "name": "Bass", "file": "arrangements/bass.json"},
|
||||||
|
],
|
||||||
|
"stems": [],
|
||||||
|
}
|
||||||
|
(pak / "manifest.yaml").write_text(yaml.safe_dump(manifest, sort_keys=False))
|
||||||
|
return pak
|
||||||
|
|
||||||
|
|
||||||
|
def _write_sloppak(dlc_root, name, arrangements):
|
||||||
|
"""Write a .sloppak whose arrangements are (id, display-name) pairs, in order."""
|
||||||
|
pak = dlc_root / f"{name}.sloppak"
|
||||||
|
pak.mkdir()
|
||||||
|
(pak / "arrangements").mkdir()
|
||||||
|
manifest_arrs = []
|
||||||
|
for arr_id, arr_name in arrangements:
|
||||||
|
(pak / "arrangements" / f"{arr_id}.json").write_text(json.dumps(_arr([])))
|
||||||
|
manifest_arrs.append(
|
||||||
|
{"id": arr_id, "name": arr_name, "file": f"arrangements/{arr_id}.json"}
|
||||||
|
)
|
||||||
|
manifest = {
|
||||||
|
"title": name,
|
||||||
|
"artist": "Tester",
|
||||||
|
"album": "",
|
||||||
|
"year": 2026,
|
||||||
|
"duration": 10.0,
|
||||||
|
"arrangements": manifest_arrs,
|
||||||
|
"stems": [],
|
||||||
|
}
|
||||||
|
(pak / "manifest.yaml").write_text(yaml.safe_dump(manifest, sort_keys=False))
|
||||||
|
return pak
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def make_client(tmp_path, monkeypatch):
|
||||||
|
def _make(instrument=None, default_arrangement=None):
|
||||||
|
cfg = tmp_path / "config"
|
||||||
|
cfg.mkdir(exist_ok=True)
|
||||||
|
conf = {}
|
||||||
|
if instrument is not None:
|
||||||
|
conf["instrument"] = instrument
|
||||||
|
if default_arrangement is not None:
|
||||||
|
conf["default_arrangement"] = default_arrangement
|
||||||
|
if conf:
|
||||||
|
(cfg / "config.json").write_text(json.dumps(conf), encoding="utf-8")
|
||||||
|
monkeypatch.setenv("CONFIG_DIR", str(cfg))
|
||||||
|
monkeypatch.setenv("DLC_DIR", str(tmp_path / "dlc"))
|
||||||
|
monkeypatch.setenv("FEEDBACK_SYNC_STARTUP", "1")
|
||||||
|
sys.modules.pop("server", None)
|
||||||
|
server = importlib.import_module("server")
|
||||||
|
monkeypatch.setattr(server, "load_plugins", lambda *a, **kw: None)
|
||||||
|
monkeypatch.setattr(server, "startup_scan", lambda: None)
|
||||||
|
monkeypatch.setattr(server, "SLOPPAK_CACHE_DIR", tmp_path / "cache")
|
||||||
|
return server
|
||||||
|
|
||||||
|
(tmp_path / "dlc").mkdir()
|
||||||
|
yield _make
|
||||||
|
server = sys.modules.get("server")
|
||||||
|
conn = getattr(getattr(server, "meta_db", None), "conn", None)
|
||||||
|
if conn is not None:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _arr_index(client, path):
|
||||||
|
with client.websocket_connect(path) as ws:
|
||||||
|
for _ in range(200):
|
||||||
|
msg = ws.receive_json()
|
||||||
|
if msg.get("error"):
|
||||||
|
raise AssertionError(f"WS error frame: {msg}")
|
||||||
|
if msg.get("type") == "song_info":
|
||||||
|
return msg["arrangement_index"]
|
||||||
|
if msg.get("type") == "ready":
|
||||||
|
break
|
||||||
|
raise AssertionError("no song_info frame received")
|
||||||
|
|
||||||
|
|
||||||
|
def test_bass_instrument_routes_to_bass_arrangement(make_client):
|
||||||
|
server = make_client(instrument="bass")
|
||||||
|
_write_multi_arr_sloppak(server._get_dlc_dir())
|
||||||
|
with TestClient(server.app) as client:
|
||||||
|
# No explicit arrangement → route to Bass (index 1), not the default Lead.
|
||||||
|
idx = _arr_index(client, "/ws/highway/multi.sloppak?naming_mode=smart")
|
||||||
|
assert idx == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_guitar_instrument_keeps_default(make_client):
|
||||||
|
server = make_client(instrument="guitar")
|
||||||
|
_write_multi_arr_sloppak(server._get_dlc_dir())
|
||||||
|
with TestClient(server.app) as client:
|
||||||
|
idx = _arr_index(client, "/ws/highway/multi.sloppak?naming_mode=smart")
|
||||||
|
assert idx == 0 # guitar falls through to the default → Lead
|
||||||
|
|
||||||
|
|
||||||
|
def test_explicit_arrangement_overrides_instrument(make_client):
|
||||||
|
server = make_client(instrument="bass")
|
||||||
|
_write_multi_arr_sloppak(server._get_dlc_dir())
|
||||||
|
with TestClient(server.app) as client:
|
||||||
|
# An explicit arrangement request wins even for a bass player.
|
||||||
|
idx = _arr_index(client, "/ws/highway/multi.sloppak?arrangement=0")
|
||||||
|
assert idx == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_bass_with_no_bass_part_falls_through_to_guitar(make_client):
|
||||||
|
server = make_client(instrument="bass")
|
||||||
|
# Lead + Rhythm, no bass part at all.
|
||||||
|
_write_sloppak(server._get_dlc_dir(), "gtr", [("lead", "Lead"), ("rhythm", "Rhythm")])
|
||||||
|
with TestClient(server.app) as client:
|
||||||
|
idx = _arr_index(client, "/ws/highway/gtr.sloppak")
|
||||||
|
assert idx == 0 # no bass candidate → existing default (a guitar part)
|
||||||
|
|
||||||
|
|
||||||
|
def test_bass_no_pref_picks_the_primary_bass_not_an_alt(make_client):
|
||||||
|
server = make_client(instrument="bass")
|
||||||
|
# Lead + two bass parts; the canonical "Bass" should win over "Bass 2".
|
||||||
|
_write_sloppak(
|
||||||
|
server._get_dlc_dir(), "bb",
|
||||||
|
[("lead", "Lead"), ("bass", "Bass"), ("bass2", "Bass 2")],
|
||||||
|
)
|
||||||
|
with TestClient(server.app) as client:
|
||||||
|
idx = _arr_index(client, "/ws/highway/bb.sloppak")
|
||||||
|
assert idx == 1 # the primary Bass, not the first-in-order-if-it-were-an-alt
|
||||||
|
|
||||||
|
|
||||||
|
def test_bass_honors_saved_pref_within_the_bass_parts(make_client):
|
||||||
|
# A bass player who saved "Bass 2" keeps it — instrument routing must not clobber
|
||||||
|
# the preference with the primary Bass.
|
||||||
|
server = make_client(instrument="bass", default_arrangement="Bass 2")
|
||||||
|
_write_sloppak(
|
||||||
|
server._get_dlc_dir(), "bb",
|
||||||
|
[("lead", "Lead"), ("bass", "Bass"), ("bass2", "Bass 2")],
|
||||||
|
)
|
||||||
|
with TestClient(server.app) as client:
|
||||||
|
idx = _arr_index(client, "/ws/highway/bb.sloppak")
|
||||||
|
assert idx == 2 # the preferred Bass 2, not the primary Bass (index 1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_guitar_still_honors_saved_pref(make_client):
|
||||||
|
# Guitar routing unchanged: a saved default_arrangement still applies.
|
||||||
|
server = make_client(instrument="guitar", default_arrangement="Rhythm")
|
||||||
|
_write_sloppak(
|
||||||
|
server._get_dlc_dir(), "gtr2",
|
||||||
|
[("lead", "Lead"), ("rhythm", "Rhythm"), ("bass", "Bass")],
|
||||||
|
)
|
||||||
|
with TestClient(server.app) as client:
|
||||||
|
idx = _arr_index(client, "/ws/highway/gtr2.sloppak")
|
||||||
|
assert idx == 1 # Rhythm, per preference
|
||||||
Reference in New Issue
Block a user