~ knotless Search/
↑↓ move↵ open⇧↵ all results

grain.social/grain-android

1 collaborator
0
1
main
228 lines · 7.4 KB Raw History
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<Lens> = setOf(Lens.Back, Lens.Front),
        private val failCapture: Boolean = false,
    ) : CameraBackend {
        val opened = mutableListOf<Lens>()
        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")))
    }
}