diff --git a/.claude/skills/run-e2e-pixel-sweep/SKILL.md b/.claude/skills/run-e2e-pixel-sweep/SKILL.md index 8c55c94..b31fa51 100644 --- a/.claude/skills/run-e2e-pixel-sweep/SKILL.md +++ b/.claude/skills/run-e2e-pixel-sweep/SKILL.md @@ -194,3 +194,7 @@ device-specific Crashlytics issue is fixed from emulator evidence alone). 14. **Emulator quirks:** all three AVDs report model `sdk_gphone16k_x86_64` (verify identity with `adb emu avd name`, not the model prop); adb sometimes holds a stale `localhost:NNNNN` transport — always pass `-s $serial`. +15. **API 34 Photo Picker + Download/:** `sweep-prep` pushes the fixture to `Download/` on + every device, and **also** to `DCIM/Camera/` when `ro.build.version.sdk` ≤ 34 — otherwise + `Pixel_8_API34` import flakes with an empty picker ("No photos or videos") even though + the file is on disk. Not an app bug; drive-flow still taps `"Video taken on"`. diff --git a/.claude/skills/run-e2e-pixel-sweep/scripts/sweep-prep.ps1 b/.claude/skills/run-e2e-pixel-sweep/scripts/sweep-prep.ps1 index 2668287..d34443c 100644 --- a/.claude/skills/run-e2e-pixel-sweep/scripts/sweep-prep.ps1 +++ b/.claude/skills/run-e2e-pixel-sweep/scripts/sweep-prep.ps1 @@ -67,8 +67,22 @@ adb -s $serial install -r -g $apk | Out-Null # drive-flow's step 0 self-heals by relaunching if the race still fires. adb -s $serial shell pm clear $pkg | Out-Null adb -s $serial shell pm grant $pkg android.permission.CAMERA -adb -s $serial push $video /sdcard/Download/ | Out-Null -adb -s $serial shell am broadcast -a android.intent.action.MEDIA_SCANNER_SCAN_FILE -d "file:///sdcard/Download/$VideoName" | Out-Null + +# Stage the canonical fixture for drive-flow's Gallery → Import → Photo Picker path. +adb -s $serial push $video "/sdcard/Download/$VideoName" | Out-Null +adb -s $serial shell am broadcast -a android.intent.action.MEDIA_SCANNER_SCAN_FILE ` + -d "file:///sdcard/Download/$VideoName" | Out-Null +# Android 14 (API 34) Photo Picker often shows "No photos or videos" for Download/ only — +# drive-flow taps "Video taken on" in the default Photos grid, which indexes DCIM/Camera. +$sdk = [int](((adb -s $serial shell getprop ro.build.version.sdk) -join '').Trim()) +if ($sdk -le 34) { + adb -s $serial shell mkdir -p /sdcard/DCIM/Camera | Out-Null + adb -s $serial push $video "/sdcard/DCIM/Camera/$VideoName" | Out-Null + adb -s $serial shell am broadcast -a android.intent.action.MEDIA_SCANNER_SCAN_FILE ` + -d "file:///sdcard/DCIM/Camera/$VideoName" | Out-Null + Start-Sleep -Seconds 3 +} + # 3-button nav so the trim-handle edge drag can't fire the back gesture. adb -s $serial shell cmd overlay enable com.android.internal.systemui.navbar.threebutton diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7af1961..e51ed9d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,10 +1,14 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.gradle.testing.jacoco.plugins.JacocoTaskExtension +import org.gradle.testing.jacoco.tasks.JacocoCoverageVerification +import org.gradle.testing.jacoco.tasks.JacocoReport import java.util.Properties plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.compose) alias(libs.plugins.baselineprofile) + jacoco } // Release signing is driven by a gitignored keystore.properties at the repo root (never commit @@ -24,8 +28,8 @@ android { applicationId = "io.github.stozo04.openloop" minSdk = 26 targetSdk = 36 - versionCode = 26 - versionName = "1.0.26" + versionCode = 27 + versionName = "1.0.27" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { @@ -125,6 +129,46 @@ kotlin { } } +jacoco { + // 0.8.13 supports the Java 21 test runtime used by Robolectric in this project. + toolVersion = "0.8.13" +} + +val debugCoverageClassExcludes = listOf( + // Android/AGP generated classes. + "**/R.class", + "**/R$*.class", + "**/BuildConfig.*", + "**/Manifest*.*", + "**/*Test*.*", + // Compose compiler and Kotlin synthetic implementation details that do not map cleanly to + // source-level business logic coverage. + "**/*ComposableSingletons*.*", +) + +fun debugUnitCoverageClassDirectories() = + files( + layout.buildDirectory.dir("intermediates/javac/debug/compileDebugJavaWithJavac/classes"), + layout.buildDirectory.dir("intermediates/built_in_kotlinc/debug/compileDebugKotlin/classes"), + ).asFileTree.matching { + exclude(debugCoverageClassExcludes) + } + +fun debugViewModelCoverageClassDirectories() = + debugUnitCoverageClassDirectories().matching { + include("**/ui/OpenLoopViewModel*.class") + exclude( + "**/OpenLoopViewModel\$Companion.class", + "**/OpenLoopViewModel\$Factory.class", + "**/OpenLoopViewModel\$WhenMappings.class", + ) + } + +val debugUnitCoverageSourceDirectories = files( + "src/main/java", + "src/main/kotlin", +) + // App code targets/compiles to Java 17 (above), but the JVM that *runs* the unit tests is pinned to // JDK 21. Robolectric must run on JDK 21 to load the API-36 android-all jar (Android's SDK 36 jars // are compiled with Java 21), and Robolectric defaults to the project's targetSdk (36). Running @@ -137,6 +181,50 @@ tasks.withType().configureEach { languageVersion.set(JavaLanguageVersion.of(21)) }, ) + extensions.configure(JacocoTaskExtension::class) { + isIncludeNoLocationClasses = true + excludes = listOf("jdk.internal.*") + } +} + +tasks.register("jacocoDebugUnitTestReport") { + group = "verification" + description = "Runs debug JVM/Robolectric unit tests and generates JaCoCo coverage reports." + + dependsOn("testDebugUnitTest") + + classDirectories.setFrom(debugUnitCoverageClassDirectories()) + sourceDirectories.setFrom(debugUnitCoverageSourceDirectories) + executionData.setFrom(layout.buildDirectory.file("jacoco/testDebugUnitTest.exec")) + + reports { + xml.required.set(true) + html.required.set(true) + csv.required.set(false) + } +} + +tasks.register("jacocoDebugViewModelCoverageVerification") { + group = "verification" + description = "Fails when debug JVM/Robolectric coverage for the ViewModel state machine drops below 90%." + + dependsOn("jacocoDebugUnitTestReport") + + classDirectories.setFrom(debugViewModelCoverageClassDirectories()) + sourceDirectories.setFrom(debugUnitCoverageSourceDirectories) + executionData.setFrom(layout.buildDirectory.file("jacoco/testDebugUnitTest.exec")) + + violationRules { + rule { + limit { + minimum = "0.90".toBigDecimal() + } + } + } +} + +tasks.named("check") { + dependsOn("jacocoDebugViewModelCoverageVerification") } dependencies { diff --git a/app/src/androidTest/java/io/github/stozo04/openloop/ShareBoomerangTest.kt b/app/src/androidTest/java/io/github/stozo04/openloop/ShareBoomerangTest.kt index 81f764b..14bf000 100644 --- a/app/src/androidTest/java/io/github/stozo04/openloop/ShareBoomerangTest.kt +++ b/app/src/androidTest/java/io/github/stozo04/openloop/ShareBoomerangTest.kt @@ -20,7 +20,7 @@ import java.io.File * * 1. **FileProvider scope** — the provider in AndroidManifest + res/xml/file_paths.xml must expose * `filesDir/videos/`. A persisted clip resolves to a `content://` URI; scratch files under - * `cacheDir/scratch/` must NOT (so in-flight captures can never leak through the share sheet). + * `filesDir/scratch/` must NOT (so in-flight captures can never leak through the share sheet). * 2. **Share intent shape** — [buildBoomerangShareIntent] must produce an ACTION_SEND `video/mp4` * intent carrying the URI as EXTRA_STREAM, the subject, and the temporary read-grant flag. * @@ -48,8 +48,8 @@ class ShareBoomerangTest { @Test fun fileProvider_rejectsScratchCapture_notInConfiguredPaths() { - // In-flight captures live in cacheDir/scratch/ — deliberately NOT listed in file_paths.xml. - val scratch = File(context.cacheDir, "scratch").apply { mkdirs() } + // In-flight captures live in filesDir/scratch/ — deliberately NOT listed in file_paths.xml. + val scratch = File(context.filesDir, "scratch").apply { mkdirs() } val scratchFile = File(scratch, "raw_test.mp4").apply { writeBytes(ByteArray(4)) } try { assertThrows(IllegalArgumentException::class.java) { diff --git a/app/src/main/java/io/github/stozo04/openloop/data/VideoStorageRepository.kt b/app/src/main/java/io/github/stozo04/openloop/data/VideoStorageRepository.kt index af07eb5..92d7901 100644 --- a/app/src/main/java/io/github/stozo04/openloop/data/VideoStorageRepository.kt +++ b/app/src/main/java/io/github/stozo04/openloop/data/VideoStorageRepository.kt @@ -31,7 +31,7 @@ data class RecordedVideo( ) /** - * A per-capture scratch target under `cacheDir/scratch/`, identified by a UUID. + * A per-capture scratch target under `filesDir/scratch/`, identified by a UUID. * * One [ScratchCapture] is minted per shutter press ([VideoStorageRepository.createScratchCapture]). * The camera records into [file]; on a successful capture the scratch is promoted to a persistent @@ -45,7 +45,7 @@ data class ScratchCapture(val uuid: String, val file: File) * Contract for persisting and reading recorded clips (raws + boomerangs). * * Backed by the app's private filesystem: - * - `cacheDir/scratch/raw_.mp4` — per-capture scratch (volatile; cache-evictable) + * - `filesDir/scratch/raw_.mp4` — per-capture scratch, pruned after abandonment * - `filesDir/videos/clip_.mp4` — persisted raw captures * - `filesDir/videos/boom__from_.mp4` — rendered loops * - `filesDir/thumbnails/.jpg` — thumbnails for both kinds @@ -57,7 +57,7 @@ data class ScratchCapture(val uuid: String, val file: File) interface VideoStorageRepository { /** - * Mint a fresh per-capture scratch target (`cacheDir/scratch/raw_.mp4`). The returned + * Mint a fresh per-capture scratch target (`filesDir/scratch/raw_.mp4`). The returned * [ScratchCapture.file] does NOT yet exist on disk — the camera creates it by recording into it. */ fun createScratchCapture(): ScratchCapture @@ -76,14 +76,14 @@ interface VideoStorageRepository { fun discardScratch(scratch: ScratchCapture) /** - * Delete scratch files under `cacheDir/scratch/` whose `lastModified()` is older than + * Delete scratch files under `filesDir/scratch/` whose `lastModified()` is older than * [olderThanMs] (i.e. age > [olderThanMs]); returns the count deleted (parent D-8). Best-effort * cleanup of orphaned captures/imports left behind by an interrupted session — imports raise this - * churn since an abandoned copy can be a whole library video. Sweeps both the top-level scratch - * captures/imports AND the cached reversed clips in `scratch/reversed/` (one ≤1080p MP4 per - * distinct trim window, never overwritten — the heaviest orphans), so the reclaim reaches the - * cache that actually grows. Younger files (a session possibly still in flight) are left - * untouched. `suspend` + off the main thread (filesystem scan). + * churn since an abandoned copy can be a whole library video. Also sweeps legacy + * `cacheDir/scratch/raw_*.mp4` captures and the cached reversed clips in + * `cacheDir/scratch/reversed/` (one ≤1080p MP4 per distinct trim window, never overwritten), so + * reclaim reaches both the old layout and the cache that actually grows. Younger files (a session + * possibly still in flight) are left untouched. `suspend` + off the main thread (filesystem scan). */ suspend fun pruneStaleScratch(olderThanMs: Long): Int diff --git a/app/src/main/java/io/github/stozo04/openloop/data/VideoStorageRepositoryImpl.kt b/app/src/main/java/io/github/stozo04/openloop/data/VideoStorageRepositoryImpl.kt index 29a802b..32c2958 100644 --- a/app/src/main/java/io/github/stozo04/openloop/data/VideoStorageRepositoryImpl.kt +++ b/app/src/main/java/io/github/stozo04/openloop/data/VideoStorageRepositoryImpl.kt @@ -18,7 +18,7 @@ import java.util.UUID * `Factory` bridges Context to these paths once, in `MainActivity`. * * On-disk layout: - * - scratch capture: `cacheDir/scratch/raw_.mp4` (per capture; cache-evictable) + * - scratch capture: `filesDir/scratch/raw_.mp4` (per capture; private app data) * - persisted clips: `filesDir/videos/` — raws as `clip_.mp4`, rendered loops as * `boom__from_.mp4` (same directory; distinguished by filename prefix) * - thumbnails: `filesDir/thumbnails/.jpg` (JPEG, 90% quality) @@ -30,7 +30,9 @@ class VideoStorageRepositoryImpl( private val videosDir = File(filesDir, "videos") private val thumbnailsDir = File(filesDir, "thumbnails") - private val scratchDir = File(cacheDir, "scratch") + private val scratchDir = File(filesDir, "scratch") + private val legacyCacheScratchDir = File(cacheDir, "scratch") + private val reversedScratchDir = File(legacyCacheScratchDir, REVERSED_SUBDIR) /** * Last minted millis-timestamp id. Two saves in the same wall-clock millisecond would otherwise @@ -105,15 +107,12 @@ class VideoStorageRepositoryImpl( override suspend fun pruneStaleScratch(olderThanMs: Long): Int = withContext(Dispatchers.IO) { val cutoff = System.currentTimeMillis() - olderThanMs - // Prune BOTH the per-capture/import scratch files directly under scratch/ (raw_.mp4) - // AND the cached reversed clips under scratch/reversed/. The reversed cache is where the - // heaviest orphans accumulate — one ≤1080p reversed MP4 per distinct trim window, written - // once and never overwritten (keyed by sha1(path_trimStart_trimEnd) in VideoReverser) — so - // D-8's reclaim must reach it. The prior sweep only looked at the top level and skipped the - // reversed/ directory entirely (isFile == false for it), leaving it to grow until opaque OS - // cache eviction. (Dir name mirrors VideoReverser's scratchDir = cacheDir/scratch/reversed.) + // Prune active capture/import scratch files in filesDir/scratch/ plus legacy raw scratch + // files from the old cacheDir/scratch/ layout. Reverse-preview cache files remain under + // cacheDir/scratch/reversed/ because they are reproducible and safely cache-evictable. val deleted = pruneStaleFilesIn(scratchDir, cutoff) + - pruneStaleFilesIn(File(scratchDir, REVERSED_SUBDIR), cutoff) + pruneStaleFilesIn(legacyCacheScratchDir, cutoff) + + pruneStaleFilesIn(reversedScratchDir, cutoff) if (deleted > 0) Log.d(TAG, "Pruned $deleted stale scratch file(s)") deleted } diff --git a/app/src/main/java/io/github/stozo04/openloop/diagnostics/ReverseCrashlytics.kt b/app/src/main/java/io/github/stozo04/openloop/diagnostics/ReverseCrashlytics.kt index c64122c..f3ab434 100644 --- a/app/src/main/java/io/github/stozo04/openloop/diagnostics/ReverseCrashlytics.kt +++ b/app/src/main/java/io/github/stozo04/openloop/diagnostics/ReverseCrashlytics.kt @@ -137,6 +137,7 @@ internal object ReverseCrashlytics { .putString("mmr_failure_kind", failureKind.take(1024)) source?.let { builder + .putBoolean("source_exists", it.exists()) .putLong("source_bytes", it.length()) .putString("source_name", it.name.take(1024)) } @@ -198,6 +199,7 @@ internal object ReverseCrashlytics { .putLong("trim_start_ms", trimStartMs) .putLong("trim_end_ms", trimEndMs) .putLong("trim_window_ms", (trimEndMs - trimStartMs).coerceAtLeast(0L)) + .putBoolean("source_exists", source.exists()) .putLong("source_bytes", source.length()) .putString("source_name", source.name.take(1024)) if (diagnostics != null) { diff --git a/app/src/main/java/io/github/stozo04/openloop/ui/OpenLoopViewModel.kt b/app/src/main/java/io/github/stozo04/openloop/ui/OpenLoopViewModel.kt index 6afd45f..069b88b 100644 --- a/app/src/main/java/io/github/stozo04/openloop/ui/OpenLoopViewModel.kt +++ b/app/src/main/java/io/github/stozo04/openloop/ui/OpenLoopViewModel.kt @@ -302,6 +302,9 @@ class OpenLoopViewModel( private val _renderProgress = MutableStateFlow(0f) val renderProgress: StateFlow = _renderProgress.asStateFlow() + /** Guards against repeated Save taps while promotion/enqueue/render is already active. */ + private var saveInProgress = false + /** One-shot snackbar events (see [BoomerangEvent]); collected once by MainActivity. */ private val _events = Channel(Channel.BUFFERED) val events: Flow = _events.receiveAsFlow() @@ -346,7 +349,7 @@ class OpenLoopViewModel( _uiState.value = OpenLoopUiState.Recording - // Per-capture scratch file (cacheDir/scratch/raw_.mp4) instead of a single fixed path, + // Per-capture scratch file (filesDir/scratch/raw_.mp4) instead of a single fixed path, // so the captured clip has a stable identity for the Trim screen and back-to-back captures // can't clobber each other. val scratch = videoStorage.createScratchCapture() @@ -1045,6 +1048,8 @@ class OpenLoopViewModel( fun saveBoomerang() { val editor = _editorState.value ?: return val scratch = activeScratch ?: return + if (saveInProgress || _uiState.value is OpenLoopUiState.Processing) return + saveInProgress = true val tab = _editorTabState.value val mode = tab.mode @@ -1121,6 +1126,7 @@ class OpenLoopViewModel( // End the WorkManager observer without canceling this coroutine mid-collect. renderObserveJob = null activeRenderScratchUuid = null + saveInProgress = false cancelReverseJob() activeScratch = null promotedRaw = null @@ -1159,6 +1165,7 @@ class OpenLoopViewModel( renderObserveJob?.cancel() renderObserveJob = null activeRenderScratchUuid = null + saveInProgress = false _renderProgress.value = 0f val editor = _editorState.value val trimStartMs = editor?.trimStartMs ?: 0L @@ -1250,6 +1257,7 @@ class OpenLoopViewModel( renderObserveJob?.cancel() renderObserveJob = null activeRenderScratchUuid = null + saveInProgress = false } /** Clear the active editing session (after discard or navigation away). Does NOT touch on-disk files. */ diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml index f0ca34c..b521c76 100644 --- a/app/src/main/res/xml/file_paths.xml +++ b/app/src/main/res/xml/file_paths.xml @@ -1,7 +1,7 @@ diff --git a/app/src/test/java/io/github/stozo04/openloop/data/VideoStorageRepositoryImplTest.kt b/app/src/test/java/io/github/stozo04/openloop/data/VideoStorageRepositoryImplTest.kt index 7b34220..60c544b 100644 --- a/app/src/test/java/io/github/stozo04/openloop/data/VideoStorageRepositoryImplTest.kt +++ b/app/src/test/java/io/github/stozo04/openloop/data/VideoStorageRepositoryImplTest.kt @@ -57,6 +57,8 @@ class VideoStorageRepositoryImplTest { private fun videosDir() = File(filesDir, "videos") private fun thumbnailsDir() = File(filesDir, "thumbnails") + private fun scratchDir() = File(filesDir, "scratch") + private fun legacyCacheScratchDir() = File(cacheDir, "scratch") /** Pre-creates a clip + matching thumbnail so the lazy MediaMetadataRetriever path is skipped. */ private fun seedClipWithThumbnail(timestamp: Long) { @@ -82,7 +84,7 @@ class VideoStorageRepositoryImplTest { val a = repository.createScratchCapture() val b = repository.createScratchCapture() - assertEquals(File(cacheDir, "scratch").absolutePath, a.file.parentFile?.absolutePath) + assertEquals(scratchDir().absolutePath, a.file.parentFile?.absolutePath) assertTrue(a.file.name.startsWith("raw_") && a.file.name.endsWith(".mp4")) assertFalse(a.file.exists()) // the camera creates it later by recording into it assertNotEquals(a.uuid, b.uuid) @@ -290,8 +292,6 @@ class VideoStorageRepositoryImplTest { // ── Stale-scratch prune (slice 07 / D-8) ── - private fun scratchDir() = File(cacheDir, "scratch") - @Test fun `pruneStaleScratch deletes old scratch files and keeps recent ones`() = runBlocking { val scratch = scratchDir().apply { mkdirs() } @@ -311,7 +311,7 @@ class VideoStorageRepositoryImplTest { @Test fun `pruneStaleScratch also prunes stale reversed clips but keeps fresh ones and the directory`() = runBlocking { - val scratch = scratchDir().apply { mkdirs() } + val scratch = legacyCacheScratchDir().apply { mkdirs() } // VideoReverser caches reversed clips in scratch/reversed/ (one per trim window, never // overwritten — the heaviest orphans), so the prune must reach them too (review W2). val reversed = File(scratch, "reversed").apply { mkdirs() } @@ -329,6 +329,23 @@ class VideoStorageRepositoryImplTest { assertTrue("reversed subdirectory itself must survive (only files are pruned)", reversed.exists()) } + @Test + fun `pruneStaleScratch also prunes legacy cache scratch captures`() = runBlocking { + val scratch = legacyCacheScratchDir().apply { mkdirs() } + val old = File(scratch, "raw_old_cache_layout.mp4").apply { writeBytes(ByteArray(4)) } + val fresh = File(scratch, "raw_fresh_cache_layout.mp4").apply { writeBytes(ByteArray(4)) } + + val now = System.currentTimeMillis() + old.setLastModified(now - 48L * 60 * 60 * 1_000) + fresh.setLastModified(now - 1L * 60 * 60 * 1_000) + + val deleted = repository.pruneStaleScratch(24L * 60 * 60 * 1_000) + + assertEquals(1, deleted) + assertFalse("stale legacy cache scratch should be deleted", old.exists()) + assertTrue("fresh legacy cache scratch should survive", fresh.exists()) + } + @Test fun `pruneStaleScratch with no scratch directory returns zero`() = runBlocking { // scratch/ never created — must be a safe no-op, never throw. diff --git a/app/src/test/java/io/github/stozo04/openloop/ui/OpenLoopViewModelTest.kt b/app/src/test/java/io/github/stozo04/openloop/ui/OpenLoopViewModelTest.kt index 7eade1b..3bc8256 100644 --- a/app/src/test/java/io/github/stozo04/openloop/ui/OpenLoopViewModelTest.kt +++ b/app/src/test/java/io/github/stozo04/openloop/ui/OpenLoopViewModelTest.kt @@ -129,6 +129,9 @@ class FakeVideoStorageRepository : VideoStorageRepository { /** Toggles to simulate failure paths. */ var failPromote: Boolean = false var failRegister: Boolean = false + var promoteGate: CompletableDeferred? = null + var promoteCallCount: Int = 0 + private set /** Fixed duration [durationOf] reports for any file. */ var fixedDurationMs: Long = 3_000L @@ -148,6 +151,8 @@ class FakeVideoStorageRepository : VideoStorageRepository { } override suspend fun promoteScratchToRaw(scratch: ScratchCapture): RecordedVideo? { + promoteCallCount++ + promoteGate?.await() if (failPromote) return null val id = nextId++ return RecordedVideo( @@ -1138,6 +1143,25 @@ class OpenLoopViewModelTest { job.cancel() } + @Test + fun `saveBoomerang ignores duplicate taps while promotion is in flight`() = + runTest(mainDispatcherRule.testDispatcher) { + enterTrimState() + viewModel.onNextFromTrim() + advanceUntilIdle() + fakeVideoStorage.promoteGate = CompletableDeferred() + + viewModel.saveBoomerang() + viewModel.saveBoomerang() + + assertEquals(1, fakeVideoStorage.promoteCallCount) + + fakeVideoStorage.promoteGate!!.complete(Unit) + advanceUntilIdle() + + assertEquals(1, fakeRenderScheduler.enqueueCount) + } + // ── Speed tab (slice 04) ── @Test diff --git a/docs/TEST_COVERAGE.md b/docs/TEST_COVERAGE.md index a579e40..6d832ba 100644 --- a/docs/TEST_COVERAGE.md +++ b/docs/TEST_COVERAGE.md @@ -106,6 +106,7 @@ media-pipeline or FGS changes. | **Fakes** | Hand-written test doubles using real interfaces | `test/` (preferred over mocks for Flow-based interfaces) | | **kotlinx-coroutines-test** | `TestDispatcher`, `runTest`, virtual time control | `test/` | | **Robolectric 4.16.1** | Run real Android framework code on the JVM; `@Config(sdk=[...])` for version-specific behavior | `test/` (tutorial: [`robolectric-testing-explained.md`](guides/robolectric-testing-explained.md) · catalog: [`robolectric-test-catalog.md`](guides/robolectric-test-catalog.md)) | +| **JaCoCo 0.8.13** | JVM/Robolectric unit-test coverage reports and the ViewModel coverage gate | Gradle tasks: `jacocoDebugUnitTestReport`, `jacocoDebugViewModelCoverageVerification` | | **androidx.work.testing** | `TestListenableWorkerBuilder` for worker guard tests without a device | `test/` (+ `androidTest/` for full encode) | | **Compose UI Test** | `createComposeRule`, semantic matchers, UI assertions | `androidTest/` | | **AndroidJUnit4** | Instrumented test runner | `androidTest/` | @@ -120,6 +121,32 @@ Use MockK for Android framework classes that can't easily be faked (e.g., `Conte --- +## Coverage Commands + +OpenLoop uses JaCoCo for device-free JVM/Robolectric coverage. The full report task runs +`testDebugUnitTest` first, then writes: + +- HTML: `app/build/reports/jacoco/jacocoDebugUnitTestReport/html/index.html` +- XML: `app/build/reports/jacoco/jacocoDebugUnitTestReport/jacocoDebugUnitTestReport.xml` + +```powershell +.\gradlew.bat :app:jacocoDebugUnitTestReport +``` + +The CI-friendly gate is intentionally scoped to the ViewModel state machine, where the local JVM +suite is expected to stay above 90%. Whole-app coverage is still reported, but not gated yet because +large UI/media/hardware paths require instrumented and OEM-device lanes. + +```powershell +.\gradlew.bat :app:jacocoDebugViewModelCoverageVerification +.\gradlew.bat :app:check +``` + +When raising coverage outside the ViewModel layer, add targeted tests first, then expand the +verification class set in `app/build.gradle.kts`. Do not lower the 90% gate to make CI pass. + +--- + ## Coroutine Testing All ViewModel coroutines use `viewModelScope`, which requires replacing `Dispatchers.Main` in tests. diff --git a/docs/guides/reverse-video-research.md b/docs/guides/reverse-video-research.md index 13caecf..0ee1c49 100644 --- a/docs/guides/reverse-video-research.md +++ b/docs/guides/reverse-video-research.md @@ -191,7 +191,7 @@ works and the output plays smoothly. - **Disk:** the intermediate keyframe-only file is roughly 2–4× the size of the source. For a 30 s 1080p source (~80 MB) the intermediate is 160–320 MB. - Lives in `cacheDir/scratch/` and is deleted after the reversed output is + Lives in `cacheDir/scratch/reversed/` and is deleted after the reversed output is produced. - **Time:** two-pass means roughly 2× the time of a single transcode. On a Pixel 10 Pro Fold class device, expect ~0.5 s per 1 s of source for the @@ -300,7 +300,7 @@ Cache key: the source file's absolute path + the trim window (`__.reversed.mp4`). Trim must be part of the key because changing trim changes the reversed content. Live in `cacheDir/scratch/reversed/`. Pruned by the same 24 h sweep that handles -orphan scratch captures (parent doc D-8). +orphan scratch captures in `filesDir/scratch/` (parent doc D-8). ### Estimated implementation effort