import XCTest
@testable import Physarum
import PhysarumEngine
final class AudioWiringTests: XCTestCase {
private func preset(_ name: String) -> Preset {
var p = PresetCatalog.fallbackPreset; p.name = name; p.builtIn = false; return p
}
/// Fresh AppState backed by a throwaway temp store, so audio persistence neither
/// pollutes the real Application Support config nor leaks state between tests. The slot store
/// is an isolated in-memory one seeded with two presets (so preset-switch tests are deterministic).
private func tempState() -> AppState {
let dir = URL(fileURLWithPath: NSTemporaryDirectory())
.appendingPathComponent("PhysAudioTest-\(UUID().uuidString)", isDirectory: true)
let factory = PresetSlots.Factory(presets: [preset("A"), preset("B")], version: 1)
return AppState(slotStore: SlotStore(backend: InMemorySlotBackend(), factory: factory),
store: PresetStore(baseDir: dir))
}
func testDefaultWiringEnablesFiveRoutes() {
let s = tempState()
s.applyDefaultAudioWiring()
let ar = s.audioReactive
XCTAssertTrue(ar.enabled); XCTAssertTrue(ar.autoGain)
XCTAssertEqual(ar.gain, 1, accuracy: 1e-6)
let expected: [(AudioBand, ModTarget, Float, RouteMode)] = [
(.bass, .bloomIntensity, 1.2, .transient),
(.bass, .moveSpeed, 0.8, .transient),
(.high, .exposure, 0.6, .transient),
(.mid, .chaosStrength, 0.7, .transient),
(.mid, .saturation, 0.4, .level),
]
XCTAssertEqual(ar.routes.count, expected.count)
for (i, e) in expected.enumerated() {
XCTAssertEqual(ar.routes[i].source, e.0, "route \(i) source")
XCTAssertEqual(ar.routes[i].target, e.1, "route \(i) target")
XCTAssertEqual(ar.routes[i].depth, e.2, accuracy: 1e-6, "route \(i) depth")
XCTAssertEqual(ar.routes[i].mode, e.3, "route \(i) mode")
}
}
func testSetAudioEnabledOnEmptyAppliesDefaults() {
let s = tempState()
s.audioReactive = AudioReactive(enabled: false, gain: 1, autoGain: false, routes: [])
s.setAudioEnabled(true)
XCTAssertTrue(s.audioReactive.enabled)
XCTAssertTrue(s.audioReactive.autoGain)
XCTAssertEqual(s.audioReactive.routes.count, 5)
}
func testSetAudioEnabledKeepsExistingRoutes() {
let s = tempState()
let custom = [AudioRoute(source: .high, target: .contrast, depth: 0.5, mode: .level)]
s.audioReactive = AudioReactive(enabled: false, gain: 2, autoGain: false, routes: custom)
s.setAudioEnabled(true)
XCTAssertTrue(s.audioReactive.enabled)
XCTAssertEqual(s.audioReactive.routes.count, 1)
XCTAssertFalse(s.audioReactive.autoGain)
}
func testSetAudioDisabledKeepsRoutes() {
let s = tempState()
s.applyDefaultAudioWiring()
s.setAudioEnabled(false)
XCTAssertFalse(s.audioReactive.enabled)
XCTAssertEqual(s.audioReactive.routes.count, 5)
}
// Global config: switching presets must NOT touch the audio wiring.
func testSelectPreservesGlobalAudioConfig() {
let s = tempState()
s.applyDefaultAudioWiring() // known global config (5 routes, enabled)
let before = s.audioReactive.routes.count
guard let other = s.presets.first(where: { $0.id != s.selectedID }) else {
return XCTFail("need a second preset to switch to")
}
s.select(id: other.id)
XCTAssertEqual(s.audioReactive.routes.count, before) // unchanged by preset switch
XCTAssertTrue(s.audioReactive.enabled)
}
// Audio config persists across AppState instances sharing a store.
func testAudioConfigPersistsAcrossInstances() {
let dir = URL(fileURLWithPath: NSTemporaryDirectory())
.appendingPathComponent("PhysAudioPersist-\(UUID().uuidString)", isDirectory: true)
let s1 = AppState(store: PresetStore(baseDir: dir))
s1.applyDefaultAudioWiring() // didSet → save
let s2 = AppState(store: PresetStore(baseDir: dir)) // reload
XCTAssertEqual(s2.audioReactive.routes.count, 5)
XCTAssertTrue(s2.audioReactive.enabled)
}
// Audio config is NOT part of the preset-undo history.
func testAudioChangeDoesNotPushUndo() {
let s = tempState()
XCTAssertTrue(s.undoStack.isEmpty)
s.applyDefaultAudioWiring()
s.setAudioEnabled(false)
XCTAssertTrue(s.undoStack.isEmpty, "audio config must not create preset-undo entries")
}
// MARK: - AudioTuning (device-tuning rig)
// A fresh store yields the shipped tuning defaults.
func testTuningDefaultsWhenNoFile() {
let s = tempState()
XCTAssertEqual(s.audioTuning, AudioTuning())
}
// A corrupt _tuning.json must fall back to defaults, not crash or carry garbage.
func testCorruptTuningFileFallsBackToDefaults() {
let dir = URL(fileURLWithPath: NSTemporaryDirectory())
.appendingPathComponent("PhysTuningCorrupt-\(UUID().uuidString)", isDirectory: true)
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
try? Data("not json".utf8).write(to: dir.appendingPathComponent("_tuning.json"))
let s = AppState(store: PresetStore(baseDir: dir))
XCTAssertEqual(s.audioTuning, AudioTuning())
}
// Tuning constants persist across AppState instances sharing a store.
func testTuningPersistsAcrossInstances() {
let dir = URL(fileURLWithPath: NSTemporaryDirectory())
.appendingPathComponent("PhysTuningPersist-\(UUID().uuidString)", isDirectory: true)
let s1 = AppState(store: PresetStore(baseDir: dir))
s1.audioTuning = AudioTuning(envCoef: 0.3, gateLo: 0.08, gateHi: 0.4) // didSet → save
let s2 = AppState(store: PresetStore(baseDir: dir)) // reload
XCTAssertEqual(s2.audioTuning.envCoef, 0.3, accuracy: 1e-6)
XCTAssertEqual(s2.audioTuning.gateLo, 0.08, accuracy: 1e-6)
XCTAssertEqual(s2.audioTuning.gateHi, 0.4, accuracy: 1e-6)
}
// Reset restores the shipped defaults.
func testResetTuningRestoresDefaults() {
let s = tempState()
s.audioTuning = AudioTuning(envCoef: 0.5, gateLo: 0.1, gateHi: 0.5)
s.resetTuning()
XCTAssertEqual(s.audioTuning, AudioTuning())
}
// Tuning is global config — never part of the preset-undo history.
func testTuningChangeDoesNotPushUndo() {
let s = tempState()
XCTAssertTrue(s.undoStack.isEmpty)
s.audioTuning = AudioTuning(envCoef: 0.2, gateLo: 0.05, gateHi: 0.3)
s.resetTuning()
XCTAssertTrue(s.undoStack.isEmpty, "tuning must not create preset-undo entries")
}
// setTuning must be callable without a running engine and must not block — the
// render loop calls it every frame next to setEnabled.
func testSetTuningIsSafeWithoutRunningEngine() {
let input = AudioInput()
var t = AudioTuning()
t.peakTau = 7
input.setTuning(t)
input.setTuning(AudioTuning())
XCTAssertFalse(input.running)
}
}