Zum Inhalt

apple/PhysarumApp/Tests/AudioFactorsTests.swift

import XCTest
@testable import Physarum
import PhysarumEngine

final class AudioFactorsTests: XCTestCase {
    private func reactive(enabled: Bool = true, gain: Float = 1, autoGain: Bool = false,
                          _ routes: [AudioRoute] = []) -> AudioReactive {
        AudioReactive(enabled: enabled, gain: gain, autoGain: autoGain, routes: routes)
    }

    func testDisabledYieldsAllNeutral() {
        var ag = AutoGainState()
        let f = evaluateAudioFactors(bands: AudioBands(bass: 1, mid: 1, high: 1),
                                     reactive: reactive(enabled: false), autoGain: &ag)
        for t in ModTarget.allCases { XCTAssertEqual(f[t], 1.0, accuracy: 1e-6) }
    }

    func testSingleRouteAdditiveOverOne() {
        var ag = AutoGainState()
        let f = evaluateAudioFactors(
            bands: AudioBands(bass: 0.5, mid: 0, high: 0),
            reactive: reactive(gain: 1, [AudioRoute(source: .bass, target: .moveSpeed, depth: 2)]),
            autoGain: &ag)
        XCTAssertEqual(f[.moveSpeed], 2.0, accuracy: 1e-5)
        XCTAssertEqual(f[.turnSpeed], 1.0, accuracy: 1e-6)
    }

    func testMultipleRoutesToSameTargetAccumulate() {
        var ag = AutoGainState()
        let f = evaluateAudioFactors(
            bands: AudioBands(bass: 0.5, mid: 0.5, high: 0),
            reactive: reactive([AudioRoute(source: .bass, target: .exposure, depth: 1),
                                AudioRoute(source: .mid, target: .exposure, depth: 1)]),
            autoGain: &ag)
        XCTAssertEqual(f[.exposure], 1.0 + 0.5 + 0.5, accuracy: 1e-5)
    }

    func testNegativeDepthSubtracts() {
        var ag = AutoGainState()
        let f = evaluateAudioFactors(
            bands: AudioBands(bass: 0.5, mid: 0, high: 0),
            reactive: reactive([AudioRoute(source: .bass, target: .contrast, depth: -1)]),
            autoGain: &ag)
        XCTAssertEqual(f[.contrast], 0.5, accuracy: 1e-5)
    }

    func testLoudnessResponseGatesSilence() {
        var ag = AutoGainState()
        let r = reactive(gain: 1, autoGain: true,
                         [AudioRoute(source: .bass, target: .moveSpeed, depth: 1)])
        var b = AudioBands(bass: 1, mid: 0, high: 0); b.loudness = 0.0   // silent overall
        for _ in 0..<300 { _ = evaluateAudioFactors(bands: b, reactive: r, autoGain: &ag) }
        let f = evaluateAudioFactors(bands: b, reactive: r, autoGain: &ag)
        XCTAssertEqual(f[.moveSpeed], 1.0, accuracy: 0.05)   // gated → neutral despite bass=1
    }

    func testLoudnessResponseFullWhenLoud() {
        var ag = AutoGainState()
        let r = reactive(gain: 1, autoGain: true,
                         [AudioRoute(source: .bass, target: .moveSpeed, depth: 1)])
        var b = AudioBands(bass: 1, mid: 0, high: 0); b.loudness = 0.5   // clearly audible
        for _ in 0..<300 { _ = evaluateAudioFactors(bands: b, reactive: r, autoGain: &ag) }
        let f = evaluateAudioFactors(bands: b, reactive: r, autoGain: &ag)
        XCTAssertGreaterThan(f[.moveSpeed], 1.8)             // ~1 + 1*1*1
    }

    func testAutoGainOffIgnoresLoudness() {
        var ag = AutoGainState()
        let r = reactive(gain: 1, autoGain: false,
                         [AudioRoute(source: .bass, target: .moveSpeed, depth: 1)])
        var b = AudioBands(bass: 1, mid: 0, high: 0); b.loudness = 0.0
        let f = evaluateAudioFactors(bands: b, reactive: r, autoGain: &ag)
        XCTAssertGreaterThan(f[.moveSpeed], 1.8)             // no gating → full
    }

    func testAgentAndPostAdapters() {
        var f = AudioFactors()
        f[.moveSpeed] = 1.5; f[.bloomIntensity] = 2.0
        XCTAssertEqual(f.agentUniform.moveSpeedFactor, 1.5, accuracy: 1e-6)
        XCTAssertEqual(f.agentUniform.turnSpeedFactor, 1.0, accuracy: 1e-6)
        XCTAssertEqual(f.postBloomIntensity, 2.0, accuracy: 1e-6)
        XCTAssertEqual(f.postExposure, 1.0, accuracy: 1e-6)
    }

    func testLoudnessEnvAdvancesEvenWhenDisabled() {
        var ag = AutoGainState()   // loudnessEnv starts 0
        var b = AudioBands(bass: 1, mid: 1, high: 1); b.loudness = 1
        _ = evaluateAudioFactors(bands: b, reactive: reactive(enabled: false), autoGain: &ag)
        // The loudness envelope advances whenever hasAudio is true, regardless of
        // `enabled` (only the route loop is gated on enabled).
        // Uses AudioTuning().envCoef — canary: fires if the shipped default changes without updating this test.
        XCTAssertEqual(ag.loudnessEnv, AudioTuning().envCoef * 1.0, accuracy: 1e-6)
    }

