Zum Inhalt

apple/PhysarumApp/Tests/AudioWiringTests.swift

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)
    }
}