Delete the document a failed save could not write (#250)

#226 proved D4's premise -- SAF hands back a document reporting exactly zero
bytes, so destinationIsKnownEmpty can answer true -- and then drove the success
path, where publish's catch is never entered. So deletePartialOutput had still
never run against a real DocumentsProvider; its only assertions were
OutputPublisherPublishTest's, against FakeSafProvider under Robolectric. That is
the same "asserted only against a fake built to match it" shape #226 was filed to
break, one layer down.

RecordingPublisher.failOpen makes openDestination return null, which publish
turns into error("Could not open destination for writing") AFTER its size probe
has run -- so the catch is reached with destinationWasEmpty true on a document
DocumentsUI created seconds earlier. Null rather than a throw because
openDestination's KDoc says a provider that is present and declines is the half
no fake can produce on demand, so that arm is also taken for the first time.

Mutation, measured: delete the deletePartialOutput call and this test fails with
"publish did not delete the document it could not write". Nothing anywhere went
red for that line before.

TWO DEAD ACCESSORS #226 LEFT, and the reason is the same one:

FixtureDocumentsProvider is declared by the test APK and runs in
org.libremediaconverter.test; instrumentation runs in the app's process. A static
in the provider is a different object from the one a test can see, so
deletedDocumentIds() would have read empty forever, and reset(File) deletes under
a filesDir that is not the provider's. Both are removed rather than worked
around. That is E7's process wall from a third side, after ACTION_OPEN_DOCUMENT
and ActivityScenario.

The oracle is the document instead, which crosses the boundary because the app
holds a URI grant for it. Still the path rather than the artefact: the size query
proves the document existed and was empty moments earlier, and one that no longer
answers a query is one something deleted.

CLEANUP IS IN TEARDOWN, and the mutation run is why. A failed save keeps its
staged file deliberately, so this test ends with a finished job for the next
launch to reattach to; its sibling then opened on Converted with no "Choose file"
to tap. The first fix tapped Start over at the end of the test body, which does
not run when the test fails -- so the mutation run turned one real failure into
two, the second looking like an unrelated flake. One cause must produce one red
test.

Baseline 6 -> 7, with the derived counts in CLAUDE.md, the marker KDoc and
status_check.yml moved in the same diff. 71 - 7 is 64, the same gating figure for
the third consecutive time, which is how that paragraph goes stale unnoticed.

Verified: three API 34 runs at 71/71 failed=0, the mutation red on the right
assertion, and the full gate plus pinned actionlint green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-06 14:58:40 -05:00
co-authored by Claude Opus 5
parent fac8e67db2
commit cbbaf74285
5 changed files with 170 additions and 62 deletions
@@ -10,7 +10,7 @@ package org.libremediaconverter
* drift, and the drift is silent in both directions (a test that runs nowhere reads as green).
*
* **"Cannot be run" covers three things now, and it covered only the first until 2026-09-05.**
* Four of the six carriers simply fail: three Media3 tests die in the image's own
* Four of the seven carriers simply fail: three Media3 tests die in the image's own
* `c2.goldfish.h264.decoder`, and the SAF rotation test takes the framework down with it. The
* fifth — `SafPickerRoundTripTest.pickingAFileThroughTheSystemPickerFillsInTheFileCard` —
* **passes about half the time and aborts `system_server` every time**, which is worse for a
@@ -18,12 +18,13 @@ package org.libremediaconverter
* point at (#108). The wording was widened rather than the test excused; that test's own KDoc has
* the four-run measurement.
*
* **The sixth is the new third thing: it is marked by inheritance, not by measurement.**
* `SafPickerRoundTripTest.aSaveWritesToTheDocumentTheSystemPickerCreated` (#226) opens the same
* picker and then a second DocumentsUI dialog on top of it, so it sits on the same task-snapshot
* path its sibling was marked for. It has never been observed at API 37 either way — see the
* measurement under [FAILS_ON_EMULATOR_API37_BASELINE], which is why it cannot be. Marking it was
* the conservative choice, and **the trigger for revisiting it is the rotation test, not itself**:
* **The sixth and seventh are the new third thing: they are marked by inheritance, not by
* measurement.** `SafPickerRoundTripTest.aSaveWritesToTheDocumentTheSystemPickerCreated` (#226)
* and `.aFailedSaveDeletesTheDocumentItCouldNotWrite` (#250) each open the same picker and then a
* second DocumentsUI dialog on top of it, so they sit on the same task-snapshot path their sibling
* was marked for. Neither has ever been observed at API 37 either way — see the measurement under
* [FAILS_ON_EMULATOR_API37_BASELINE], which is why they cannot be. Marking them was the
* conservative choice, and **the trigger for revisiting it is the rotation test, not themselves**:
* while that one truncates the advisory run, nothing downstream of it can report.
*
* It says only what has been measured: **on the emulator, at API 37.** The same tests pass on a
@@ -58,8 +59,8 @@ annotation class FailsOnEmulatorApi37
* cannot be run on this image, so the count is meant to be simultaneously how many the advisory
* leg runs and how many fail. A *smaller* failure count is the interesting direction: it means one
* of them now passes, which is the trigger the KDoc above names for deleting the annotation.
* **Since 2026-09-06 the second half no longer holds in practice** — the run truncates before two
* of the six start, which the last paragraph below measures. `expected` still holds, and it is the
* **Since 2026-09-06 the second half no longer holds in practice** — the run truncates before
* three of the seven start, which the last paragraph below measures. `expected` still holds, and it is the
* field that catches a marker added without changing this number.
*
* **The picker tests are the ones to read that sentence carefully for, and the reason changed
@@ -79,10 +80,11 @@ annotation class FailsOnEmulatorApi37
* framework having died, which is this job's normal.
*
* **That is no longer what happens, and the difference is that neither picker test reports at
* all.** With six carriers the rotation test truncates the run before them: **all four** advisory
* runs at this baseline — 34041156680, 34041593697, 34042397320 and 34043502322 — report
* `expected: 6, received: 4, failed: 4`, and the four are the three Media3 tests plus the
* rotation. So the advisory leg currently answers for
* all.** The rotation test truncates the run before them: **all five** advisory runs at the
* previous baseline of six — 34041156680, 34041593697, 34042397320, 34043502322 and 34045105857 —
* report `expected: 6, received: 4, failed: 4`, and the four are the three Media3 tests plus the
* rotation. #250 adds a third picker test behind the same wall, so expect `expected: 7,
* received: 4`. So the advisory leg currently answers for
* four of its six, and the comparison below is unaffected only because `failed` is not compared
* on a truncated run. Read it as **unmeasured**, not as passing or failing.
*
@@ -96,4 +98,4 @@ annotation class FailsOnEmulatorApi37
* `INSTRUMENTATION_ABORTED`, so the count is a number taken from a partial run. The report
* records the truncation next to the counts for that reason.
*/
const val FAILS_ON_EMULATOR_API37_BASELINE = 6
const val FAILS_ON_EMULATOR_API37_BASELINE = 7
@@ -14,8 +14,6 @@ import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.ArrayList;
import java.util.List;
/**
* One file, offered to the system file picker, so that picking one can be tested at all.
@@ -116,7 +114,6 @@ public final class FixtureDocumentsProvider extends DocumentsProvider {
public static final String DESTINATION_PREFIX = "dest/";
/** Document ids {@link #deleteDocument} was called with, newest last. Cleared by {@link #reset}. */
private static final List<String> DELETED = new ArrayList<>();
/** Already in this source set, and already a real H.264 MP4 the engines can open. */
private static final String FIXTURE_ASSET = "sample_h264.mp4";
@@ -239,34 +236,11 @@ public final class FixtureDocumentsProvider extends DocumentsProvider {
if (documentId == null || !documentId.startsWith(DESTINATION_PREFIX)) {
throw new FileNotFoundException("refusing to delete: " + documentId);
}
synchronized (DELETED) {
DELETED.add(documentId);
}
destinationFile(documentId).delete();
}
/**
* Document ids {@link #deleteDocument} was called with, newest last.
*
* <p><b>Nothing reads this yet, and that is recorded rather than hidden (#250).</b> It was
* added with #226 to assert {@code OutputPublisher.deletePartialOutput} — D4's cleanup — against
* a real {@code DocumentsProvider}. #226 only reached the <i>success</i> path, so the
* {@code catch} that calls it is still asserted only against {@code FakeSafProvider} under
* Robolectric. It is kept because the forcing condition is one {@code openDestination} override
* away and #250 says exactly what to add; if that ticket is closed any other way, delete this
* and {@link #DELETED} with it rather than leaving an accessor implying coverage.
*/
public static List<String> deletedDocumentIds() {
synchronized (DELETED) {
return new ArrayList<>(DELETED);
}
}
/** Forgets recorded deletes and removes created destinations. The process outlives one class. */
/** Removes created destinations. The process outlives one class. */
public static void reset(File filesDir) {
synchronized (DELETED) {
DELETED.clear();
}
File dir = new File(filesDir, "destinations");
File[] children = dir.listFiles();
if (children != null) {
@@ -25,9 +25,11 @@ import androidx.test.uiautomator.Configurator
import androidx.test.uiautomator.StaleObjectException
import androidx.test.uiautomator.UiDevice
import androidx.test.uiautomator.Until
import androidx.work.WorkManager
import org.junit.After
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
@@ -40,6 +42,7 @@ import org.libremediaconverter.convert.ConversionDependencies
import org.libremediaconverter.convert.OutputPublisher
import org.libremediaconverter.ui.TestTags
import java.io.File
import java.io.OutputStream
import java.util.concurrent.atomic.AtomicInteger
import java.util.regex.Pattern
@@ -280,17 +283,41 @@ private class RecordingPublisher(private val app: Context) : OutputPublisher(app
super.publish(staged, destination)
}
/**
* Refuses the write when [failOpen] is set, which is the forcing condition for #250.
*
* Returning null rather than throwing is deliberate: it is the arm `publish`'s
* `?: error("Could not open destination for writing")` exists for, and `openDestination`'s
* own KDoc says a provider that is present and declines is the half no fake can produce on
* demand. The size probe in `publish` has already run by the time this is reached, so
* `destinationWasEmpty` is true and `deletePartialOutput` is reached with the document
* genuinely empty — which is the whole point.
*/
override fun openDestination(destination: Uri): OutputStream? =
if (failOpen) null else super.openDestination(destination)
companion object {
var savedBytes: ByteArray = ByteArray(0)
var seenDestination: Uri? = null
var seenIsDocumentUri: Boolean? = null
var seenSizeBefore: Long? = null
/**
* Makes the next `publish` refuse to open its destination.
*
* A flag rather than a second publisher because `ConversionDependencies.publisher` is one
* seam and there is no orchestrator: every test in this process shares the instance the
* `init` block installed. [reset] clears it in teardown, so a test that sets it cannot
* leak a refusing publisher into the next class.
*/
var failOpen: Boolean = false
fun reset() {
savedBytes = ByteArray(0)
seenDestination = null
seenIsDocumentUri = null
seenSizeBefore = null
failOpen = false
}
}
}
@@ -367,6 +394,8 @@ class SafPickerRoundTripTest {
fun restoreOrientation() {
// The suite runs without Android Test Orchestrator, so a swapped seam outlives the class.
ConversionDependencies.reset()
RecordingPublisher.reset()
clearFinishedWork()
ActivityLifecycleMonitorRegistry.getInstance().removeLifecycleCallback(recreationWatcher)
if (!rotated) return
device.setOrientationNatural()
@@ -570,6 +599,106 @@ class SafPickerRoundTripTest {
assertTrue("staging should be empty after a successful save", staged.listFiles().isNullOrEmpty())
}
/**
* The other half of D4 (#250): a save that fails deletes the document it could not write.
*
* ## Why this is separate from the test above
*
* #226 proved the *premise* — SAF hands back a document reporting exactly zero bytes, so
* `destinationIsKnownEmpty` can answer true — and then drove the success path, where the
* `catch` is never entered. So `deletePartialOutput` had still never run against a real
* `DocumentsProvider`; its only assertions were `OutputPublisherPublishTest`'s, against
* `FakeSafProvider` under Robolectric. That is the same "asserted only against a fake built to
* match it" shape #226 was filed to break, one layer down.
*
* ## The forcing condition, and why it is a returned null
*
* [RecordingPublisher.failOpen] makes `openDestination` return null. `publish` turns that into
* `error("Could not open destination for writing")` **after** its size probe has already run,
* so the `catch` is reached with `destinationWasEmpty == true` on a document DocumentsUI
* created seconds earlier. Nothing is simulated: the URI, the grant, the provider and the
* delete are all real.
*
* Null rather than a throw because `openDestination`'s KDoc says a provider that is present
* and declines is the half no fake can produce on demand — so this is also the first time that
* arm has been taken against a live provider rather than a stub.
*
* ## The oracle, and why it is not a recorder inside the provider
*
* The obvious assertion — have the provider record what `deleteDocument` was called with, and
* read it back — **cannot work here, and finding that out is half of what this test cost.**
* `FixtureDocumentsProvider` is declared by the test APK and runs in
* `org.libremediaconverter.test`; instrumentation runs in the app's process. A `static` in the
* provider is therefore a different object from the one a test can see, and the accessor #226
* left behind read empty on every run. That is E7's process wall from a third side, after
* `ACTION_OPEN_DOCUMENT` and `ActivityScenario`.
*
* So the oracle is the document, which does cross the boundary because the app holds a URI
* grant for it. **This is still the path rather than the artefact**, because the two
* assertions are read together: the size query above proves the document *existed and was
* empty* moments earlier, and a `content://` document that no longer answers a query is one
* something deleted. Nothing else in the app deletes SAF documents.
*
* The staged file is asserted to **survive**, which is the deliberate other half of that
* `catch`: a failed save may leave the staged copy as the only copy of an hour of transcoding,
* so `ConversionViewModel` keeps it and puts "Try saving again" on screen.
*/
@Test
@FailsOnEmulatorApi37
fun aFailedSaveDeletesTheDocumentItCouldNotWrite() {
pickTheFixture()
convertToTheDefaultFormat()
RecordingPublisher.failOpen = true
saveThroughTheSystemPicker(settlesOn = TestTags.RETRY_SAVE)
val destination = RecordingPublisher.seenDestination
assertNotNull("publish was never reached, so the delete arm was not exercised", destination)
assertEquals(
"the document was not positively empty, so publish would refuse to delete it",
0L,
RecordingPublisher.seenSizeBefore,
)
assertFalse(
"publish did not delete the document it could not write: $destination",
documentStillExists(destination!!),
)
// The staged copy is kept on purpose -- see ConversionViewModel.save's onFailure.
val staged = File(context.cacheDir, "conversions")
assertTrue(
"a failed save must not delete the staged file; it may be the only copy",
staged.listFiles()?.isNotEmpty() == true,
)
}
/**
* Leaves nothing for the next test's launch to reattach to.
*
* **In teardown rather than at the end of a test, and that placement is the point.**
* `aFailedSaveDeletesTheDocumentItCouldNotWrite` proves that a failed save *keeps* its staged
* file — deliberately, since it may be the only copy — so it ends with a finished job and a
* live staged file, which is exactly what the app reattaches to on the next launch. Its
* sibling then opened on `Converted` with no "Choose file" to tap: measured, as a 30 s timeout
* on `converter.chooseFile` in a test that had nothing wrong with it.
*
* The first fix tapped "Start over" at the end of the test body. That works until the test
* fails, and then it does not run at all — measured too, on the mutation run that proved this
* suite bites: one real failure became two, and the second looked like an unrelated flake.
* **One cause must produce one red test**, so the cleanup belongs where it runs either way.
*/
private fun clearFinishedWork() {
WorkManager.getInstance(context).cancelAllWork()
File(context.cacheDir, "conversions").listFiles()?.forEach { it.delete() }
}
/** Whether [destination] still answers a metadata query. A deleted document does not. */
private fun documentStillExists(destination: Uri): Boolean = runCatching {
context.contentResolver
.query(destination, arrayOf(OpenableColumns.SIZE), null, null, null)
?.use { it.moveToFirst() } ?: false
}.getOrDefault(false)
/**
* Runs the conversion, leaving the screen on `Converted`.
*
@@ -636,7 +765,7 @@ class SafPickerRoundTripTest {
* Retried whole, for the reason [pickTheFixture] documents: a dialog that came up unreadable
* cannot be recovered from inside, and a fresh one is the only answer.
*/
private fun saveThroughTheSystemPicker() {
private fun saveThroughTheSystemPicker(settlesOn: String = TestTags.Converter.CONVERT_ANOTHER) {
var missing: BySelector? = null
repeat(PICK_ATTEMPTS) { attempt ->
requireAReadableScreen()
@@ -645,7 +774,9 @@ class SafPickerRoundTripTest {
if (attempt == 0) PICKER_TIMEOUT_MS else REOPENED_TIMEOUT_MS,
)
if (missing == null) {
awaitNode(TestTags.Converter.CONVERT_ANOTHER, SAVE_TIMEOUT_MS)
// The node that says the save has *finished*, either way. Waiting on the success
// one when the save is meant to fail would time out on a test that is working.
awaitNode(settlesOn, SAVE_TIMEOUT_MS)
return
}
dismissThePicker()