    func testHasAudioFalseSkipsEnvAndRoutes() {
        var ag = AutoGainState()
        var b = AudioBands(bass: 1, mid: 1, high: 1); b.loudness = 1
        let f = evaluateAudioFactors(
            bands: b,
            reactive: reactive(gain: 1, [AudioRoute(source: .bass, target: .moveSpeed, depth: 2)]),
            autoGain: &ag, hasAudio: false)
        XCTAssertEqual(ag.loudnessEnv, 0, accuracy: 1e-9)          // env NOT advanced
        XCTAssertEqual(f[.moveSpeed], 1.0, accuracy: 1e-6)          // route loop suppressed
    }

    func testTransientModeUsesTransientSignal() {
        var ag = AutoGainState()
        var bands = AudioBands(bass: 0.9, mid: 0, high: 0)
        bands.transientBass = 0.1
        let r = AudioReactive(enabled: true, gain: 1, autoGain: false,
                              routes: [AudioRoute(source: .bass, target: .moveSpeed, depth: 2, mode: .transient)])
        let f = evaluateAudioFactors(bands: bands, reactive: r, autoGain: &ag)
        XCTAssertEqual(f[.moveSpeed], 1.0 + 0.1 * 2, accuracy: 1e-5)
    }

    func testLevelModeUnchanged() {
        var ag = AutoGainState()
        var bands = AudioBands(bass: 0.5, mid: 0, high: 0)
        bands.transientBass = 0.9
        let r = AudioReactive(enabled: true, gain: 1, autoGain: false,
                              routes: [AudioRoute(source: .bass, target: .moveSpeed, depth: 2, mode: .level)])
        let f = evaluateAudioFactors(bands: bands, reactive: r, autoGain: &ag)
        XCTAssertEqual(f[.moveSpeed], 1.0 + 0.5 * 2, accuracy: 1e-5)
    }

    // MARK: - AudioTuning (device-tuning rig)

    /// The AudioTuning defaults ARE the shipped loudness-response constants — the contract
    /// that guarantees zero behaviour drift when the rig is untouched (release, all tests).
    func testTuningDefaultsMatchShippedConstants() {
        let t = AudioTuning()
        XCTAssertEqual(t.envCoef, 0.15, accuracy: 1e-9)
        XCTAssertEqual(t.gateLo, 0.04, accuracy: 1e-9)
        XCTAssertEqual(t.gateHi, 0.22, accuracy: 1e-9)
    }

    /// Calling with the defaulted `tuning` param must be identical to passing AudioTuning().
    func testDefaultTuningReproducesPreRigCurve() {
        let r = reactive(gain: 1, autoGain: true,
                         [AudioRoute(source: .bass, target: .moveSpeed, depth: 1)])
        var b = AudioBands(bass: 1, mid: 0, high: 0); b.loudness = 0.3
        var agImplicit = AutoGainState()
        var agExplicit = AutoGainState()
        for _ in 0..<300 {
            _ = evaluateAudioFactors(bands: b, reactive: r, autoGain: &agImplicit)
            _ = evaluateAudioFactors(bands: b, reactive: r, autoGain: &agExplicit, tuning: AudioTuning())
        }
        let fImplicit = evaluateAudioFactors(bands: b, reactive: r, autoGain: &agImplicit)
        let fExplicit = evaluateAudioFactors(bands: b, reactive: r, autoGain: &agExplicit, tuning: AudioTuning())
        XCTAssertEqual(fImplicit[.moveSpeed], fExplicit[.moveSpeed], accuracy: 1e-9)
        XCTAssertGreaterThan(fImplicit[.moveSpeed], 1.5)   // discriminating: fails if both paths degenerate to neutral
    }

    /// An injected tuning actually moves the live response curve: a lower `gateHi`
    /// reaches "full" at a quieter loudness, so the same input yields a stronger factor.
    func testInjectedTuningMovesResponseCurve() {
        let r = reactive(gain: 1, autoGain: true,
                         [AudioRoute(source: .bass, target: .moveSpeed, depth: 1, mode: .level)])
        var b = AudioBands(bass: 1, mid: 0, high: 0); b.loudness = 0.15

        func warmedResponse(_ tuning: AudioTuning) -> Float {
            var ag = AutoGainState()
            for _ in 0..<400 { _ = evaluateAudioFactors(bands: b, reactive: r, autoGain: &ag, tuning: tuning) }
            return evaluateAudioFactors(bands: b, reactive: r, autoGain: &ag, tuning: tuning)[.moveSpeed]
        }

        let saturated = warmedResponse(AudioTuning(envCoef: 0.15, gateLo: 0, gateHi: 0.1))   // env 0.15 > hi → full
        let gated = warmedResponse(AudioTuning(envCoef: 0.15, gateLo: 0.04, gateHi: 0.6))     // env 0.15 in ramp → partial
        XCTAssertGreaterThan(saturated, gated + 0.3, "lower gateHi must yield a stronger response")
        XCTAssertEqual(saturated, 2.0, accuracy: 0.05)   // fully on: 1 + 1*1*1
    }
}