package social.grain import androidx.camera.core.SurfaceRequest import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleRegistry import androidx.test.ext.junit.runners.AndroidJUnit4 import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test import org.junit.rules.TemporaryFolder import org.junit.runner.RunWith import social.grain.feature.stories.CameraBackend import social.grain.feature.stories.CameraStatus import social.grain.feature.stories.Lens import social.grain.feature.stories.OpenedCamera import social.grain.feature.stories.StoryCamera import social.grain.feature.stories.labelSummary import social.grain.feature.stories.pruneStaleCaptures import java.io.File /** * The story camera's state machine, driven through a fake backend. * * What these pin is that every path ends in a state the capture stage knows * how to draw, that nothing tries to shoot from the wrong one, and that a * lens switch which fails leaves the previous lens up rather than a dark * viewfinder. */ @RunWith(AndroidJUnit4::class) class StoryCameraTest { @get:Rule val folder = TemporaryFolder() private val owner = object : LifecycleOwner { override val lifecycle = LifecycleRegistry.createUnsafe(this).apply { currentState = Lifecycle.State.RESUMED } } /** A device with the lenses in [lenses]; the front one has no flash, as on most phones. */ private class FakeBackend( private val lenses: Set = setOf(Lens.Back, Lens.Front), private val failCapture: Boolean = false, ) : CameraBackend { val opened = mutableListOf() var closed = 0 var lastFlash: Boolean? = null override suspend fun open( owner: LifecycleOwner, lens: Lens, onSurface: (SurfaceRequest) -> Unit, ): OpenedCamera? { if (lens !in lenses) return null opened += lens return object : OpenedCamera { override val hasFlash = lens == Lens.Back override suspend fun capture(flash: Boolean, into: File) { if (failCapture) error("shutter jammed") lastFlash = flash into.writeBytes(byteArrayOf(1, 2, 3)) } override fun close() { closed += 1 } } } } private fun camera( backend: CameraBackend = FakeBackend(), access: Boolean = true, ) = StoryCamera(backend, requestAccess = { access }, captureDir = folder.root) @Test fun deniedAccessLeavesTheCameraUnauthorized() = runTest { val backend = FakeBackend() val camera = camera(backend, access = false) camera.start(owner) assertEquals(CameraStatus.Unauthorized, camera.status) assertTrue("nothing should be bound without permission", backend.opened.isEmpty()) assertNull("there is nothing to shoot with", camera.capture()) } @Test fun grantedAccessWithoutACameraIsUnavailable() = runTest { val camera = camera(FakeBackend(lenses = emptySet())) camera.start(owner) assertEquals(CameraStatus.Unavailable, camera.status) assertNull(camera.capture()) } @Test fun theCameraStartsOutStartingOnTheBackLensWithFlashOff() { val camera = camera() assertEquals(CameraStatus.Starting, camera.status) assertEquals(Lens.Back, camera.lens) assertFalse(camera.flashOn) assertFalse("flash is only offered once a lens reports having one", camera.hasFlash) assertFalse(camera.isCapturing) assertNull(camera.surfaceRequest) } @Test fun aWorkingBackLensComesUpReadyWithItsFlash() = runTest { val backend = FakeBackend() val camera = camera(backend) camera.start(owner) assertEquals(CameraStatus.Ready, camera.status) assertEquals(listOf(Lens.Back), backend.opened) assertTrue(camera.hasFlash) } @Test fun aShotLandsInTheCaptureDirectory() = runTest { val backend = FakeBackend() val camera = camera(backend) camera.start(owner) val shot = camera.capture() assertNotNull(shot) assertEquals(folder.root, shot!!.parentFile) assertTrue(shot.length() > 0) assertFalse("the shutter is free again afterwards", camera.isCapturing) } @Test fun flashOnlyFiresWhenToggledOnAndTheLensHasOne() = runTest { val backend = FakeBackend() val camera = camera(backend) camera.start(owner) camera.capture() assertEquals(false, backend.lastFlash) camera.toggleFlash() camera.capture() assertEquals(true, backend.lastFlash) // The front lens has no flash, so the toggle being on must not ask for it. camera.flip() camera.capture() assertEquals(false, backend.lastFlash) } @Test fun flippingSwitchesLensesAndTheirFlash() = runTest { val backend = FakeBackend() val camera = camera(backend) camera.start(owner) camera.flip() assertEquals(Lens.Front, camera.lens) assertFalse(camera.hasFlash) assertEquals(listOf(Lens.Back, Lens.Front), backend.opened) assertEquals(CameraStatus.Ready, camera.status) } /** Opening replaces the binding, so a flip that fails has to reopen the old lens. */ @Test fun aFlipToAMissingLensPutsThePreviousOneBack() = runTest { val backend = FakeBackend(lenses = setOf(Lens.Back)) val camera = camera(backend) camera.start(owner) camera.flip() assertEquals(Lens.Back, camera.lens) assertEquals(CameraStatus.Ready, camera.status) assertEquals("back, then back again after the front failed", listOf(Lens.Back, Lens.Back), backend.opened) } @Test fun aShotThatFailsReportsNothingRatherThanCrashing() = runTest { val camera = camera(FakeBackend(failCapture = true)) camera.start(owner) assertNull(camera.capture()) assertFalse(camera.isCapturing) assertEquals("still usable for another try", CameraStatus.Ready, camera.status) } @Test fun stoppingClosesTheLensAndDropsThePreview() = runTest { val backend = FakeBackend() val camera = camera(backend) camera.start(owner) camera.stop() assertEquals(1, backend.closed) assertNull(camera.surfaceRequest) assertNull("nothing to shoot with once stopped", camera.capture()) } /** Recreating the composer on rotation used to wipe the shot it was showing. */ @Test fun pruningKeepsAFreshShotAndDropsAStaleOne() { val now = System.currentTimeMillis() val fresh = folder.newFile("fresh.jpg").apply { setLastModified(now - 5_000) } val stale = folder.newFile("stale.jpg").apply { setLastModified(now - 2 * 60 * 60 * 1000) } pruneStaleCaptures(folder.root, now) assertTrue(fresh.exists()) assertFalse(stale.exists()) } @Test fun theLabelChipNamesWhatIsSetInTheOrderTheyAreOffered() { assertNull(labelSummary(emptySet())) assertEquals("Nudity, Adult", labelSummary(setOf("porn", "nudity"))) } }