Files
pi-multifx-pedal/tests/test_preset_manager.py
shawn bfe1434f75 feat: separate preset banks per channel (guitar/bass)
- 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
2026-06-12 23:22:47 -04:00

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() == []