diff --git a/CLAUDE.md b/CLAUDE.md index 2eb09c3..c72f0f6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -76,12 +76,18 @@ days. Read it as the current answer, and see the git history if you need the old `angle_indirect` and `swangle_indirect` all boot, while `auto`, `off`, `guest` and `swiftshader_indirect` do not. `docs/local-emulator.md` has the evidence and the per-API renderer table. -- **CI runs API 37, and it gates.** The matrix is 33/34/35/36/37. **Five** of the 68 instrumented +- **CI runs API 37, and it gates.** The matrix is 33/34/35/36/37. **Five** of the 69 instrumented tests cannot be *run* on that image, for three unrelated reasons: three Media3 tests fail inside the emulator's own `c2.goldfish.h264.decoder`, one SAF test takes the framework down when it rotates the display, and its sibling — the SAF picker round trip — aborts `system_server` from the task-snapshot path whether it passes or not. All five carry `@FailsOnEmulatorApi37` and run - in a separate `continue-on-error` job; the gating leg runs the other 63. + in a separate `continue-on-error` job; the gating leg runs the other 64. + + **These two numbers move with the suite and are derived, not remembered.** `grep -cE + '^\s*@Test' ` over `app/src/androidTest` is the first; the second is that minus the marker + count `.github/scripts/e2e-report-shape.sh` greps. Cross-check against any run's shape rather + than trusting the sentence: a leg below 37 reports the first as `expected`, and the API 37 + gating leg reports the second. **That third reason is why "cannot pass" became "cannot be run" on 2026-09-05.** Four gating runs were read logcat-first — 34006456986, 34001744574, 34001377499 and the green 34002313300 — diff --git a/app/src/androidTest/java/org/libremediaconverter/join/ConcatEngineTest.kt b/app/src/androidTest/java/org/libremediaconverter/join/ConcatEngineTest.kt index d059f4e..ecadf1c 100644 --- a/app/src/androidTest/java/org/libremediaconverter/join/ConcatEngineTest.kt +++ b/app/src/androidTest/java/org/libremediaconverter/join/ConcatEngineTest.kt @@ -25,6 +25,7 @@ import org.junit.runner.RunWith import org.libremediaconverter.convert.MediaProbe import org.libremediaconverter.convert.StagingNames import org.libremediaconverter.ffmpeg.ConcatEngine +import org.libremediaconverter.ffmpeg.FFmpegEngine import org.libremediaconverter.model.ConcatStrategy import org.libremediaconverter.work.ConcatWorker import java.io.File @@ -236,6 +237,55 @@ class ConcatEngineTest { ) } + /** + * A failed join tells the user the return code and what FFmpeg said. + * + * **This is the device half of #203/#217**, whose PR closed by noting the join legs had not + * been run. Running them would not have answered it: nothing on either source set drove a real + * join *failure*, so the unified message was asserted only against values a JVM test hands to + * `sessionOutcome` directly. + * + * What is device-only here is that the three reads behind that message work against a real + * native session at all — `getReturnCode`, `getFailStackTrace` and `getAllLogsAsString`. If + * the log tail came back null or empty on a device, the user would get `Joining failed (1): ` + * with nothing after the colon and every JVM test would still pass. + * + * **What this deliberately does not pin is the preference between the two detail sources.** On + * an ordinary non-zero return code FFmpegKit reports no fail stack trace, so the stack-trace- + * first rule and the log-tail-first rule produce the same text and no assertion here can tell + * them apart. That ordering is [SessionOutcomeTest][org.libremediaconverter.ffmpeg.SessionOutcomeTest]'s + * job, where both sources can be non-blank at once. Asserting it here would be a test whose + * KDoc claims more than it checks — the `probeForConcat` mistake wave 3 caught. + * + * The failure is forced with an input that does not exist, which the concat demuxer rejects + * the same way on every FFmpeg build, rather than with malformed media whose handling varies. + */ + @Test + fun aFailedJoinReportsTheReturnCodeAndWhatFFmpegSaid(): Unit = runBlocking { + val missing = File(context.cacheDir, "no_such_clip.mp4").also { it.delete() } + val out = output("joined_failure.mp4") + + val failure = runCatching { + engine.join(listOf(Uri.fromFile(clipA), Uri.fromFile(missing)), out) + }.exceptionOrNull() + + assertTrue( + "a join over a missing input must fail, got $failure", + failure is FFmpegEngine.FFmpegException, + ) + val message = failure?.message.orEmpty() + assertTrue( + "the message must name the operation and carry the return code, was: '$message'", + message.startsWith("Joining failed ("), + ) + // The half a JVM test cannot reach: a real session actually produced detail to show. + val detail = message.substringAfter("): ", "") + assertTrue( + "the message stopped at the return code and told the user nothing, was: '$message'", + detail.isNotBlank(), + ) + } + @Test fun theListFileIsCleanedUpAfterJoining(): Unit = runBlocking { val out = output("joined_cleanup.mp4")