// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown
// SPDX-License-Identifier: GPL-3.0-or-later

package space.rcmd.android.sizzle.audio

import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test

/**
 * Locks the gate-vs-release distinction the audition note-off relies on. A voice in
 * its release tail must report [SynthVoice.isActive] == true (still sounding) but
 * [SynthVoice.isGated] == false, so a note-off preferring a gated voice never lands
 * on a releasing one — the fix for stuck notes under rapid / chord tapping.
 */
class SynthVoiceGateTest {

    private val sr = 48_000

    @Test fun heldNoteIsGated() {
        val v = SynthVoice(sr)
        v.noteOn(60, 100, SynthPatch.DEFAULT)
        assertTrue("a freshly triggered note is gated", v.isGated)
        assertTrue(v.isActive)
        // Advance well past the attack/decay into sustain — still held.
        repeat(sr / 2) { v.render() }
        assertTrue("a sustaining note is still gated", v.isGated)
        assertTrue(v.isActive)
    }

    @Test fun releasingNoteIsActiveButNotGated() {
        val v = SynthVoice(sr)
        v.noteOn(60, 100, SynthPatch.DEFAULT)
        repeat(sr / 4) { v.render() } // reach sustain
        v.noteOff()
        // One sample into the release: still audible, but no longer gated.
        v.render()
        assertTrue("a releasing voice is still active (audible tail)", v.isActive)
        assertFalse("a releasing voice is no longer gated", v.isGated)
    }

    @Test fun releaseReachesSilence() {
        val v = SynthVoice(sr)
        v.noteOn(60, 100, SynthPatch.DEFAULT)
        repeat(sr / 4) { v.render() }
        v.noteOff()
        // DEFAULT release is 0.2 s; render a full second to be safe.
        repeat(sr) { v.render() }
        assertFalse("the voice frees itself after the release completes", v.isActive)
        assertFalse(v.isGated)
    }

    /** The stuck-note scenario at the voice level: a releasing voice and a held voice
     *  of the same pitch coexist; selecting the gated one for note-off leaves no held
     *  voice behind (whereas picking the first *active* match would strand it). */
    @Test fun gatedPreferenceReleasesTheHeldVoice() {
        val releasing = SynthVoice(sr)
        val held = SynthVoice(sr)
        releasing.noteOn(60, 100, SynthPatch.DEFAULT)
        repeat(sr / 4) { releasing.render() }
        releasing.noteOff()
        releasing.render() // now active-but-not-gated
        held.noteOn(60, 100, SynthPatch.DEFAULT) // same pitch, gated

        val pool = listOf(releasing, held)
        // Mirror auditionSlotOff's selection: prefer a gated voice of this pitch.
        val target = pool.firstOrNull { it.pitch == 60 && it.isGated }
            ?: pool.firstOrNull { it.isActive && it.pitch == 60 }
        target?.noteOff()

        assertFalse("the held voice received the note-off and is releasing", held.isGated)
    }
}
