mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-15 10:46:12 -04:00
fix(reaction): Treat CA status 5 as speech-resume, not a stop
This commit is contained in:
+16
-5
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+99
-3
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user