bfe1434f75
- Channel enum (GUITAR, BASS) with channel field on Preset dataclass
- Channel-prefixed storage: {root}/{channel}/bank_{bank}/preset_{program}.json
- PresetManager channel awareness: set_channel(), current_channel property
- Per-channel state persistence (channel_state.json per channel dir)
- Legacy migration: flat bank_* dirs auto-migrate to guitar/ on first boot
- All CRUD methods accept optional channel parameter
- API endpoints accept channel param on GET/PUT/DELETE /api/presets
- /api/channel GET/POST for channel switching
- 10 new channel tests (independence, switching, migration, scoping)
- Factory presets install to specified channel
664 lines
28 KiB
Python
664 lines
28 KiB
Python
"""Tests for the preset and bank management system.
|
|
|
|
Covers all acceptance criteria:
|
|
- Save/load presets to/from JSON (human-readable)
|
|
- Bank structure with 4 presets per bank
|
|
- Footswitch preset-up/down wraps within bank
|
|
- Bank-up/down switches banks
|
|
- MIDI Program Change loads preset (bank=channel, program=preset)
|
|
- Current preset saved on change (auto-restore on power cycle)
|
|
- Factory preset installation
|
|
- Preset rename and reorder
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import shutil
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from src.presets.manager import (
|
|
FACTORY_PRESET_DIR,
|
|
PRESETS_PER_BANK,
|
|
PresetManager,
|
|
_preset_from_dict,
|
|
_preset_to_dict,
|
|
)
|
|
from src.presets.types import Channel, FXBlock, FXType, MIDIMapping, Preset
|
|
|
|
|
|
# ── Fixtures ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
@pytest.fixture
|
|
def preset_dir() -> Path:
|
|
"""Provide a clean temp directory for each test."""
|
|
tmp = Path(tempfile.mkdtemp(prefix="pedal_presets_"))
|
|
yield tmp
|
|
shutil.rmtree(tmp, ignore_errors=True)
|
|
|
|
|
|
@pytest.fixture
|
|
def manager(preset_dir: Path) -> PresetManager:
|
|
"""Return a PresetManager pointed at the temp dir, no audio pipeline."""
|
|
return PresetManager(preset_dir=str(preset_dir), audio_pipeline=None)
|
|
|
|
|
|
@pytest.fixture
|
|
def sample_preset() -> Preset:
|
|
"""A realistic preset with a few FX blocks and MIDI mappings."""
|
|
return Preset(
|
|
name="Crunch Rhythm",
|
|
bank=0,
|
|
program=0,
|
|
chain=[
|
|
FXBlock(FXType.NOISE_GATE, enabled=True, params={"threshold": 0.01}),
|
|
FXBlock(FXType.OVERDRIVE, enabled=True, params={"drive": 0.6, "gain": 0.8}),
|
|
FXBlock(FXType.NAM_AMP, enabled=True, nam_model_path="/home/pedal/models/marshall.nam"),
|
|
FXBlock(FXType.IR_CAB, enabled=True, ir_file_path="/home/pedal/irs/mesa_v30.wav"),
|
|
FXBlock(FXType.CHORUS, enabled=True, params={"rate": 0.4, "depth": 0.6, "mix": 0.3}),
|
|
FXBlock(FXType.DELAY, enabled=True, params={"time": 420.0, "feedback": 0.35, "mix": 0.25}),
|
|
FXBlock(FXType.REVERB, enabled=True, params={"decay": 0.5, "mix": 0.2}),
|
|
FXBlock(FXType.VOLUME, enabled=True, params={"level": 0.85}),
|
|
],
|
|
midi_mappings={
|
|
"drive": MIDIMapping(cc_number=14, channel=0, min_val=0.0, max_val=1.0),
|
|
"mix": MIDIMapping(cc_number=15, channel=0, min_val=0.0, max_val=1.0),
|
|
},
|
|
master_volume=0.8,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def factory_preset_dir(preset_dir: Path) -> Path:
|
|
"""Create a fake factory preset directory structure."""
|
|
factory = preset_dir / "factory_presets"
|
|
bank0 = factory / "bank_0"
|
|
bank0.mkdir(parents=True)
|
|
(bank0 / "bank.json").write_text(
|
|
json.dumps({"number": 0, "name": "Factory Classics", "preset_count": 4}), encoding="utf-8"
|
|
)
|
|
for i in range(3):
|
|
p = Preset(name=f"Factory {i}", bank=0, program=i, master_volume=0.75)
|
|
(bank0 / f"preset_{i}.json").write_text(
|
|
json.dumps(_preset_to_dict(p), indent=2), encoding="utf-8"
|
|
)
|
|
bank1 = factory / "bank_1"
|
|
bank1.mkdir(parents=True)
|
|
(bank1 / "bank.json").write_text(
|
|
json.dumps({"number": 1, "name": "Modern High-Gain", "preset_count": 4}), encoding="utf-8"
|
|
)
|
|
p = Preset(name="Chug", bank=1, program=0, master_volume=0.7)
|
|
(bank1 / "preset_0.json").write_text(
|
|
json.dumps(_preset_to_dict(p), indent=2), encoding="utf-8"
|
|
)
|
|
return factory
|
|
|
|
|
|
# ── Serialisation round-trip ────────────────────────────────────────────────
|
|
|
|
|
|
class TestSerialization:
|
|
def test_preset_to_dict_round_trip(self, sample_preset: Preset):
|
|
d = _preset_to_dict(sample_preset)
|
|
restored = _preset_from_dict(d)
|
|
assert restored.name == sample_preset.name
|
|
assert restored.bank == sample_preset.bank
|
|
assert restored.program == sample_preset.program
|
|
assert restored.master_volume == sample_preset.master_volume
|
|
assert len(restored.chain) == len(sample_preset.chain)
|
|
for a, b in zip(restored.chain, sample_preset.chain):
|
|
assert a.fx_type == b.fx_type
|
|
assert a.params == b.params
|
|
assert a.nam_model_path == b.nam_model_path
|
|
assert a.ir_file_path == b.ir_file_path
|
|
assert set(restored.midi_mappings.keys()) == set(sample_preset.midi_mappings.keys())
|
|
for k in sample_preset.midi_mappings:
|
|
assert restored.midi_mappings[k].cc_number == sample_preset.midi_mappings[k].cc_number
|
|
|
|
def test_to_dict_is_human_readable(self, sample_preset: Preset):
|
|
d = _preset_to_dict(sample_preset)
|
|
serialized = json.dumps(d, indent=2, sort_keys=True)
|
|
# Verify key strings are present plain-text, not encoded
|
|
assert "Crunch Rhythm" in serialized
|
|
assert "marshall.nam" in serialized
|
|
assert "mesa_v30" in serialized
|
|
assert "noise_gate" in serialized
|
|
assert "overdrive" in serialized
|
|
|
|
def test_minimal_preset_round_trip(self):
|
|
p = Preset(name="Empty", bank=0, program=0)
|
|
d = _preset_to_dict(p)
|
|
restored = _preset_from_dict(d)
|
|
assert restored.name == "Empty"
|
|
assert restored.chain == []
|
|
assert restored.midi_mappings == {}
|
|
assert restored.master_volume == 0.8
|
|
|
|
|
|
# ── Save / Load ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestSaveLoad:
|
|
def test_save_and_load(self, manager: PresetManager, sample_preset: Preset):
|
|
manager.save(sample_preset)
|
|
loaded = manager.load(0, 0)
|
|
assert loaded.name == "Crunch Rhythm"
|
|
assert len(loaded.chain) == 8
|
|
assert loaded.chain[0].fx_type == FXType.NOISE_GATE
|
|
|
|
def test_save_creates_json_file(self, manager: PresetManager, sample_preset: Preset, preset_dir: Path):
|
|
manager.save(sample_preset)
|
|
path = preset_dir / "guitar" / "bank_0" / "preset_0.json"
|
|
assert path.exists()
|
|
content = path.read_text(encoding="utf-8")
|
|
assert "Crunch Rhythm" in content
|
|
# Confirm it's valid JSON
|
|
data = json.loads(content)
|
|
assert data["name"] == "Crunch Rhythm"
|
|
|
|
def test_load_missing_preset_raises(self, manager: PresetManager):
|
|
with pytest.raises(FileNotFoundError):
|
|
manager.load(9, 3)
|
|
|
|
def test_save_multiple_presets(self, manager: PresetManager):
|
|
p0 = Preset(name="A", bank=0, program=0)
|
|
p1 = Preset(name="B", bank=0, program=1)
|
|
p2 = Preset(name="C", bank=0, program=2)
|
|
p3 = Preset(name="D", bank=0, program=3)
|
|
for p in [p0, p1, p2, p3]:
|
|
manager.save(p)
|
|
assert manager.load(0, 0).name == "A"
|
|
assert manager.load(0, 1).name == "B"
|
|
assert manager.load(0, 2).name == "C"
|
|
assert manager.load(0, 3).name == "D"
|
|
|
|
|
|
# ── Bank structure ──────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestBanks:
|
|
def test_list_banks_empty(self, manager: PresetManager):
|
|
assert manager.list_banks() == []
|
|
|
|
def test_list_banks_after_saves(self, manager: PresetManager):
|
|
p0 = Preset(name="A", bank=0, program=0)
|
|
p1 = Preset(name="B", bank=1, program=0)
|
|
manager.save(p0)
|
|
manager.save(p1)
|
|
banks = manager.list_banks()
|
|
assert len(banks) == 2
|
|
assert banks[0].number == 0
|
|
assert banks[1].number == 1
|
|
|
|
def test_bank_has_four_preset_slots(self, manager: PresetManager):
|
|
"""The PRESETS_PER_BANK convention is set to 4."""
|
|
assert PRESETS_PER_BANK == 4
|
|
|
|
def test_program_clamped_to_range(self, manager: PresetManager):
|
|
"""select() clamps program to 0-3."""
|
|
p = manager.select(0, -1)
|
|
assert p.program == 0
|
|
p = manager.select(0, 99)
|
|
assert p.program == 3
|
|
|
|
|
|
# ── Footswitch navigation ───────────────────────────────────────────────────
|
|
|
|
|
|
class TestFootswitchNavigation:
|
|
def test_preset_up_within_bank(self, manager: PresetManager):
|
|
p0 = Preset(name="P0", bank=0, program=0)
|
|
p1 = Preset(name="P1", bank=0, program=1)
|
|
p2 = Preset(name="P2", bank=0, program=2)
|
|
p3 = Preset(name="P3", bank=0, program=3)
|
|
for p in [p0, p1, p2, p3]:
|
|
manager.save(p)
|
|
manager.select(0, 0)
|
|
assert manager.preset_up().name == "P1"
|
|
assert manager.preset_up().name == "P2"
|
|
assert manager.preset_up().name == "P3"
|
|
# Wraps 3 -> 0
|
|
assert manager.preset_up().name == "P0"
|
|
|
|
def test_preset_down_within_bank(self, manager: PresetManager):
|
|
p0 = Preset(name="P0", bank=0, program=0)
|
|
p1 = Preset(name="P1", bank=0, program=1)
|
|
p2 = Preset(name="P2", bank=0, program=2)
|
|
for p in [p0, p1, p2]:
|
|
manager.save(p)
|
|
manager.select(0, 0)
|
|
# Wraps 0 -> 3 (which is empty, so "New Bank 0 #3")
|
|
assert "New" in manager.preset_down().name
|
|
# Down again -> 2
|
|
assert manager.preset_down().name == "P2"
|
|
|
|
def test_preset_up_creates_empty_preset_when_missing(self, manager: PresetManager):
|
|
"""When pressing up into an empty slot, a blank preset is created."""
|
|
p0 = Preset(name="Only One", bank=0, program=0)
|
|
manager.save(p0)
|
|
manager.select(0, 0)
|
|
next_preset = manager.preset_up()
|
|
assert next_preset.name == "New Bank 0 #1"
|
|
assert next_preset.bank == 0
|
|
assert next_preset.program == 1
|
|
|
|
|
|
# ── Bank up/down ────────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestBankUpDown:
|
|
def test_bank_up(self, manager: PresetManager):
|
|
"""Moving from bank 0 to bank 1."""
|
|
manager.save(Preset(name="B0P0", bank=0, program=0))
|
|
manager.save(Preset(name="B0P1", bank=0, program=1))
|
|
manager.save(Preset(name="B1P0", bank=1, program=0))
|
|
manager.select(0, 0)
|
|
bank, preset = manager.bank_up()
|
|
assert bank.number == 1
|
|
assert preset.name == "B1P0"
|
|
|
|
def test_bank_down(self, manager: PresetManager):
|
|
manager.save(Preset(name="B0P0", bank=0, program=0))
|
|
manager.save(Preset(name="B1P0", bank=1, program=0))
|
|
manager.select(1, 0)
|
|
bank, preset = manager.bank_down()
|
|
assert bank.number == 0
|
|
assert preset.name == "B0P0"
|
|
|
|
def test_bank_up_wraps(self, manager: PresetManager):
|
|
"""Wrapping from highest bank back to lowest."""
|
|
manager.save(Preset(name="B0P0", bank=0, program=0))
|
|
manager.save(Preset(name="B1P0", bank=1, program=0))
|
|
manager.select(1, 0)
|
|
bank, preset = manager.bank_up()
|
|
assert bank.number == 0
|
|
|
|
def test_bank_down_wraps(self, manager: PresetManager):
|
|
manager.save(Preset(name="B0P0", bank=0, program=0))
|
|
manager.save(Preset(name="B1P0", bank=1, program=0))
|
|
manager.select(0, 0)
|
|
bank, preset = manager.bank_down()
|
|
assert bank.number == 1
|
|
|
|
|
|
# ── MIDI Program Change ─────────────────────────────────────────────────────
|
|
|
|
|
|
class TestMIDIPC:
|
|
def test_midi_pc_selects_preset(self, manager: PresetManager):
|
|
"""Bank=channel (0), program=preset (2)."""
|
|
p0 = Preset(name="P0", bank=0, program=0)
|
|
p1 = Preset(name="P1", bank=0, program=1)
|
|
p2 = Preset(name="P2", bank=0, program=2)
|
|
for p in [p0, p1, p2]:
|
|
manager.save(p)
|
|
result = manager.midi_pc(channel=0, program=2)
|
|
assert result.name == "P2"
|
|
assert manager.current_bank == 0
|
|
assert manager.current_program == 2
|
|
|
|
def test_midi_pc_different_banks(self, manager: PresetManager):
|
|
"""Channel maps to bank, program maps to preset index."""
|
|
manager.save(Preset(name="B1P0", bank=1, program=0))
|
|
manager.save(Preset(name="B1P3", bank=1, program=3))
|
|
result = manager.midi_pc(channel=1, program=3)
|
|
assert result.name == "B1P3"
|
|
|
|
def test_midi_pc_creates_blank_on_empty_slot(self, manager: PresetManager):
|
|
"""When PC targets an empty slot, a blank preset is created."""
|
|
manager.save(Preset(name="B2P0", bank=2, program=0))
|
|
result = manager.midi_pc(channel=2, program=2)
|
|
assert "New" in result.name
|
|
assert result.program == 2
|
|
|
|
|
|
# ── Auto-restore ────────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestAutoRestore:
|
|
def test_save_state_persists(self, manager: PresetManager, preset_dir: Path):
|
|
manager.save(Preset(name="RestoreMe", bank=0, program=2))
|
|
manager.select(0, 2) # This calls save_state internally
|
|
state_path = preset_dir / "state.json"
|
|
assert state_path.exists()
|
|
data = json.loads(state_path.read_text(encoding="utf-8"))
|
|
assert data["current_bank"] == 0
|
|
assert data["current_program"] == 2
|
|
|
|
def test_restore_state(self, manager: PresetManager, preset_dir: Path):
|
|
manager.save(Preset(name="RestoreMe", bank=0, program=2))
|
|
manager.select(0, 2)
|
|
|
|
# Create a fresh manager and restore
|
|
m2 = PresetManager(preset_dir=str(preset_dir))
|
|
preset = m2.restore_state()
|
|
assert preset is not None
|
|
assert preset.name == "RestoreMe"
|
|
assert m2.current_bank == 0
|
|
assert m2.current_program == 2
|
|
|
|
def test_restore_state_no_file(self, manager: PresetManager):
|
|
"""restore_state returns None when no state file exists."""
|
|
assert manager.restore_state() is None
|
|
|
|
def test_restore_state_missing_preset_fallback(self, preset_dir: Path):
|
|
"""If the saved slot is empty, fall back gracefully."""
|
|
# Write state pointing to a slot that doesn't exist yet
|
|
state_path = preset_dir / "state.json"
|
|
state_path.write_text(
|
|
json.dumps({"current_bank": 5, "current_program": 3}), encoding="utf-8"
|
|
)
|
|
m = PresetManager(preset_dir=str(preset_dir))
|
|
assert m.restore_state() is None
|
|
|
|
def test_preset_change_triggers_state_save(self, manager: PresetManager, preset_dir: Path):
|
|
"""Every preset_up/down/select saves state."""
|
|
manager.save(Preset(name="A", bank=0, program=0))
|
|
manager.save(Preset(name="B", bank=0, program=1))
|
|
manager.select(0, 0)
|
|
manager.preset_up()
|
|
state = json.loads((preset_dir / "state.json").read_text())
|
|
assert state["current_program"] == 1
|
|
|
|
|
|
# ── Preset rename and reorder ───────────────────────────────────────────────
|
|
|
|
|
|
class TestRenameReorder:
|
|
def test_rename(self, manager: PresetManager):
|
|
manager.save(Preset(name="Old", bank=0, program=0))
|
|
renamed = manager.rename(0, 0, "New")
|
|
assert renamed.name == "New"
|
|
assert manager.load(0, 0).name == "New"
|
|
|
|
def test_reorder_same_slot_noop(self, manager: PresetManager):
|
|
"""Reordering to the same program index does nothing."""
|
|
manager.save(Preset(name="A", bank=0, program=0))
|
|
manager.reorder(0, 0, 0) # Should not raise
|
|
assert manager.load(0, 0).name == "A"
|
|
|
|
def test_reorder_swap(self, manager: PresetManager):
|
|
"""Reordering from 0 to 2 swaps if slot 2 is occupied."""
|
|
manager.save(Preset(name="A", bank=0, program=0))
|
|
manager.save(Preset(name="B", bank=0, program=2))
|
|
manager.reorder(0, 0, 2)
|
|
assert manager.load(0, 0).name == "B"
|
|
assert manager.load(0, 2).name == "A"
|
|
|
|
def test_reorder_into_empty_slot(self, manager: PresetManager):
|
|
manager.save(Preset(name="A", bank=0, program=0))
|
|
manager.reorder(0, 0, 3)
|
|
assert manager.load(0, 3).name == "A"
|
|
|
|
def test_reorder_missing_source_raises(self, manager: PresetManager):
|
|
with pytest.raises(FileNotFoundError):
|
|
manager.reorder(0, 0, 1)
|
|
|
|
def test_reorder_out_of_range_raises(self, manager: PresetManager):
|
|
manager.save(Preset(name="A", bank=0, program=0))
|
|
with pytest.raises(ValueError):
|
|
manager.reorder(0, 0, 4)
|
|
|
|
def test_reorder_updates_active_tracking(self, manager: PresetManager):
|
|
"""If the active preset is moved, current_program tracks it."""
|
|
manager.save(Preset(name="A", bank=0, program=0))
|
|
manager.save(Preset(name="B", bank=1, program=0))
|
|
manager.select(0, 0)
|
|
manager.reorder(0, 0, 2)
|
|
assert manager.current_program == 2 # active moved to slot 2
|
|
|
|
|
|
# ── Factory presets ─────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestFactoryPresets:
|
|
def test_install_factory_presets_overwrite_default(self, manager: PresetManager, factory_preset_dir: Path, monkeypatch):
|
|
monkeypatch.setattr("src.presets.manager.FACTORY_PRESET_DIR", factory_preset_dir)
|
|
count = manager.install_factory_presets()
|
|
assert count == 4 # 3 in bank 0 + 1 in bank 1
|
|
assert manager.load(0, 0).name == "Factory 0"
|
|
assert manager.load(0, 1).name == "Factory 1"
|
|
assert manager.load(1, 0).name == "Chug"
|
|
|
|
def test_install_skip_existing(self, manager: PresetManager, factory_preset_dir: Path, monkeypatch):
|
|
monkeypatch.setattr("src.presets.manager.FACTORY_PRESET_DIR", factory_preset_dir)
|
|
manager.save(Preset(name="My Custom", bank=0, program=0))
|
|
count = manager.install_factory_presets(overwrite=False)
|
|
# Should install 3 (skip slot 0 in bank 0, install 1 & 2, plus bank 1)
|
|
assert count == 3
|
|
assert manager.load(0, 0).name == "My Custom" # Not overwritten
|
|
|
|
def test_install_overwrite_existing(self, manager: PresetManager, factory_preset_dir: Path, monkeypatch):
|
|
monkeypatch.setattr("src.presets.manager.FACTORY_PRESET_DIR", factory_preset_dir)
|
|
manager.save(Preset(name="My Custom", bank=0, program=0))
|
|
count = manager.install_factory_presets(overwrite=True)
|
|
assert count == 4
|
|
assert manager.load(0, 0).name == "Factory 0" # Overwritten
|
|
|
|
def test_no_factory_dir(self, manager: PresetManager, monkeypatch):
|
|
"""When factory dir doesn't exist, returns 0."""
|
|
monkeypatch.setattr("src.presets.manager.FACTORY_PRESET_DIR", Path("/nonexistent_factory"))
|
|
assert manager.install_factory_presets() == 0
|
|
|
|
|
|
# ── Activation pipeline integration ─────────────────────────────────────────
|
|
|
|
|
|
class TestActivation:
|
|
def test_activate_calls_pipeline(self, manager: PresetManager, sample_preset: Preset, mocker):
|
|
pipeline = mocker.Mock()
|
|
manager._pipeline = pipeline
|
|
manager.activate(sample_preset)
|
|
pipeline.load_preset.assert_called_once_with(sample_preset)
|
|
|
|
def test_select_calls_activate(self, manager: PresetManager, mocker):
|
|
pipeline = mocker.Mock()
|
|
manager._pipeline = pipeline
|
|
manager.save(Preset(name="ActivateMe", bank=0, program=0))
|
|
preset = manager.select(0, 0)
|
|
assert preset.name == "ActivateMe"
|
|
pipeline.load_preset.assert_called_once()
|
|
|
|
def test_select_no_pipeline_does_not_crash(self, manager: PresetManager):
|
|
"""Works without an audio pipeline attached."""
|
|
manager.save(Preset(name="Headless", bank=0, program=0))
|
|
preset = manager.select(0, 0)
|
|
assert preset.name == "Headless"
|
|
|
|
|
|
# ── Edge cases ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestEdgeCases:
|
|
def test_delete_preset(self, manager: PresetManager):
|
|
manager.save(Preset(name="DeleteMe", bank=0, program=1))
|
|
assert manager.load(0, 1).name == "DeleteMe"
|
|
manager.delete(0, 1)
|
|
with pytest.raises(FileNotFoundError):
|
|
manager.load(0, 1)
|
|
|
|
def test_get_or_create_bank(self, manager: PresetManager):
|
|
bank = manager.get_or_create_bank(7, "Lead")
|
|
assert bank.number == 7
|
|
assert bank.name == "Lead"
|
|
# Calling again returns existing bank
|
|
bank2 = manager.get_or_create_bank(7)
|
|
assert bank2.number == 7
|
|
assert bank2.name == "Lead" # Name persisted
|
|
|
|
def test_multiple_banks_preset_independence(self, manager: PresetManager):
|
|
"""Presets in different banks don't interfere."""
|
|
manager.save(Preset(name="Bank0", bank=0, program=0))
|
|
manager.save(Preset(name="Bank1", bank=1, program=0))
|
|
assert manager.load(0, 0).name == "Bank0"
|
|
assert manager.load(1, 0).name == "Bank1"
|
|
|
|
def test_full_bank_capacity(self, manager: PresetManager):
|
|
"""All 4 slots in a bank can hold distinct presets."""
|
|
for i in range(4):
|
|
manager.save(Preset(name=f"Slot{i}", bank=2, program=i))
|
|
for i in range(4):
|
|
assert manager.load(2, i).name == f"Slot{i}"
|
|
|
|
def test_current_bank_program_tracking(self, manager: PresetManager):
|
|
manager.save(Preset(name="A", bank=0, program=0))
|
|
manager.save(Preset(name="B", bank=0, program=1))
|
|
assert manager.current_bank == 0
|
|
assert manager.current_program == 0
|
|
manager.select(0, 1)
|
|
assert manager.current_program == 1
|
|
|
|
def test_corrupted_json_raises(self, manager: PresetManager, preset_dir: Path):
|
|
path = preset_dir / "guitar" / "bank_0" / "preset_0.json"
|
|
path.parent.mkdir(parents=True)
|
|
path.write_text("not valid json", encoding="utf-8")
|
|
with pytest.raises(json.JSONDecodeError):
|
|
manager.load(0, 0)
|
|
|
|
|
|
# ── Channel independence ────────────────────────────────────────────────
|
|
|
|
|
|
class TestChannelIndependence:
|
|
"""Guitar and bass channels each have their own preset bank namespace."""
|
|
|
|
def test_channel_serialization_round_trip(self):
|
|
"""Channel field survives to_dict / from_dict."""
|
|
p = Preset(name="GTR Lead", channel=Channel.GUITAR, bank=0, program=0)
|
|
d = _preset_to_dict(p)
|
|
assert d["channel"] == "guitar"
|
|
restored = _preset_from_dict(d)
|
|
assert restored.channel == Channel.GUITAR
|
|
|
|
p2 = Preset(name="Bass Slap", channel=Channel.BASS, bank=0, program=0)
|
|
d2 = _preset_to_dict(p2)
|
|
assert d2["channel"] == "bass"
|
|
restored2 = _preset_from_dict(d2)
|
|
assert restored2.channel == Channel.BASS
|
|
|
|
def test_channel_defaults_to_guitar(self):
|
|
"""Presets without explicit channel default to guitar."""
|
|
p = Preset(name="Default", bank=0, program=0)
|
|
assert p.channel == Channel.GUITAR
|
|
|
|
def test_guitar_and_bass_independent_banks(self, manager: PresetManager):
|
|
"""Guitar and bass presets in the same (bank, program) don't collide."""
|
|
gtr = Preset(name="GTR Crunch", channel=Channel.GUITAR, bank=0, program=0)
|
|
bass = Preset(name="Bass Slap", channel=Channel.BASS, bank=0, program=0)
|
|
|
|
# Save guitar via current channel (defaults to guitar)
|
|
manager.save(gtr)
|
|
# Save bass explicitly
|
|
manager.save(bass, channel=Channel.BASS)
|
|
|
|
# Load guitar
|
|
loaded_gtr = manager.load(0, 0, Channel.GUITAR)
|
|
assert loaded_gtr.name == "GTR Crunch"
|
|
assert loaded_gtr.channel == Channel.GUITAR
|
|
|
|
# Load bass
|
|
loaded_bass = manager.load(0, 0, Channel.BASS)
|
|
assert loaded_bass.name == "Bass Slap"
|
|
assert loaded_bass.channel == Channel.BASS
|
|
|
|
def test_channel_switch_restores_preset(self, manager: PresetManager):
|
|
"""Switching channels restores the last active state for that channel."""
|
|
manager.save(Preset(name="GTR Lead", channel=Channel.GUITAR, bank=0, program=0))
|
|
manager.save(Preset(name="GTR Clean", channel=Channel.GUITAR, bank=0, program=1))
|
|
manager.save(Preset(name="Bass Slap", channel=Channel.BASS, bank=1, program=2))
|
|
|
|
# Select guitar preset (bank=0, program=1)
|
|
manager.select(0, 1, Channel.GUITAR)
|
|
|
|
# Select bass preset so its channel state is saved
|
|
manager.select(1, 2, Channel.BASS)
|
|
|
|
# Switch to guitar — should go to (0, 1)
|
|
manager.set_channel(Channel.GUITAR)
|
|
assert manager.current_channel == Channel.GUITAR
|
|
assert manager.current_bank == 0
|
|
assert manager.current_program == 1
|
|
|
|
# Switch back to bass — should restore (1, 2)
|
|
manager.set_channel(Channel.BASS)
|
|
assert manager.current_channel == Channel.BASS
|
|
assert manager.current_bank == 1
|
|
assert manager.current_program == 2
|
|
|
|
def test_set_same_channel_noop(self, manager: PresetManager):
|
|
"""set_channel to the same channel preserves current position."""
|
|
manager.save(Preset(name="Only", channel=Channel.GUITAR, bank=0, program=2))
|
|
manager.select(0, 2, Channel.GUITAR)
|
|
result = manager.set_channel(Channel.GUITAR)
|
|
assert result.name == "Only"
|
|
assert manager.current_bank == 0
|
|
assert manager.current_program == 2
|
|
|
|
def test_current_channel_property(self, manager: PresetManager):
|
|
"""current_channel returns the active channel."""
|
|
assert manager.current_channel == Channel.GUITAR
|
|
manager.set_channel(Channel.BASS)
|
|
assert manager.current_channel == Channel.BASS
|
|
manager.set_channel(Channel.GUITAR)
|
|
assert manager.current_channel == Channel.GUITAR
|
|
|
|
def test_list_banks_channel_scoped(self, manager: PresetManager):
|
|
"""list_banks only returns banks for the specified channel."""
|
|
manager.save(Preset(name="GTR_A", channel=Channel.GUITAR, bank=0, program=0))
|
|
manager.save(Preset(name="BASS_A", channel=Channel.BASS, bank=5, program=0))
|
|
|
|
gtr_banks = manager.list_banks(Channel.GUITAR)
|
|
assert len(gtr_banks) == 1
|
|
assert gtr_banks[0].number == 0
|
|
|
|
bass_banks = manager.list_banks(Channel.BASS)
|
|
assert len(bass_banks) == 1
|
|
assert bass_banks[0].number == 5
|
|
|
|
def test_migrate_legacy_presets(self, tmp_path: Path):
|
|
"""Legacy flat bank_* dirs are migrated to guitar/ on first boot."""
|
|
# Create legacy layout: bank_0/preset_0.json at root
|
|
legacy_bank = tmp_path / "bank_0"
|
|
legacy_bank.mkdir()
|
|
(legacy_bank / "preset_0.json").write_text(
|
|
json.dumps({"name": "Legacy Tone", "bank": 0, "program": 0}),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
# Create PresetManager — should migrate
|
|
pm = PresetManager(preset_dir=str(tmp_path), audio_pipeline=None)
|
|
|
|
# Old location should be gone
|
|
assert not (tmp_path / "bank_0").exists()
|
|
|
|
# New location should exist
|
|
guitar_path = tmp_path / "guitar" / "bank_0" / "preset_0.json"
|
|
assert guitar_path.exists()
|
|
|
|
# Preset should be loadable from guitar channel
|
|
preset = pm.load(0, 0, Channel.GUITAR)
|
|
assert preset.name == "Legacy Tone"
|
|
|
|
def test_migration_skips_when_already_migrated(self, tmp_path: Path):
|
|
"""Migration doesn't run if guitar/ dir already exists."""
|
|
guitar_dir = tmp_path / "guitar"
|
|
guitar_dir.mkdir(parents=True)
|
|
(guitar_dir / "bank_0").mkdir()
|
|
(guitar_dir / "bank_0" / "preset_0.json").write_text(
|
|
json.dumps({"name": "Already Migrated", "channel": "guitar", "bank": 0, "program": 0}),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
pm = PresetManager(preset_dir=str(tmp_path), audio_pipeline=None)
|
|
preset = pm.load(0, 0, Channel.GUITAR)
|
|
assert preset.name == "Already Migrated"
|
|
|
|
def test_fresh_install_no_migration(self, tmp_path: Path):
|
|
"""Fresh install with no files doesn't raise or create guitar dir."""
|
|
pm = PresetManager(preset_dir=str(tmp_path), audio_pipeline=None)
|
|
assert not (tmp_path / "guitar").exists()
|
|
assert pm.list_banks() == [] |