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