diff --git a/app/src/test/java/org/libremediaconverter/convert/FailedSaveRetryTest.kt b/app/src/test/java/org/libremediaconverter/convert/FailedSaveRetryTest.kt index e63cb7a..8cd756e 100644 --- a/app/src/test/java/org/libremediaconverter/convert/FailedSaveRetryTest.kt +++ b/app/src/test/java/org/libremediaconverter/convert/FailedSaveRetryTest.kt @@ -51,10 +51,13 @@ import java.io.File * here needs. `OutputPublisherPublishTest` owns what a real publish writes. * - **The screen's two buttons.** `ConverterStateAffordancesTest` and `JoinStateAffordancesTest` * own what each state renders; this file owns what each state carries. - * - **`ConverterScreen`'s `destinationMime` line itself.** It lives in the entry point, above the - * `ScreenContent` seam, and reaching it needs a real ViewModel inside a composition. What it - * reads -- `pendingSave()?.mimeType` -- is asserted directly instead, which is why that - * derivation was moved out of the entry point in the first place. + * - ~~**`ConverterScreen`'s `destinationMime` line itself.**~~ **Withdrawn 2026-09-02 (#201).** The + * exemption read: "it lives in the entry point, above the `ScreenContent` seam, and reaching it + * needs a real ViewModel inside a composition". That was true when written and is no longer: + * `AdaptiveShellTest` (#173) established composing the real screens with real ViewModels, and + * #200 added the `ShadowActivity` mechanics for reading what a launcher launched. `RetrySaveMimeTest` + * now asserts the line directly. What this file still owns is the half below the seam -- what each + * state *carries* -- which is why `pendingSave()?.mimeType` is also asserted here. * - **Picking a new input while a `Failed` carries a file.** `onInputPicked` overwrites the state * without discarding, from `Converted` exactly as much as from a carrying `Failed`, and neither * branch renders a picker. It is a pre-existing path this change neither opens nor widens: the diff --git a/app/src/test/java/org/libremediaconverter/convert/RetrySaveMimeTest.kt b/app/src/test/java/org/libremediaconverter/convert/RetrySaveMimeTest.kt new file mode 100644 index 0000000..ae17299 --- /dev/null +++ b/app/src/test/java/org/libremediaconverter/convert/RetrySaveMimeTest.kt @@ -0,0 +1,136 @@ +package org.libremediaconverter.convert + +import android.app.Application +import android.content.Intent +import android.net.Uri +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.media3.common.util.UnstableApi +import androidx.work.WorkManager +import androidx.work.workDataOf +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.libremediaconverter.model.InputProbe +import org.libremediaconverter.model.OutputFormat +import org.libremediaconverter.ui.TestTags +import org.libremediaconverter.work.ConversionWorker +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import java.io.File + +/** + * The save dialog opens with the type the *job* produced, not the type the picker is showing now. + * + * `ConverterScreen.kt:80` — `state.pendingSave()?.mimeType ?: settings.spec.mimeType` — had never + * taken its left-hand side. Its comment records what the line is for: + * + * > a retry offered after a failed save opens the dialog with the type its first attempt used — + * > the cast answered null for a `Failed`, and the fallback below is the current picker, which a + * > reattached job never set. + * + * So the untested half is the fix, and the tested half is the fallback it was added to stop being + * used. + * + * ## This revises a named exemption, deliberately + * + * `FailedSaveRetryTest`'s KDoc lists this line under "Not asserted here, so each is a decision + * rather than an omission": + * + * > It lives in the entry point, above the `ScreenContent` seam, and reaching it needs a real + * > ViewModel inside a composition. + * + * That was true when written. `AdaptiveShellTest` (#173) then established exactly that capability, + * and #200 added the two `ShadowActivity` mechanics that let a test read what a launcher launched. + * The reason the exemption gave no longer holds, so the exemption is withdrawn rather than left to + * be taken at face value — the same shape as #141 revising #84's boundary. That KDoc is corrected + * in this change. + * + * ## Why the job is reattached rather than run + * + * The screen composes its own ViewModel through `viewModel()`, so nothing can be injected into it. + * A job finished before the composition is the one route to a `Converted` state carrying output + * `Data` this test chose — and it is also the case the line exists for, since a reattached job's + * spec "was never in these settings at all". + */ +@UnstableApi +@RunWith(RobolectricTestRunner::class) +class RetrySaveMimeTest { + + @get:Rule + val composeRule = createAndroidComposeRule() + + private lateinit var app: Application + private lateinit var staged: File + + @Before + fun setUp() { + app = RuntimeEnvironment.getApplication() + ConversionDependencies.probe = { _, _ -> InputProbe() } + staged = OutputPublisher(app).createStagingFile("holiday.mkv").apply { writeBytes(ByteArray(4096)) } + } + + @After + fun tearDown() = ConversionDependencies.reset() + + @Test + fun `the save dialog offers the type the job produced, not the one the picker is showing`() { + finishAJobProducing(JOB_MIME_TYPE) + composeRule.setContent { ConverterScreen() } + composeRule.waitForIdle() + + composeRule.onNodeWithTag(TestTags.SAVE_FILE).performScrollTo().performClick() + composeRule.waitForIdle() + + val intent = requireNotNull(shadowOf(composeRule.activity).nextStartedActivityForResult) { + "the save dialog was never launched" + }.intent + assertEquals(Intent.ACTION_CREATE_DOCUMENT, intent.action) + assertEquals(JOB_MIME_TYPE, intent.type) + // The fixture is only meaningful while the two differ; without this the assertion above + // would pass just as well against the fallback. + assertNotEquals( + "the picker's own type must differ, or this test proves nothing", + JOB_MIME_TYPE, + OutputFormat.MP4_H265.spec.mimeType, + ) + } + + /** + * A conversion that finished while nothing was watching, which is what `reattach()` picks up. + * + * `SucceedingWorkerFactory` reports this output `Data` for whatever is enqueued, so the job + * lands `SUCCEEDED` carrying a staged path that exists — the two things `Reattachment.choose` + * requires of a finished job. + */ + private fun finishAJobProducing(mimeType: String) { + installTestWorkManager( + app, + workDataOf( + ConversionWorker.KEY_OUTPUT_PATH to staged.absolutePath, + ConversionWorker.KEY_SUGGESTED_NAME to "holiday.mkv", + ConversionWorker.KEY_MIME_TYPE to mimeType, + ), + ) + WorkManager.getInstance(app).enqueue( + ConversionWorker.request( + inputUri = Uri.parse("content://test/holiday.mkv"), + displayName = "holiday.mkv", + sizeBytes = 4_096L, + ), + ).result.get() + } + + private companion object { + /** Matroska, against the MP4 the picker defaults to. */ + const val JOB_MIME_TYPE = "video/x-matroska" + } +}