Compare commits

..
Author SHA1 Message Date
Jason Ross f65578b1f7 Merge branch 'main' into test/theme-follows-system-dark 2026-09-05 20:01:47 -05:00
Jason Ross b38ad6a683 Merge pull request #212 from JMR-dev/test/hardware-progress-reaches-workmanager
Report hardware progress to WorkManager, which nothing had checked
2026-09-05 20:00:46 -05:00
Jason Ross 9a0f494e26 Merge pull request #211 from JMR-dev/test/ffprobe-mapping-seam
Read FFprobe's answer without spawning FFprobe
2026-09-05 20:00:22 -05:00
Jason Ross f3478706b3 Merge pull request #210 from JMR-dev/test/device-codec-enumeration-seam
Cut a seam through the codec enumeration, and say what a failed one actually does
2026-09-05 19:59:58 -05:00
Jason Ross 61c400d2c6 Merge pull request #209 from JMR-dev/test/unknown-container-row
Render the container row for a video nothing could name
2026-09-05 19:59:36 -05:00
Jason Ross d83775d5c6 Merge pull request #208 from JMR-dev/test/audio-drop-arm
Build a command for the audio the user turned off
2026-09-05 19:59:14 -05:00
Jason Ross e90f5a801c Merge branch 'main' into test/audio-drop-arm 2026-09-05 19:49:49 -05:00
Jason Ross 68015b3374 Merge pull request #207 from JMR-dev/test/null-message-fallbacks
Make a failure that says nothing still say something
2026-09-05 19:48:11 -05:00
3 changed files with 4 additions and 284 deletions
@@ -51,13 +51,10 @@ 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.**~~ **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.
* - **`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.
* - **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
@@ -1,141 +0,0 @@
package org.libremediaconverter.convert
import android.app.Activity
import android.content.Intent
import android.net.Uri
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.media3.common.util.UnstableApi
import androidx.work.Data
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.join.JoinScreen
import org.libremediaconverter.model.InputProbe
import org.libremediaconverter.ui.TestTags
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.Shadows.shadowOf
import org.robolectric.shadows.ShadowActivity
/**
* The launcher layer above the `ScreenContent` seam — registered, and until now never resulted.
*
* ## The hazard this exists for
*
* `ConversionViewModel.onInputPicked(uri: Uri)` and `.save(destination: Uri)` are **both
* `(Uri) -> Unit`**, so swapping the two launcher callbacks at `ConverterScreen.kt:70` and `:83`
* compiles, renders, and passes the entire suite. Picking a file would attempt a save to it, and
* choosing a destination would load it as input.
*
* That is precisely the defect class `ScreenWiringTest` exists for, on the one pair it declines to
* cover: it drives `converterActions` directly and says the launcher-backed actions stay
* parameters. Correct for the `actions` seam, and it leaves the edge above that seam unpinned.
*
* Join's equivalents (`JoinScreen.kt:45`, `:55`) are `List<Uri>` and `Uri`, so they are **not**
* transposable and need no such test. The picker filter is a different matter and is covered below
* for both screens.
*
* ## The two mechanics, verified before the assertions were written
*
* Neither is used anywhere else in the suite, so both were spiked first:
*
* - **Reading what was launched** — `shadowOf(activity).nextStartedActivityForResult`, which returns
* the `Intent` with its `EXTRA_MIME_TYPES` intact.
* - **Delivering a result** — `shadowOf(activity).receiveResult(...)`, which reaches
* `ComponentActivity`'s `ActivityResultRegistry` and fires the `rememberLauncherForActivityResult`
* callback.
*
* `createAndroidComposeRule`, as `AdaptiveShellTest` uses and for the reason it gives: the screens
* compose real ViewModels through `viewModel()`, and the plain rule supplies no `ViewModelStoreOwner`.
*/
@UnstableApi
@RunWith(RobolectricTestRunner::class)
class LauncherWiringTest {
@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()
@Before
fun setUp() {
val app = RuntimeEnvironment.getApplication()
installTestWorkManager(app, Data.EMPTY)
// The real screen composes a real ViewModel; neither test here is about probing.
ConversionDependencies.probe = { _, _ -> InputProbe() }
}
@After
fun tearDown() = ConversionDependencies.reset()
/**
* The transposition guard. A picked file has to reach `onInputPicked`, which is observable as
* the screen arriving at `Ready` with the file card showing — `save()` from `Idle` returns at
* its own guard and leaves nothing behind.
*/
@Test
fun `a picked document is loaded as input rather than saved to`() {
composeRule.setContent { ConverterScreen() }
composeRule.onNodeWithTag(TestTags.Converter.CHOOSE_FILE).performClick()
deliver(Uri.parse("content://test/holiday.mkv"))
composeRule.onNodeWithTag(TestTags.Converter.FILE_CARD_NAME).assertIsDisplayed()
}
/**
* `ConverterScreen.kt:65-67` records why the all-types wildcard is load-bearing rather than lazy:
*
* > the picker is images and video only, offers no audio at all, and will not reliably surface
* > .mkv/.flac/.webm
*
* Narrowing it would make every audio conversion unreachable from the file picker, and nothing
* would have gone red. (The literal is spelled only in the assertion below: a KDoc cannot
* contain it, because the wildcard's second half closes the comment.)
*/
@Test
fun `the converter picker asks for every type, not just the ones a photo picker offers`() {
composeRule.setContent { ConverterScreen() }
composeRule.onNodeWithTag(TestTags.Converter.CHOOSE_FILE).performClick()
val intent = launched().intent
assertEquals(Intent.ACTION_OPEN_DOCUMENT, intent.action)
assertEquals(listOf("*/*"), intent.getStringArrayExtra(Intent.EXTRA_MIME_TYPES)?.toList())
}
@Test
fun `the join picker asks for video and accepts more than one file`() {
composeRule.setContent { JoinScreen() }
composeRule.onNodeWithTag(TestTags.Join.CHOOSE_FILES).performClick()
val intent = launched().intent
assertEquals(Intent.ACTION_OPEN_DOCUMENT, intent.action)
assertEquals(listOf("video/*"), intent.getStringArrayExtra(Intent.EXTRA_MIME_TYPES)?.toList())
// A join of one file is not a join; the contract is what asks for several.
assertEquals(true, intent.getBooleanExtra(Intent.EXTRA_ALLOW_MULTIPLE, false))
}
private fun launched(): ShadowActivity.IntentForResult {
composeRule.waitForIdle()
return requireNotNull(shadowOf(composeRule.activity).nextStartedActivityForResult) {
"nothing was launched for a result"
}
}
private fun deliver(uri: Uri) {
val started = launched()
shadowOf(composeRule.activity).receiveResult(
started.intent,
Activity.RESULT_OK,
Intent().setData(uri),
)
composeRule.waitForIdle()
}
}
@@ -1,136 +0,0 @@
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<ComponentActivity>()
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"
}
}