diff --git a/tools/chatgui-android/call-service-tests/src/test/kotlin/com/utun/chat/data/CallServiceLifecycleTest.kt b/tools/chatgui-android/call-service-tests/src/test/kotlin/com/utun/chat/data/CallServiceLifecycleTest.kt index 0460efe49..9c6d05733 100644 --- a/tools/chatgui-android/call-service-tests/src/test/kotlin/com/utun/chat/data/CallServiceLifecycleTest.kt +++ b/tools/chatgui-android/call-service-tests/src/test/kotlin/com/utun/chat/data/CallServiceLifecycleTest.kt @@ -230,4 +230,109 @@ class CallServiceLifecycleTest { assertTrue(error.get() is IllegalStateException); assertEquals(0, service.foregroundAttempts) diagnostic("requires main thread") } + + @Test fun radioRestorationAndOtherMediaWaitForConfirmedAudioCleanup() { + val radio = RadioAudioEngine.instance(); radio.activeGroup.value = 7 + val service = active(); service.audio.failures = setOf("stop") + CallController.end(); dispatcher.scheduler.runCurrent() + assertEquals(CallPhase.ENDED, state?.phase); assertTrue(CallController.blocksMedia()) + val completion = requireNotNull(CallService.pendingRelease) + assertFalse(completion.isCompleted); assertTrue(radio.restored.isEmpty()); assertTrue(manager.active.containsKey(1002)) + assertTrue(runCatching { CallService.requireReleased() }.isFailure) + service.audio.failures = emptySet(); main.advanceBy(1000); dispatcher.scheduler.runCurrent() + assertTrue(completion.isCompleted); assertFalse(CallService.cleanupPending()) + await("radio restored after cleanup") { radio.restored == listOf(7L) } + assertFalse(CallController.blocksMedia()) + } + + @Test fun unexpectedDestroyStillRetainsFailedCleanupAndEndsTheControllerSession() { + val service = active(); service.audio.failures = setOf("stop") + service.onDestroy() + assertEquals(1, service.destroys); assertEquals(CallPhase.ENDED, state?.phase) + assertTrue(CallService.cleanupPending()); assertTrue(CallController.blocksMedia()) + service.audio.failures = emptySet(); main.advanceBy(1000) + assertFalse(service.audio.active); assertFalse(CallService.cleanupPending()) + } + + @Test fun rejectedRetryQueueRetainsAnExplicitCleanupPathAndBlocksCoreShutdown() { + val service = active(); service.audio.failures = setOf("stop"); Handler.rejectDelayed = true + val completion = service.shutdown() + assertFalse(completion.isCompleted); diagnostic("cleanup retry rejected") + assertTrue(runCatching { CallService.requireReleased() }.isFailure) + service.audio.failures = emptySet(); CallService.requireReleased() + assertTrue(completion.isCompleted); assertFalse(CallService.cleanupPending()) + } + + @Test fun aCancelledCompletionCannotBeMistakenForSuccessfulResourceCleanup() { + val service = active(); service.audio.failures = setOf("stop") + val completion = service.shutdown(); completion.cancel() + assertTrue(completion.isCompleted) + assertTrue(runCatching { CallService.requireReleased() }.isFailure) + assertTrue(CallService.cleanupPending()); assertTrue(service.audio.active) + } + + @Test fun foregroundAndStopSelfFailuresDoNotRepeatReleasedMedia() { + for (foreground in listOf(true, false)) { + val service = active() + service.failForegroundStop = foreground; service.failStopSelf = !foreground + val completion = service.shutdown(); assertFalse(completion.isCompleted) + assertFalse(service.audio.active); assertFalse(power.locks.last().held); assertTrue(service.receivers.isEmpty()) + assertEquals(1, service.audio.stops) + service.failForegroundStop = false; service.failStopSelf = false; service.shutdown() + assertTrue(completion.isCompleted); assertEquals(1, service.audio.stops) + CallController.end(); service.onDestroy() + } + } + + @Test fun actionCancellationFailuresRetainTheTokensUntilAcknowledged() { + val service = active(); val token = requireNotNull(manager.active.getValue(1002).hangup) + token.failCancel = true + val completion = service.shutdown() + assertFalse(completion.isCompleted); assertSame(token, field(service, "endIntent")); assertTrue(CallService.cleanupPending()) + token.failCancel = false; main.advanceBy(1000) + assertTrue(completion.isCompleted); assertTrue(token.cancelled); assertNull(field(service, "endIntent")) + } + + @Test fun failedReleaseAfterItsEffectConfirmsAbsenceOnTheNextAttempt() { + val service = active(); val lock = power.locks.single() + lock.failures = setOf("release_after"); service.receiverFailures = setOf("unregister after") + val completion = service.shutdown() + assertFalse(lock.held); assertTrue(service.receivers.isEmpty()); assertFalse(completion.isCompleted) + lock.failures = emptySet(); service.receiverFailures = emptySet(); main.advanceBy(1000) + assertTrue(completion.isCompleted); assertEquals(1, lock.releaseCalls) + assertNull(field(service, "wakeLock")); assertEquals(false, field(service, "powerEventsRegistered")) + } + + @Test fun staleAudioFailureCannotEndANewCallOnAReusedServiceInstance() { + val service = active(); val oldId = requireNotNull(state).callId; val callback = requireNotNull(service.audio.onFailure) + CallController.end(); val nextId = request(); start(service, nextId) + await("new signaling") { NativeLib.callCommands.any { it == "start" to nextId } } + callback(oldId, "old worker failed") + assertEquals(nextId, state?.callId); assertTrue(state?.ongoing == true) + assertTrue(power.locks.last().held) + } + + @Test fun notificationUpdatesReuseOwnedTokensAndKeepTheConversationClock() { + val service = active(); val original = manager.active.getValue(1002) + assertTrue(original.chronometer); assertTrue(original.showWhen); assertEquals("Alice", original.title) + assertEquals("node:99", original.person?.key); assertEquals(2, PendingIntent.created.size) + AppLanguage.language = "ru"; service.refreshLanguage() + val updated = manager.active.getValue(1002) + assertSame(original.hangup, updated.hangup); assertSame(original.contentIntent, updated.contentIntent) + assertTrue(kotlin.math.abs(original.whenMs - updated.whenMs) < 50) + assertEquals(2, PendingIntent.created.size) + } + + @Test fun powerDiagnosticFailuresAreLoggedAndDoNotLoseTheOwnedLock() { + val service = active(); val receiver = service.receivers.keys.single(); val lock = power.locks.single() + lock.failures = setOf("query") + receiver.onReceive(service, Intent(Intent.ACTION_SCREEN_OFF)) + assertSame(lock, field(service, "wakeLock")); assertTrue(lock.held); diagnostic("power diagnostics failed") + } + + @Test fun aMissingPowerServiceRollsBackForegroundAndFinishesStartup() { + val service = create(); service.powerManager = null; start(service) + assertEquals(CallPhase.ENDED, state?.phase); assertTrue(service.stops > 0) + assertFalse(manager.active.containsKey(1002)); diagnostic("start failed operation=wake lock") + } }