diff --git a/core/chanora_core/src/ptt.rs b/core/chanora_core/src/ptt.rs index 028ca54..48ababd 100644 --- a/core/chanora_core/src/ptt.rs +++ b/core/chanora_core/src/ptt.rs @@ -373,4 +373,50 @@ mod tests { tokio::time::sleep(Duration::from_millis(15)).await; assert!(!real_gate.load(), "stop must drop transmit cleanly"); } + + /// User-acceptance shape: "press → ptt on, release → ptt off". + /// Exercises the full chain on the same surface + /// `audioStats.pttActive` reads from in production: + /// backend press_gate → edge-watcher → release-tail → selector + /// → real gate. With `release_tail_ms = 0` the transition is + /// synchronous (modulo one tokio tick). + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn press_on_release_off_zero_tail() { + let (real_gate, _sel, tail) = setup(0); + let controller = PttController::new(tail); + tokio::time::sleep(Duration::from_millis(10)).await; + assert!(!real_gate.load(), "idle baseline must be off"); + + controller.press_gate().set(true); + tokio::time::sleep(Duration::from_millis(10)).await; + assert!(real_gate.load(), "press: pttActive must be true"); + + controller.press_gate().set(false); + tokio::time::sleep(Duration::from_millis(15)).await; + assert!(!real_gate.load(), "release: pttActive must be false"); + } + + /// Same acceptance shape but with the default 200 ms tail — + /// verifies that the gate stays "on" briefly past release and + /// then transitions to "off" within a bounded time, matching + /// the SDD-096 spec. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn press_on_release_off_default_tail() { + let (real_gate, _sel, tail) = setup(200); + let controller = PttController::new(tail); + tokio::time::sleep(Duration::from_millis(10)).await; + + controller.press_gate().set(true); + tokio::time::sleep(Duration::from_millis(10)).await; + assert!(real_gate.load(), "press: pttActive must be true"); + + controller.press_gate().set(false); + // Mid-tail: still on. + tokio::time::sleep(Duration::from_millis(50)).await; + assert!(real_gate.load(), "tail window: pttActive must still be true"); + + // Past the tail: off. + tokio::time::sleep(Duration::from_millis(220)).await; + assert!(!real_gate.load(), "post-tail: pttActive must be false"); + } }