fix(reaction): Treat CA status 5 as speech-resume, not a stop

This commit is contained in:
darken
2026-06-18 12:07:19 +02:00
committed by Matthias Urhahn
parent 352e020b54
commit 246b96bfb4
7 changed files with 187 additions and 48 deletions
@@ -562,21 +562,32 @@ class AapSessionEngineTest : BaseTest() {
}
@Test
fun `terminal statuses 5, 6, 8, 9 emit STOP`() = runTest(UnconfinedTestDispatcher()) {
// 5 is the terminal wind-down value on fw …6861 (never reaches 6/8/9); 6/8/9 on fw …6503.
firstEventFor(5) shouldBe ConversationAwarenessEvent.STOP
firstEventFor(6) shouldBe ConversationAwarenessEvent.STOP
fun `status 5 emits RESUME`() = runTest(UnconfinedTestDispatcher()) {
// 5 = speech resumed after a pause (bursty talking cycles 3,5,3,5,…); NOT a terminal.
// Labelled captures across Pro 3 + Pro 2 USB-C show 5 only ever inside an active
// conversation, never ending one. Misreading it as STOP was the premature-resume bug.
firstEventFor(5) shouldBe ConversationAwarenessEvent.RESUME
}
@Test
fun `terminal statuses 8 and 9 emit STOP`() = runTest(UnconfinedTestDispatcher()) {
// The conversation terminal is always the 8→9 pair (both pods); also emitted by pod
// removal / case-close. 6 is never observed as a terminal, so it falls through to HOLD.
firstEventFor(8) shouldBe ConversationAwarenessEvent.STOP
firstEventFor(9) shouldBe ConversationAwarenessEvent.STOP
}
@Test
fun `transitional and unknown statuses emit HOLD (stay engaged)`() = runTest(UnconfinedTestDispatcher()) {
// Must never disengage on these — only an explicit terminal STOP does.
// Must never resume immediately on these — they arm the short wind-down fuse instead.
// 3 = pause, 0x0B/4 = wind-down, 7 = abort; 6 and any unknown value default to HOLD.
firstEventFor(3) shouldBe ConversationAwarenessEvent.HOLD
firstEventFor(4) shouldBe ConversationAwarenessEvent.HOLD
firstEventFor(0x0B) shouldBe ConversationAwarenessEvent.HOLD
firstEventFor(7) shouldBe ConversationAwarenessEvent.HOLD
firstEventFor(6) shouldBe ConversationAwarenessEvent.HOLD
firstEventFor(0) shouldBe ConversationAwarenessEvent.HOLD
firstEventFor(0xFF) shouldBe ConversationAwarenessEvent.HOLD
}
}
@@ -338,9 +338,9 @@ class ConversationReactionTest : BaseTest() {
@Test
fun `PAUSE stays paused through frame silence, resumes only on explicit STOP, then re-engages`() =
runTest(UnconfinedTestDispatcher()) {
// Regression for the fw …6861 bug: the pod sends an onset, then NO frames for ~20s while
// the wearer keeps talking, then a terminal STOP. The old 12s stale timeout resumed media
// mid-speech; the backstop must not, and a fresh talk must re-arm.
// The pod sends NO frames during continuous speech (29s silent gaps observed), then a
// terminal STOP. The old 12s stale timeout resumed media mid-speech; the long backstop
// must not, and a fresh talk must re-arm.
devicesFlow.value = listOf(mockPodDevice(primaryAddress, ConversationAction.PAUSE))
val job = launchReaction()
@@ -377,6 +377,102 @@ class ConversationReactionTest : BaseTest() {
job.cancel()
}
@Test
fun `RESUME keeps media paused through a bursty conversation, resumes only at the real terminal`() =
runTest(UnconfinedTestDispatcher()) {
// Regression for the fw …6861 status-5 bug. Bursty talking emits 1,2 then 3,5 (pause,
// resume) pairs while CA stays engaged, ending with the real wind-down 3,0xB,4,8,9.
// Status 5 was misclassified as a terminal STOP, so media resumed on the first burst
// pause and — with no fresh 1/2 onset mid-conversation — never paused again. RESUME must
// keep media paused until the genuine terminal.
devicesFlow.value = listOf(mockPodDevice(primaryAddress, ConversationAction.PAUSE))
val job = launchReaction()
emit(primaryAddress, ConversationAwarenessEvent.START) // 1
emit(primaryAddress, ConversationAwarenessEvent.START) // 2
coVerify(exactly = 1) { mediaControl.sendPause(false) }
emit(primaryAddress, ConversationAwarenessEvent.HOLD) // 3 pause
emit(primaryAddress, ConversationAwarenessEvent.RESUME) // 5 resume
emit(primaryAddress, ConversationAwarenessEvent.HOLD) // 3 pause
emit(primaryAddress, ConversationAwarenessEvent.RESUME) // 5 resume
coVerify(exactly = 0) { mediaControl.sendPlay() } // stayed paused through the bursts
emit(primaryAddress, ConversationAwarenessEvent.HOLD) // 3
emit(primaryAddress, ConversationAwarenessEvent.HOLD) // 0x0B
emit(primaryAddress, ConversationAwarenessEvent.HOLD) // 4
emit(primaryAddress, ConversationAwarenessEvent.STOP) // 8 terminal
coVerify(exactly = 1) { mediaControl.sendPlay() }
job.cancel()
}
@Test
fun `RESUME cancels the wind-down fuse`() = runTest(UnconfinedTestDispatcher()) {
// A pause (3) arms the short fuse; a resume (5) must cancel it and switch back to the long
// backstop — otherwise media resumes ~6s into renewed speech.
devicesFlow.value = listOf(mockPodDevice(primaryAddress, ConversationAction.PAUSE))
val job = launchReaction()
emit(primaryAddress, ConversationAwarenessEvent.START)
emit(primaryAddress, ConversationAwarenessEvent.HOLD) // 3 — wind-down fuse armed
advanceTimeBy(windDownTimeoutMs * 2 / 3)
runCurrent()
emit(primaryAddress, ConversationAwarenessEvent.RESUME) // 5 — speech resumed, cancel fuse
advanceTimeBy(windDownTimeoutMs * 2) // well past the original fuse
runCurrent()
coVerify(exactly = 0) { mediaControl.sendPlay() }
job.cancel()
}
@Test
fun `wind-down after a RESUME still disengages via the fuse (dropped terminal)`() =
runTest(UnconfinedTestDispatcher()) {
// After a resume re-arms the long backstop, a later genuine wind-down (0xB,4 with the
// 8,9 terminal dropped — single-pod) must still disengage via the short fuse. Proves the
// RESUME keep-alive doesn't permanently disable #608 recovery.
devicesFlow.value = listOf(mockPodDevice(primaryAddress, ConversationAction.PAUSE))
val job = launchReaction()
emit(primaryAddress, ConversationAwarenessEvent.START)
emit(primaryAddress, ConversationAwarenessEvent.HOLD) // 3 pause
emit(primaryAddress, ConversationAwarenessEvent.RESUME) // 5 resume → long backstop
emit(primaryAddress, ConversationAwarenessEvent.HOLD) // 3 pause again
emit(primaryAddress, ConversationAwarenessEvent.HOLD) // 0x0B wind-down
emit(primaryAddress, ConversationAwarenessEvent.HOLD) // 4 — terminal dropped
coVerify(exactly = 0) { mediaControl.sendPlay() }
advanceTimeBy(windDownTimeoutMs + 500)
runCurrent()
coVerify(exactly = 1) { mediaControl.sendPlay() }
job.cancel()
}
@Test
fun `RESUME without a prior start is a no-op`() = runTest(UnconfinedTestDispatcher()) {
devicesFlow.value = listOf(mockPodDevice(primaryAddress, ConversationAction.PAUSE))
val job = launchReaction()
emit(primaryAddress, ConversationAwarenessEvent.RESUME) // stray 5, nothing engaged
coVerify(exactly = 0) { mediaControl.sendPause(any()) }
coVerify(exactly = 0) { mediaControl.sendPlay() }
job.cancel()
}
@Test
fun `LOWER_VOLUME RESUME keeps the volume ducked`() = runTest(UnconfinedTestDispatcher()) {
// Same status-5 bug seen on the default action: it restored volume on the first 3→5 pause.
val job = launchReaction() // devicesFlow default = LOWER_VOLUME
emit(primaryAddress, ConversationAwarenessEvent.START)
verify(exactly = 1) { mediaControl.duckMusicVolume(any()) }
emit(primaryAddress, ConversationAwarenessEvent.HOLD) // 3
emit(primaryAddress, ConversationAwarenessEvent.RESUME) // 5
verify(exactly = 0) { mediaControl.restoreMusicVolume(any()) }
job.cancel()
}
@Test
fun `STOP from a non-owner does not disengage the active owner`() = runTest(UnconfinedTestDispatcher()) {
devicesFlow.value = listOf(mockPodDevice(primaryAddress, ConversationAction.PAUSE))