diff --git a/tools/chatgui-android/router-tests/src/test/kotlin/com/utun/chat/data/RadioAudioLifecycleTest.kt b/tools/chatgui-android/router-tests/src/test/kotlin/com/utun/chat/data/RadioAudioLifecycleTest.kt new file mode 100644 index 00000000..fbc5c059 --- /dev/null +++ b/tools/chatgui-android/router-tests/src/test/kotlin/com/utun/chat/data/RadioAudioLifecycleTest.kt @@ -0,0 +1,264 @@ +package com.utun.chat.data + +import android.content.Context +import android.content.Intent +import android.media.AudioManager +import android.media.AudioTrack +import android.os.Handler +import android.os.Looper +import com.utun.chat.ChatApplication +import java.util.concurrent.ExecutionException +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test + +/** Настоящие engine/session/workers: отказы запуска, независимая очистка, сохранённые владельцы и stale callbacks. */ +class RadioAudioLifecycleTest { + private lateinit var context: Context + private lateinit var engine: RadioAudioEngine + private lateinit var io: ExecutorService + private val main get() = Looper.getMainLooper() + + @Before fun prepare() { + assertSame(main, Looper.myLooper()) + context = Context(); ChatApplication.instance = context + AudioManager.shared = AudioManager(); CallController.blocked = false + AndroidMicrophoneCapture.reset(); AudioTrack.created.clear(); AudioTrack.nextFailures = emptySet(); AudioTrack.minimumBuffer = 1920 + Handler.rejectDelayed = false; Handler.rejectImmediate = false + NativeLib.radioFailures = emptySet(); NativeLib.radioCommands.clear() + LogManager.entries.clear(); LogManager.console = true + engine = RadioAudioEngine() + io = RadioAudioEngine::class.java.getDeclaredField("io").apply { isAccessible = true }.get(engine) as ExecutorService + } + + @After fun release() { + context.receiverFailures = emptySet(); context.failPowerService = false; context.configProvider.failRadioSettings = false + AudioTrack.created.forEach { it.failures = emptySet() }; AudioTrack.nextFailures = emptySet(); AudioTrack.minimumBuffer = 1920 + NativeLib.radioFailures = emptySet(); Handler.rejectDelayed = false; Handler.rejectImmediate = false + if (!io.isShutdown) { + engine.stop().get(5, TimeUnit.SECONDS) + main.advanceBy(10_000); drain() + io.shutdown(); assertTrue(io.awaitTermination(5, TimeUnit.SECONDS)) + } + val barrier = Executors.newSingleThreadExecutor() + try { barrier.submit { MediaAudioController.awaitIdle() }.get(5, TimeUnit.SECONDS) } + finally { barrier.shutdownNow() } + // Старый код теряет receiver после частичной регистрации; отрицательный контроль не загрязняет следующий тест. + context.receivers.keys.toList().forEach { context.unregisterReceiver(it) } + assertTrue(AudioTrack.created.all { it.released }) + assertTrue(AndroidMicrophoneCapture.opened.all { it.closed }) + } + + private fun drain() { io.submit {}.get(5, TimeUnit.SECONDS) } + private fun start(group: Long = 7, expected: Boolean = true, ack: Boolean = true) { + val results = mutableListOf() + engine.start(group) { assertSame(main, Looper.myLooper()); results.add(it) } + drain(); main.dispatchAll() + assertEquals("one main result for group=$group", listOf(expected), results) + if (expected && ack) { engine.onSettingsApplied(NativeLib.radioRequest, true); drain() } + } + private fun failedStop() { + try { engine.stop().get(5, TimeUnit.SECONDS); fail("cleanup must report its failure") } + catch (e: ExecutionException) { assertTrue(e.cause is Exception) } + } + private fun count(kind: String) = synchronized(NativeLib.radioCommands) { NativeLib.radioCommands.count { it.kind == kind } } + private fun diagnostic(fragment: String) = assertTrue("missing diagnostic: $fragment", + synchronized(LogManager.entries) { LogManager.entries.any { fragment in it.message && it.level in listOf("WARN", "ERROR") } }) + private fun await(condition: () -> Boolean) { + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(3) + while (!condition() && System.nanoTime() < deadline) Thread.sleep(2) + assertTrue("radio state did not settle", condition()) + } + + @Test fun receiverCleanupFailureDoesNotSkipCaptureOutputOrNativeStopAndCanBeRetried() { + start(); assertTrue(engine.talkBegin()); drain(); await { NativeLib.radioTransmitting() } + val track = AudioTrack.created.single() + val before = count("audio stop") + context.receiverFailures = setOf("unregister") + failedStop() + assertTrue(track.released); assertFalse(NativeLib.radioTransmitting()); assertEquals(before + 1, count("audio stop")) + assertEquals(1, context.receivers.size); assertFalse(engine.isActive()); assertNull(engine.activeGroup.value) + context.receiverFailures = emptySet(); engine.stop().get(5, TimeUnit.SECONDS) + assertTrue(context.receivers.isEmpty()); assertEquals(1, track.releaseCalls) + } + + @Test fun unregistrationAfterEffectFailureIsConfirmedAbsentOnRetry() { + start(); val track = AudioTrack.created.single() + context.receiverFailures = setOf("unregister after") + failedStop(); assertTrue(context.receivers.isEmpty()); assertTrue(track.released) + context.receiverFailures = emptySet(); repeat(2) { engine.stop().get(5, TimeUnit.SECONDS) } + assertEquals(2, context.unregistrationAttempts); assertEquals(1, track.releaseCalls) + } + + @Test fun registrationFailuresBeforeAndAfterEffectAlwaysReportFalseAndLeaveNoOwners() { + for (failure in listOf("register", "register after")) { + context.receiverFailures = setOf(failure) + start(expected = false) + assertFalse(engine.isActive()); assertNull(engine.activeGroup.value) + assertTrue(context.receivers.isEmpty()); assertTrue(AudioTrack.created.all { it.released }) + context.receiverFailures = emptySet(); engine.stop().get(5, TimeUnit.SECONDS) + } + } + + @Test fun failedPowerQueryRollsBackTheRegisteredReceiverAndPlayback() { + context.failPowerService = true + start(expected = false) + assertNull(engine.activeGroup.value); assertTrue(context.receivers.isEmpty()) + assertTrue(AudioTrack.created.single().released); diagnostic("audio setup failed") + } + + @Test fun failedTrackReleaseStillStopsNativeAndRetainsOnlyTheFailedTrackForRetry() { + start(); val track = AudioTrack.created.single().also { it.failures = setOf("release") } + val before = count("audio stop") + failedStop() + assertEquals(before + 1, count("audio stop")); assertTrue(context.receivers.isEmpty()) + assertFalse(track.released); assertTrue(track.listeners.isEmpty()) + track.failures = emptySet(); repeat(2) { engine.stop().get(5, TimeUnit.SECONDS) } + assertTrue(track.released); assertEquals(2, track.releaseCalls) + } + + @Test fun releaseAfterEffectFailureDoesNotCallStopOrReadTheReleasedTrackOnRetry() { + start(); val track = AudioTrack.created.single().also { it.failures = setOf("release after") } + failedStop(); assertTrue(track.released) + track.failures = emptySet(); engine.stop().get(5, TimeUnit.SECONDS) + assertEquals(2, track.releaseCalls); assertEquals(0, track.accessAfterRelease) + } + + @Test fun partialSetupAndFailedRollbackStillDeliverFailureAndBlockReplacementUntilCleanupSucceeds() { + AudioTrack.nextFailures = setOf("play", "release") + start(expected = false) + val old = AudioTrack.created.single() + assertFalse(old.released); assertFalse(engine.isActive()) + AudioTrack.nextFailures = emptySet() + start(8, expected = false) + assertEquals(1, AudioTrack.created.size) + old.failures = emptySet(); engine.stop().get(5, TimeUnit.SECONDS) + start(8); assertEquals(2, AudioTrack.created.size) + } + + @Test fun cleanupFailureBeforeSwitchAlwaysCompletesReadinessWithoutStartingTheNewGroup() { + start(); val old = AudioTrack.created.single() + context.receiverFailures = setOf("unregister") + start(8, expected = false) + assertTrue(old.released); assertFalse(engine.isActive()); assertNull(engine.activeGroup.value) + assertEquals(1, count("audio start")) + context.receiverFailures = emptySet(); start(8) + assertEquals(2, count("audio start")) + } + + @Test fun invalidMinimumBufferNeverCreatesAnOutputAndStillCompletesReadiness() { + for (minimum in listOf(-2, 0)) { + AudioTrack.minimumBuffer = minimum; start(expected = false) + assertTrue(AudioTrack.created.isEmpty()); assertNull(engine.activeGroup.value) + diagnostic("minimum buffer") + } + } + + @Test fun nativeStartupFailureClearsThePublishedActiveGroup() { + NativeLib.radioFailures = setOf("start") + start(expected = false) + assertFalse(engine.isActive()); assertNull(engine.activeGroup.value); assertTrue(AudioTrack.created.isEmpty()) + } + + @Test fun settingsReadFailureDoesNotPublishAReadyOrActiveRadio() { + context.configProvider.failRadioSettings = true + start(expected = false) + assertFalse(engine.isActive()); assertNull(engine.activeGroup.value); assertTrue(AudioTrack.created.isEmpty()) + diagnostic("radio settings read failed") + } + + @Test fun nativeStopFailureDoesNotRetainAlreadyReleasedAndroidResources() { + start(); val track = AudioTrack.created.single() + NativeLib.radioFailures = setOf("stop"); failedStop() + assertTrue(track.released); assertTrue(context.receivers.isEmpty()) + NativeLib.radioFailures = emptySet(); engine.stop().get(5, TimeUnit.SECONDS) + assertEquals(1, track.releaseCalls) + } + + @Test fun staleReadinessCannotOverwriteTheNewGroupAndEveryCallbackRunsOnce() { + val results = mutableListOf>() + engine.start(7) { results.add(7L to it) }; drain() + engine.start(8) { results.add(8L to it) }; drain(); main.dispatchAll() + assertEquals(listOf(7L to false, 8L to true), results) + assertEquals(8L, engine.activeGroup.value); assertTrue(engine.isActive()) + } + + @Test fun readinessCallbackExceptionsAreLoggedOnMainAndDoNotLoseResourceOwnership() { + engine.start(7) { throw IllegalStateException("caller callback failed") }; drain() + try { main.dispatchAll(); fail("caller exception must propagate") } catch (e: IllegalStateException) { + assertEquals("caller callback failed", e.message) + } + diagnostic("readiness callback failed") + engine.stop().get(5, TimeUnit.SECONDS); assertTrue(AudioTrack.created.single().released) + } + + @Test fun rejectedReadinessDeliveryReleasesTheNewAudioWithoutRunningCallerOnIo() { + Handler.rejectImmediate = true + val results = mutableListOf() + engine.start(7) { results.add(it) }; drain() + diagnostic("readiness callback rejected") + assertTrue(results.isEmpty()); assertFalse(engine.isActive()); assertNull(engine.activeGroup.value) + assertTrue(AudioTrack.created.single().released); assertTrue(context.receivers.isEmpty()) + } + + @Test fun staleScreenReceiverCannotChangeOrStopTheReplacementGroup() { + start(); val oldReceiver = context.receivers.keys.single() + start(8); engine.setLevelMonitoring(true); drain() + assertEquals(1, count("capture start")) + oldReceiver.onReceive(context, Intent(Intent.ACTION_SCREEN_OFF)); drain() + assertEquals(0, count("capture stop")); assertEquals(8L, engine.activeGroup.value) + } + + @Test fun staleSettingsAcknowledgementCannotEnableCaptureOfANewGroup() { + start(); val old = NativeLib.radioRequest + start(8, ack = false); engine.setLevelMonitoring(true); engine.onSettingsApplied(old, true); drain() + assertEquals(0, count("capture start")) + engine.onSettingsApplied(NativeLib.radioRequest, true); drain() + assertEquals(1, count("capture start")) + } + + @Test fun stopRemovesSettingsTimeoutBeforeItsExecutorCanBeTerminated() { + start(ack = false); engine.stop().get(5, TimeUnit.SECONDS) + io.shutdown(); assertTrue(io.awaitTermination(5, TimeUnit.SECONDS)) + main.advanceBy(10_000) + assertTrue(context.receivers.isEmpty()) + } + + @Test fun rejectedSettingsWatchdogIsLoggedButAnAcknowledgementCanStillEnableCapture() { + Handler.rejectDelayed = true + start(ack = false); diagnostic("settings timeout rejected") + engine.onSettingsApplied(NativeLib.radioRequest, true); engine.setLevelMonitoring(true); drain() + assertEquals(1, count("capture start")) + } + + @Test fun rejectedBackgroundTimerClosesTheAutomaticWindowAndDoesNotOpenTheMicrophone() { + context.configProvider.radioSettings = RadioAutoSettings(enabled = true) + Handler.rejectDelayed = true; start() + diagnostic("automatic timeout rejected") + assertEquals(0, count("capture start")); assertTrue(engine.isActive()) + } + + @Test fun rejectedBackgroundTimerPreservesManualPttInsteadOfDisablingItWithAutomation() { + context.configProvider.radioSettings = RadioAutoSettings(enabled = true) + start(); Handler.rejectDelayed = true + assertTrue(engine.talkBegin()); drain(); await { NativeLib.radioTransmitting() } + diagnostic("automatic timeout rejected") + assertEquals(RadioAutoSettings.OFF, NativeLib.radioAutoMode()); assertTrue(engine.isTalking()) + engine.cancelTalk(); drain(); await { !NativeLib.radioTransmitting() } + } + + @Test fun workerLifecycleCallsAreRejectedBeforeChangingThePublishedGroup() { + start() + for (action in listOf<() -> Unit>({ engine.start(8) }, { engine.stop() })) { + val failure = AtomicReference() + Thread({ try { action() } catch (e: Throwable) { failure.set(e) } }, "radio-lifecycle-worker").apply { start(); join() } + assertTrue("lifecycle must reject worker: ${failure.get()}", failure.get() is IllegalStateException) + } + assertEquals(7L, engine.activeGroup.value); assertTrue(engine.isActive()) + } +}