feedBack/tests/test_tuning_provider_isolation.py
ChrisBeWithYou 605dbdfd25
feat(sloppak): load multiple drum parts (feedpak 1.17.0 drums-as-arrangements) (#1020)
* feat(sloppak): load multiple drum parts (feedpak 1.17.0 drums-as-arrangements)

A song can now ship SEVERAL drum charts (a second drummer, an aux-percussion
layer). The Arrangement Editor already writes them per the feedpak 1.17.0 FEP
(feedpak-spec#63): the primary stays the song-level `drum_tab:` key (what this
app has always played), and each part rides the manifest as a `type: drums`
arrangement entry carrying a per-arrangement `drum_tab` file pointer and NO
note `file` — an entry this loader's file/notation gate already skips, which
is exactly why old builds are unaffected by such packs.

lib/sloppak.py:
- The arrangements loop collects drum-part pointer entries instead of merely
  skipping them — but still NEVER turns one into a fretted Arrangement. That
  skip is the grading invariant (an empty drum chart must not reach the
  fretted pipeline / note-detection grading) and is now pinned by test.
- New `LoadedSloppak.drum_parts`: [{id, name, drum_tab}], primary FIRST. The
  entry aliasing the song-level file contributes its id/name but is never
  loaded twice (the primary's payload IS `loaded.drum_tab`, same object).
  Legacy single-drum packs read as a one-part list; a pointer-only pack (a
  writer omitted the alias) promotes its first part so has_drum_tab, the
  default stream, and the drum-only placeholder keep working.
- The song-level drum_tab loading block is extracted verbatim into
  `_load_drum_tab_file()` and shared by both paths, so every part gets the
  same permissive posture: missing file → that part silently absent;
  traversal / parse / validation failure → that part skipped with a warning,
  never an aborted load. (The 9 pinned drumtab-load tests pass unchanged.)

lib/routers/ws_highway.py:
- `song_info` gains `drum_parts` (names only; always a list, empty without
  drums) so a part picker can bind unconditionally.
- `?drum_part=<id>` on the WS URL selects which part's tab streams as the
  `drum_tab`/`drum_hits` messages; the default and any unknown id fall back
  to the primary — byte-identical legacy behavior. The `drum_tab` message
  carries `part_id` only when a parts list exists, keeping the legacy frame
  unchanged.

Tests: tests/test_sloppak_drum_parts.py (9) — the grading invariant +
parallel-ids pin, primary-first resolution with alias identity, legacy
one-part list, pointer-only promotion, per-part failure isolation (bad JSON,
path traversal, duplicate rels), and the drum-only placeholder with pointer
entries. Full suite: the only failures are 9 machine-environmental tests
(installed desktop plugins under LOCALAPPDATA, CRLF/path-shape assertions)
that fail identically on an untouched origin/main checkout on this box.
tools/check_spec_conformance.py passes against the spec's current HEAD
(`drum_tab` and `type` are declared keys); the semantics of the
per-arrangement placement land in feedpak-spec#63 — this PR should merge
after it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xGPjDBF8NTwTK7VQvizix
Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>

* Fix drum-part review findings

* Normalize drum part pointer identities

* fix(sloppak): enforce drums grading invariant + green the suite

- Gate the drum-pointer skip on type FIRST: a type:drums/drum entry never becomes
  a fretted Arrangement even if it carries a note file/notation (with drum_tab it
  is collected as a drum part, without it dropped+warned). Closes the spec
  §5.2/§7.5 MUST-NOT hole (a malformed drums+file entry was being fretted-graded).
- Make test_drum_pointer_with_wrong_type_logs_warning robust (attach handler to the
  feedBack logger + set WARNING, restore in finally) and fix the root-cause level
  leak in test_tuning_provider_isolation.py (finally restored the handler but not
  the level, leaking ERROR onto the feedBack tree and turning the suite red under
  full ordering).
- Restore the chart-transform CHANGELOG bullet (#952) the drum entry had truncated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-07-21 13:27:41 +02:00

75 lines
2.8 KiB
Python

"""A raising tuning provider must not take down get_merged() for everyone. (#899)
`TuningProviderRegistry.get_merged()` wraps each provider in a try/except precisely so one
misbehaving plugin cannot break tunings for the rest. The handler called `logger.exception`
— and there is no `logger` in server.py; the module logger is `log`. So the handler MEANT
to swallow-and-report instead raised NameError, which propagated out of get_merged().
The net effect was the exact opposite of the handler's purpose: one bad provider took the
whole merged-tunings call down, and the traceback named the wrong problem.
Nothing exercised the failure path, which is why it survived. This is that path.
"""
import importlib
import logging
import sys
import pytest
@pytest.fixture()
def registry(monkeypatch, tmp_path):
monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
sys.modules.pop("server", None)
mod = importlib.import_module("server")
yield mod.TuningProviderRegistry()
def test_a_raising_provider_does_not_break_the_others(registry, caplog):
"""The whole point of the try/except. Before the fix this raised NameError."""
def boom():
raise RuntimeError("provider exploded")
def good():
return {"guitar": {"My Tuning": [82.41, 110.0, 146.83, 196.0, 246.94, 329.63]}}
registry.register("bad-plugin", boom)
registry.register("good-plugin", good)
merged = registry.get_merged() # must NOT raise
assert "My Tuning" in merged["guitar"], (
"the healthy provider's tuning is missing — one raising provider took down the "
"merged result for everyone"
)
# and the default tunings survive
assert merged["guitar"], "default tunings were lost"
def test_the_failure_is_actually_logged(registry, caplog):
"""Swallowing is only acceptable if it is reported. A NameError in the handler meant
nothing was ever logged — the failure was both fatal AND silent about its real cause."""
def boom():
raise RuntimeError("provider exploded")
registry.register("bad-plugin", boom)
# The feedBack logger sets propagate=False, so pytest's root-logger capture sees
# NOTHING from it. Attach caplog's handler directly. (test_plugins.py has a
# capture_logger() context manager for this, but it is not importable from here:
# pyproject pins pythonpath to [".", "lib"], so `tests` is not a package.)
lg = logging.getLogger("feedBack")
orig_level = lg.level
lg.addHandler(caplog.handler)
lg.setLevel(logging.ERROR)
try:
registry.get_merged()
finally:
lg.removeHandler(caplog.handler)
lg.setLevel(orig_level) # restore, or ERROR leaks onto the feedBack tree
assert any("bad-plugin" in r.getMessage() for r in caplog.records), (
"the raising provider was never named in the logs"
)