Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02555ceb91 | ||
|
|
1b1d5c6d04 | ||
|
|
2f3f461cc1 | ||
|
|
6166763f24 | ||
|
|
46ad95350b | ||
|
|
e968deb5a2 | ||
|
|
3d55004286 | ||
|
|
e06b0826a0 | ||
|
|
7b578c1ccf | ||
|
|
9e7f80feaa | ||
|
|
3d51fefeff |
@@ -64,16 +64,28 @@ of the first one that fails.
|
||||
on this machine (below), so without it an androidTest compile error is not discovered until CI.
|
||||
ktlint and detekt also cover the `test`/`androidTest` source sets that `lintDebug` skips.
|
||||
|
||||
## Instrumented tests do not run locally
|
||||
## Instrumented tests: where they actually run
|
||||
|
||||
Two independent reasons, so do not spend time on either:
|
||||
This section said the opposite until 2026-08-24, and both of its claims had been false for two
|
||||
days. Read it as the current answer, and see the git history if you need the old one.
|
||||
|
||||
- **Emulators segfault on this host.** qemu dies on every AVD. Instrumented tests run on CI or on
|
||||
the physical Pixel, never in a local emulator.
|
||||
- **The API 37 image is broken.** `android-37.0` crash-loops surfaceflinger inside its own gralloc
|
||||
mapper, so every test fails there regardless of this app. `docs/api-37-emulator-crash.md` records
|
||||
the evidence and the ruled-out fixes; CI's matrix therefore stops at API 36 even though targetSdk
|
||||
is 37. **API 37 needs a manual check on the Pixel 10 Pro XL before each release.**
|
||||
- **Local emulators work, for API 33-36.** `tools/local-emulator/run-e2e.sh` runs them on this
|
||||
host. The segfault that made this look impossible was not a broken machine: SwiftShader's Reactor
|
||||
JIT writes generated shader code onto the heap and executes it, Fedora's SELinux policy denies
|
||||
`execheap`, and qemu dies. Choosing a different renderer avoids it entirely — `-gpu host`,
|
||||
`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. Two Media3 hardware-transcode
|
||||
tests fail inside the emulator's own `c2.goldfish.h264.decoder` rather than on anything this app
|
||||
does; they carry `@FailsOnEmulatorApi37` and run in a separate `continue-on-error` job,
|
||||
`E2E API 37 Media3 hardware transcode (advisory)`. The gating leg runs the other 55.
|
||||
**That advisory job is red on every PR, by design** — do not read it as your change breaking
|
||||
something, and do not read a green run as evidence those two tests pass.
|
||||
`docs/api-37-emulator-crash.md` has the measurements.
|
||||
|
||||
Still true, and the reason the advisory job is not simply deleted: **API 37 needs a manual check on
|
||||
the Pixel 10 Pro XL before each release.** The advisory pair is the one thing CI cannot answer for.
|
||||
|
||||
On a device or emulator, build only the ABI it can execute:
|
||||
|
||||
@@ -96,12 +108,26 @@ install for code that can never run — and on API 37 the full APK does not fit
|
||||
- The `model` package is excluded from `ReturnCount` and `CyclomaticComplexMethod` only. It is the
|
||||
decision layer, where one branch is one documented user-visible outcome and the metric counts
|
||||
answers rather than complexity. Every other rule still applies there.
|
||||
- **Coverage is reported, not gated** — **29.8% of lines (629/2113), 28.7% of branches**, measured
|
||||
on `main` 2026-08-23 with `./gradlew :app:jacocoTestReport`. A floor needs a baseline that has
|
||||
settled first, and this one has not: the figure **fell** from the ~31% recorded earlier even
|
||||
though the JVM suite went from 11 test files to 43. Main source grew 4,114 -> 5,715 lines over
|
||||
the same period, so the denominator outran the numerator. Re-measure before quoting it; do not
|
||||
assume more tests means a higher percentage here.
|
||||
- **Coverage is reported, not gated** — **69.2% of lines (1519/2194), 53.2% of branches**,
|
||||
measured 2026-08-24 with `./gradlew :app:jacocoTestReport`.
|
||||
|
||||
**Every figure this file carried before that date was an artifact, roughly half the real one.**
|
||||
Robolectric loads classes through its own sandbox classloader with no source location, JaCoCo
|
||||
skips no-location classes by default, and nothing told it otherwise — so **not one Robolectric
|
||||
test counted**, and Robolectric is what exercises the framework edge here. The
|
||||
`isIncludeNoLocationClasses` block in `app/build.gradle.kts` is what fixes it; **do not delete
|
||||
it as stray config**, and re-run the numbers if you ever touch it. Same commit, same 335 tests:
|
||||
29.7% -> 69.2% with that block alone.
|
||||
|
||||
The old entry also explained the wrong thing. It said coverage **fell** as the suite grew from 11
|
||||
test files to 43 because "the denominator outran the numerator" on framework-edge code "the JVM
|
||||
cannot reach". The JVM reaches that code fine. What actually happened is that the new tests were
|
||||
disproportionately Robolectric, so each one added denominator and no numerator — the measurement
|
||||
was punishing exactly the tests that were hardest to write.
|
||||
|
||||
Two things still hold. A floor needs a baseline that has settled, and this one has now moved by
|
||||
39 points in a single build change, so it has not. And **re-measure before quoting** — that
|
||||
instruction is the only reason this was caught.
|
||||
- **Testable code is not done until it is tested.** If a piece is unit testable, it gets unit
|
||||
tests before it counts as done. If it is e2e testable, it gets e2e tests. Both clauses apply —
|
||||
a change that is both needs both.
|
||||
@@ -112,9 +138,9 @@ install for code that can never run — and on API 37 the full APK does not fit
|
||||
documents the reasoning — turns "needs a device" into "a pure function plus a thin edge".
|
||||
Robolectric is in the JVM source set, `compose-ui-test-junit4` with it, so Compose screens are
|
||||
unit testable too. Reach for the seam before concluding something cannot be unit tested.
|
||||
- **E2E is runnable locally now.** `tools/local-emulator/run-e2e.sh` runs API 33-36 on this
|
||||
machine; see `docs/local-emulator.md`. That was believed impossible until the SELinux/renderer
|
||||
cause was found, and it is what makes the e2e half of this norm enforceable.
|
||||
- **E2E is runnable locally**, API 33-36, via `tools/local-emulator/run-e2e.sh` — see
|
||||
"Instrumented tests: where they actually run" above. That was believed impossible until the
|
||||
SELinux/renderer cause was found, and it is what makes the e2e half of this norm enforceable.
|
||||
- **A test has to bite.** Revert the line it covers, confirm it goes red, restore. A review of
|
||||
this codebase ran 46 mutations against a 257-test suite and **9 were vacuous** — five of them
|
||||
passing the whole suite over a completely unguarded code path. Green is not evidence.
|
||||
|
||||
@@ -188,6 +188,30 @@ detekt {
|
||||
}
|
||||
|
||||
// Pin the coverage agent rather than inheriting whatever Gradle bundles.
|
||||
// Robolectric loads every class it touches through its own sandbox classloader, and those
|
||||
// classes arrive with no source location. JaCoCo skips no-location classes by default, so
|
||||
// without this block **not one Robolectric test counts** -- and Robolectric is what exercises
|
||||
// the framework edge here: the workers, the publisher, both ViewModels, every Compose screen.
|
||||
//
|
||||
// Measured on e06b082, same 335 tests, same 0 failures, only this block added:
|
||||
//
|
||||
// LINE 29.7% -> 69.2% OutputPublisher 0.0% -> 97.5%
|
||||
// BRANCH 29.8% -> 53.2% ConversionViewModel 0.0% -> 85.4%
|
||||
//
|
||||
// The discriminator, if this ever looks like superstition: inside ConverterScreenKt, `describe`
|
||||
// is the one non-Composable and is exercised by a plain JVM test -- it reported 8/8 covered while
|
||||
// every @Composable in the same class reported 0, including ones whose mutations demonstrably
|
||||
// failed the build when reverted.
|
||||
//
|
||||
// `excludes` is not optional. Without it JaCoCo walks JDK-internal classes that Robolectric has
|
||||
// no location for either, and the test JVM dies rather than reporting a number.
|
||||
tasks.withType<Test>().configureEach {
|
||||
extensions.configure<JacocoTaskExtension> {
|
||||
isIncludeNoLocationClasses = true
|
||||
excludes = listOf("jdk.internal.*")
|
||||
}
|
||||
}
|
||||
|
||||
jacoco {
|
||||
toolVersion = libs.versions.jacoco.get()
|
||||
}
|
||||
|
||||
@@ -87,6 +87,89 @@ fun ConverterScreen(modifier: Modifier = Modifier, viewModel: ConversionViewMode
|
||||
ActivityResultContracts.RequestPermission(),
|
||||
) { viewModel.convert() }
|
||||
|
||||
ConverterScreenContent(
|
||||
state = state,
|
||||
settings = settings,
|
||||
validation = validation,
|
||||
actions = ConverterActions(
|
||||
onPickInput = { pickInput.launch(arrayOf("*/*")) },
|
||||
onPreset = viewModel::setPreset,
|
||||
onContainer = viewModel::setContainer,
|
||||
onVideoCodec = viewModel::setVideoCodec,
|
||||
onAudioCodec = viewModel::setAudioCodec,
|
||||
onSuggestion = viewModel::applySuggestion,
|
||||
onQuality = viewModel::setQuality,
|
||||
onEnginePreference = viewModel::setEnginePreference,
|
||||
onConvert = { requestNotifications.launch(Manifest.permission.POST_NOTIFICATIONS) },
|
||||
onCancel = viewModel::cancel,
|
||||
onSave = { suggestedName -> chooseDestination.launch(suggestedName) },
|
||||
onReset = viewModel::reset,
|
||||
),
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything [ConverterScreenContent] can ask for, in one value.
|
||||
*
|
||||
* A holder rather than twelve parameters because detekt's `LongParameterList` sits at its default
|
||||
* threshold of six and `config/detekt/detekt.yml` does not relax it for `@Composable` the way it
|
||||
* relaxes `LongMethod` and `CyclomaticComplexMethod` -- `AdvancedPicker` already sits exactly on
|
||||
* that threshold. The rule exempts data classes, so the callbacks travel together.
|
||||
*
|
||||
* In production every one of these is a launcher or a `ConversionViewModel` call. Naming them here
|
||||
* instead of handing the content a ViewModel is the whole point of the seam: a test can render a
|
||||
* [ConversionState] no ViewModel can be driven into, since `Waiting` needs a denied foreground
|
||||
* start and `Converted` needs a worker run that has already succeeded.
|
||||
*/
|
||||
internal data class ConverterActions(
|
||||
/** Open the document picker. The `Idle` and `Ready` branches both offer it. */
|
||||
val onPickInput: () -> Unit,
|
||||
val onPreset: (OutputFormat) -> Unit,
|
||||
val onContainer: (Container) -> Unit,
|
||||
val onVideoCodec: (VideoCodec) -> Unit,
|
||||
val onAudioCodec: (AudioCodec) -> Unit,
|
||||
val onSuggestion: (OutputSpec) -> Unit,
|
||||
val onQuality: (QualityTier) -> Unit,
|
||||
val onEnginePreference: (EnginePreference) -> Unit,
|
||||
/**
|
||||
* Start the job. It asks for the notification permission first, which is why the screen never
|
||||
* calls `convert` directly -- the launcher's result callback does, whichever way it went.
|
||||
*/
|
||||
val onConvert: () -> Unit,
|
||||
val onCancel: () -> Unit,
|
||||
/**
|
||||
* Open the save dialog for the finished output.
|
||||
*
|
||||
* Takes the suggested name rather than reading it back off the state, because the name comes
|
||||
* from the job -- see `ConversionWorker.KEY_SUGGESTED_NAME` -- and the branch that renders the
|
||||
* button is the only place that has it.
|
||||
*/
|
||||
val onSave: (suggestedName: String) -> Unit,
|
||||
val onReset: () -> Unit,
|
||||
)
|
||||
|
||||
/**
|
||||
* The converter screen, with its state handed in.
|
||||
*
|
||||
* Split from [ConverterScreen] so that state has somewhere to come from other than a live
|
||||
* `ConversionViewModel`. Driving the screen through a real one needs a `WorkManager` and a media
|
||||
* probe in the constructor, and even then two of the six states are unreachable: `Waiting` follows
|
||||
* a denied foreground start and `Converted` follows a completed worker.
|
||||
*
|
||||
* `internal` rather than private, because `src/test` is a friend of `main` and this is what the
|
||||
* state tests compose. The leaves below stay exactly where they were -- this function is a move,
|
||||
* not a redesign, and the tests that already pin those leaves are what says so.
|
||||
*/
|
||||
@UnstableApi
|
||||
@Composable
|
||||
internal fun ConverterScreenContent(
|
||||
state: ConversionState,
|
||||
settings: ConversionSettings,
|
||||
validation: Validation,
|
||||
actions: ConverterActions,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
@@ -115,7 +198,7 @@ fun ConverterScreen(modifier: Modifier = Modifier, viewModel: ConversionViewMode
|
||||
modifier = Modifier.padding(bottom = 16.dp),
|
||||
)
|
||||
Button(
|
||||
onClick = { pickInput.launch(arrayOf("*/*")) },
|
||||
onClick = actions.onPickInput,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(PrimaryButtonHeight)
|
||||
@@ -132,21 +215,19 @@ fun ConverterScreen(modifier: Modifier = Modifier, viewModel: ConversionViewMode
|
||||
|
||||
is ConversionState.Ready -> {
|
||||
FileCard(s.input)
|
||||
FormatPicker(settings.matchingPreset, viewModel::setPreset)
|
||||
FormatPicker(settings.matchingPreset, actions.onPreset)
|
||||
AdvancedPicker(
|
||||
spec = settings.spec,
|
||||
validation = validation,
|
||||
onContainer = viewModel::setContainer,
|
||||
onVideoCodec = viewModel::setVideoCodec,
|
||||
onAudioCodec = viewModel::setAudioCodec,
|
||||
onSuggestion = viewModel::applySuggestion,
|
||||
onContainer = actions.onContainer,
|
||||
onVideoCodec = actions.onVideoCodec,
|
||||
onAudioCodec = actions.onAudioCodec,
|
||||
onSuggestion = actions.onSuggestion,
|
||||
)
|
||||
QualityPicker(settings.quality, viewModel::setQuality)
|
||||
EnginePicker(settings.enginePreference, viewModel::setEnginePreference)
|
||||
QualityPicker(settings.quality, actions.onQuality)
|
||||
EnginePicker(settings.enginePreference, actions.onEnginePreference)
|
||||
Button(
|
||||
onClick = {
|
||||
requestNotifications.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
},
|
||||
onClick = actions.onConvert,
|
||||
// The Advanced picker lets an impossible combination be selected on
|
||||
// purpose, so this is what stops it from being run.
|
||||
enabled = validation.isValid,
|
||||
@@ -156,7 +237,7 @@ fun ConverterScreen(modifier: Modifier = Modifier, viewModel: ConversionViewMode
|
||||
.testTag(TestTags.Converter.CONVERT),
|
||||
) { Text("Convert") }
|
||||
OutlinedButton(
|
||||
onClick = { pickInput.launch(arrayOf("*/*")) },
|
||||
onClick = actions.onPickInput,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag(TestTags.Converter.CHOOSE_DIFFERENT_FILE),
|
||||
@@ -173,7 +254,7 @@ fun ConverterScreen(modifier: Modifier = Modifier, viewModel: ConversionViewMode
|
||||
.testTag(TestTags.Converter.PROGRESS),
|
||||
)
|
||||
OutlinedButton(
|
||||
onClick = viewModel::cancel,
|
||||
onClick = actions.onCancel,
|
||||
modifier = Modifier.fillMaxWidth().testTag(TestTags.CANCEL),
|
||||
) { Text("Cancel") }
|
||||
}
|
||||
@@ -192,7 +273,7 @@ fun ConverterScreen(modifier: Modifier = Modifier, viewModel: ConversionViewMode
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
OutlinedButton(
|
||||
onClick = viewModel::cancel,
|
||||
onClick = actions.onCancel,
|
||||
modifier = Modifier.fillMaxWidth().testTag(TestTags.CANCEL),
|
||||
) { Text("Cancel") }
|
||||
}
|
||||
@@ -208,17 +289,21 @@ fun ConverterScreen(modifier: Modifier = Modifier, viewModel: ConversionViewMode
|
||||
// explains why a job was slow, makes the software fallback
|
||||
// visible, and is how the user learns a remux happened rather
|
||||
// than a re-encode.
|
||||
AssistChip(onClick = {}, label = { Text(s.routeReason) })
|
||||
AssistChip(
|
||||
onClick = {},
|
||||
label = { Text(s.routeReason) },
|
||||
modifier = Modifier.testTag(TestTags.Converter.ROUTE_REASON),
|
||||
)
|
||||
}
|
||||
Button(
|
||||
onClick = { chooseDestination.launch(s.suggestedName) },
|
||||
onClick = { actions.onSave(s.suggestedName) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(PrimaryButtonHeight)
|
||||
.testTag(TestTags.SAVE_FILE),
|
||||
) { Text("Save file") }
|
||||
OutlinedButton(
|
||||
onClick = viewModel::reset,
|
||||
onClick = actions.onReset,
|
||||
modifier = Modifier.fillMaxWidth().testTag(TestTags.START_OVER),
|
||||
) { Text("Start over") }
|
||||
}
|
||||
@@ -226,7 +311,7 @@ fun ConverterScreen(modifier: Modifier = Modifier, viewModel: ConversionViewMode
|
||||
is ConversionState.Saved -> {
|
||||
Text("Saved ${s.displayName}.", style = MaterialTheme.typography.bodyLarge)
|
||||
Button(
|
||||
onClick = viewModel::reset,
|
||||
onClick = actions.onReset,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(PrimaryButtonHeight)
|
||||
@@ -241,7 +326,7 @@ fun ConverterScreen(modifier: Modifier = Modifier, viewModel: ConversionViewMode
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Button(
|
||||
onClick = viewModel::reset,
|
||||
onClick = actions.onReset,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(PrimaryButtonHeight)
|
||||
|
||||
@@ -53,6 +53,46 @@ fun JoinScreen(modifier: Modifier = Modifier, viewModel: JoinViewModel = viewMod
|
||||
remember(destinationMime) { ActivityResultContracts.CreateDocument(destinationMime) },
|
||||
) { uri -> uri?.let(viewModel::save) }
|
||||
|
||||
JoinScreenContent(
|
||||
state = state,
|
||||
actions = JoinActions(
|
||||
onPickInputs = { pickInputs.launch(arrayOf("video/*")) },
|
||||
onJoin = viewModel::join,
|
||||
onCancel = viewModel::cancel,
|
||||
onSave = { suggestedName -> chooseDestination.launch(suggestedName) },
|
||||
onReset = viewModel::reset,
|
||||
),
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything [JoinScreenContent] can ask for, in one value.
|
||||
*
|
||||
* Five callbacks would fit under detekt's `LongParameterList` threshold, unlike the converter's
|
||||
* twelve. It is a holder anyway, so both screens present the same shape to the state tests and
|
||||
* neither one has to be reworked the first time a branch grows a button.
|
||||
*/
|
||||
internal data class JoinActions(
|
||||
/** Open the multi-document picker. The `Idle` and `Ready` branches both offer it. */
|
||||
val onPickInputs: () -> Unit,
|
||||
val onJoin: () -> Unit,
|
||||
val onCancel: () -> Unit,
|
||||
/** Open the save dialog. Takes the name the job chose -- see `ConcatWorker.KEY_SUGGESTED_NAME`. */
|
||||
val onSave: (suggestedName: String) -> Unit,
|
||||
val onReset: () -> Unit,
|
||||
)
|
||||
|
||||
/**
|
||||
* The join screen, with its state handed in.
|
||||
*
|
||||
* The same split as [org.libremediaconverter.convert.ConverterScreenContent], for the same reason:
|
||||
* `JoinState.Waiting` follows a denied foreground start and `JoinState.Joined` follows a completed
|
||||
* concatenation, so neither is reachable by driving a real `JoinViewModel`.
|
||||
*/
|
||||
@UnstableApi
|
||||
@Composable
|
||||
internal fun JoinScreenContent(state: JoinState, actions: JoinActions, modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
@@ -81,7 +121,7 @@ fun JoinScreen(modifier: Modifier = Modifier, viewModel: JoinViewModel = viewMod
|
||||
modifier = Modifier.padding(bottom = 16.dp),
|
||||
)
|
||||
Button(
|
||||
onClick = { pickInputs.launch(arrayOf("video/*")) },
|
||||
onClick = actions.onPickInputs,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(PrimaryButtonHeight)
|
||||
@@ -99,14 +139,14 @@ fun JoinScreen(modifier: Modifier = Modifier, viewModel: JoinViewModel = viewMod
|
||||
is JoinState.Ready -> {
|
||||
s.inputs.forEach { FileRow(it) }
|
||||
Button(
|
||||
onClick = viewModel::join,
|
||||
onClick = actions.onJoin,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(PrimaryButtonHeight)
|
||||
.testTag(TestTags.Join.JOIN),
|
||||
) { Text("Join ${s.inputs.size} files") }
|
||||
OutlinedButton(
|
||||
onClick = { pickInputs.launch(arrayOf("video/*")) },
|
||||
onClick = actions.onPickInputs,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag(TestTags.Join.CHOOSE_DIFFERENT_FILES),
|
||||
@@ -124,7 +164,7 @@ fun JoinScreen(modifier: Modifier = Modifier, viewModel: JoinViewModel = viewMod
|
||||
.testTag(TestTags.Join.PROGRESS),
|
||||
)
|
||||
OutlinedButton(
|
||||
onClick = viewModel::cancel,
|
||||
onClick = actions.onCancel,
|
||||
modifier = Modifier.fillMaxWidth().testTag(TestTags.CANCEL),
|
||||
) { Text("Cancel") }
|
||||
}
|
||||
@@ -138,7 +178,7 @@ fun JoinScreen(modifier: Modifier = Modifier, viewModel: JoinViewModel = viewMod
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
OutlinedButton(
|
||||
onClick = viewModel::cancel,
|
||||
onClick = actions.onCancel,
|
||||
modifier = Modifier.fillMaxWidth().testTag(TestTags.CANCEL),
|
||||
) { Text("Cancel") }
|
||||
}
|
||||
@@ -157,14 +197,14 @@ fun JoinScreen(modifier: Modifier = Modifier, viewModel: JoinViewModel = viewMod
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
Button(
|
||||
onClick = { chooseDestination.launch(s.suggestedName) },
|
||||
onClick = { actions.onSave(s.suggestedName) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(PrimaryButtonHeight)
|
||||
.testTag(TestTags.SAVE_FILE),
|
||||
) { Text("Save file") }
|
||||
OutlinedButton(
|
||||
onClick = viewModel::reset,
|
||||
onClick = actions.onReset,
|
||||
modifier = Modifier.fillMaxWidth().testTag(TestTags.START_OVER),
|
||||
) { Text("Start over") }
|
||||
}
|
||||
@@ -172,7 +212,7 @@ fun JoinScreen(modifier: Modifier = Modifier, viewModel: JoinViewModel = viewMod
|
||||
is JoinState.Saved -> {
|
||||
Text("Saved ${s.displayName}.", style = MaterialTheme.typography.bodyLarge)
|
||||
Button(
|
||||
onClick = viewModel::reset,
|
||||
onClick = actions.onReset,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(PrimaryButtonHeight)
|
||||
@@ -187,7 +227,7 @@ fun JoinScreen(modifier: Modifier = Modifier, viewModel: JoinViewModel = viewMod
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Button(
|
||||
onClick = viewModel::reset,
|
||||
onClick = actions.onReset,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(PrimaryButtonHeight)
|
||||
|
||||
@@ -55,6 +55,15 @@ object TestTags {
|
||||
/** The determinate bar in `Converting`. It carries no text, so nothing else can find it. */
|
||||
const val PROGRESS: String = "converter.progress"
|
||||
|
||||
/**
|
||||
* The chip on `Converted` that says which engine ran the job and why.
|
||||
*
|
||||
* Conditional on `routeReason` being non-blank, and that condition is what the tag is for:
|
||||
* its text comes from the finished job, so a text matcher looking for it would have to
|
||||
* name a routing explanation the screen does not own.
|
||||
*/
|
||||
const val ROUTE_REASON: String = "converter.routeReason"
|
||||
|
||||
const val FILE_CARD: String = "converter.fileCard"
|
||||
const val FILE_CARD_NAME: String = "converter.fileCard.name"
|
||||
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
package org.libremediaconverter.convert
|
||||
|
||||
import android.net.Uri
|
||||
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 org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.createDrainedComposeRule
|
||||
import org.libremediaconverter.model.Validation
|
||||
import org.libremediaconverter.ui.TestTags
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* The seam carries a `ConversionState` in and an action back out.
|
||||
*
|
||||
* The defect this bites on is the extraction having quietly stopped being an extraction: a
|
||||
* `ConverterScreenContent` that ignores the `state` it was handed, or renders the finished job's
|
||||
* affordances without wiring them to the callbacks the entry point supplies. Neither shows up at
|
||||
* compile time -- an unread parameter compiles, and a `Button` whose `onClick` does nothing is a
|
||||
* valid `Button` -- and neither is visible from the leaf tests, which compose `FileCard`,
|
||||
* `AdvancedPicker` and the pickers directly and never see a state at all.
|
||||
*
|
||||
* **Both assertions were unreachable before R38.5**, which is the point of the ticket rather than
|
||||
* a remark about it. `ConversionState.Converted` is produced only by a `ConversionWorker` run that
|
||||
* has already succeeded, so no test can drive a real `ConversionViewModel` into it: it would need
|
||||
* a `WorkManager`, a media probe, a staged output file and a completed job. Handing the state in
|
||||
* is the only way to ask what the screen does with it.
|
||||
*
|
||||
* Deliberately not the state matrix. Which affordances each of the six `ConversionState`s renders
|
||||
* is R38.6 (#62); this file asserts only that the injection point exists and works in both
|
||||
* directions, so the two PRs cannot collide over the same cases.
|
||||
*/
|
||||
@UnstableApi
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class ConverterScreenContentTest {
|
||||
|
||||
// Not `createComposeRule()` directly: see [org.libremediaconverter.drainEscapedCoroutineErrors].
|
||||
@get:Rule
|
||||
val composeRule = createDrainedComposeRule()
|
||||
|
||||
/** What the screen asked to save, in the order it asked. Empty until Save is tapped. */
|
||||
private val savedAs = mutableListOf<String>()
|
||||
|
||||
@Test
|
||||
fun `a converted job renders the save button`() {
|
||||
setContent(converted())
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.SAVE_FILE).assertExists()
|
||||
}
|
||||
|
||||
/**
|
||||
* The direction that did not exist before this change.
|
||||
*
|
||||
* Asserting the *name* rather than just that something was called: the suggested name comes
|
||||
* from the job -- `ConversionWorker.KEY_SUGGESTED_NAME` -- and is what the save dialog opens
|
||||
* with, so a Save button wired to the wrong branch's state would hand over the wrong one and
|
||||
* a bare "was called" check would stay green.
|
||||
*/
|
||||
@Test
|
||||
fun `tapping save hands back the name the finished job chose`() {
|
||||
setContent(converted())
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.SAVE_FILE).performScrollTo().performClick()
|
||||
|
||||
assertEquals(listOf("holiday.mp4"), savedAs)
|
||||
}
|
||||
|
||||
/**
|
||||
* `staged` names a file that does not exist, on purpose.
|
||||
*
|
||||
* The branch renders `formatBytes(s.staged.length())`, and `length()` answers `0L` for a
|
||||
* missing path rather than throwing, so the size line reads `0 B` and no temporary folder is
|
||||
* needed. `routeReason` stays blank, which is what keeps the routing chip out of the tree --
|
||||
* that chip is R38.6's case, not this file's.
|
||||
*/
|
||||
private fun converted() = ConversionState.Converted(
|
||||
input = InputFile(
|
||||
uri = Uri.parse("content://test/holiday.mkv"),
|
||||
displayName = "holiday.mkv",
|
||||
sizeBytes = 12_345_678L,
|
||||
),
|
||||
staged = File("no-such-staged-output.mp4"),
|
||||
suggestedName = "holiday.mp4",
|
||||
mimeType = "video/mp4",
|
||||
)
|
||||
|
||||
private fun setContent(state: ConversionState) {
|
||||
composeRule.setContent {
|
||||
ConverterScreenContent(
|
||||
state = state,
|
||||
settings = ConversionSettings(),
|
||||
validation = Validation.Valid,
|
||||
actions = ConverterActions(
|
||||
onPickInput = {},
|
||||
onPreset = {},
|
||||
onContainer = {},
|
||||
onVideoCodec = {},
|
||||
onAudioCodec = {},
|
||||
onSuggestion = {},
|
||||
onQuality = {},
|
||||
onEnginePreference = {},
|
||||
onConvert = {},
|
||||
onCancel = {},
|
||||
onSave = { suggestedName -> savedAs += suggestedName },
|
||||
onReset = {},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
package org.libremediaconverter.convert
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.ui.semantics.ProgressBarRangeInfo
|
||||
import androidx.compose.ui.test.assertIsEnabled
|
||||
import androidx.compose.ui.test.assertIsNotEnabled
|
||||
import androidx.compose.ui.test.assertRangeInfoEquals
|
||||
import androidx.compose.ui.test.assertTextEquals
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.createDrainedComposeRule
|
||||
import org.libremediaconverter.model.AudioCodec
|
||||
import org.libremediaconverter.model.Container
|
||||
import org.libremediaconverter.model.OutputSpec
|
||||
import org.libremediaconverter.model.Validation
|
||||
import org.libremediaconverter.model.VideoCodec
|
||||
import org.libremediaconverter.ui.TestTags
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Every `ConversionState` renders its own affordances, and only its own.
|
||||
*
|
||||
* The defect this bites on is a `when` arm that has drifted from the state it names: a button
|
||||
* offered in a state where it cannot work, a state's own data never reaching the node that is
|
||||
* supposed to display it, or an affordance wired to the wrong callback. None of that is a compile
|
||||
* error -- every arm of the `when` returns `Unit`, so an arm can render anything at all -- and none
|
||||
* of it is visible from the leaf tests, which compose `FileCard`, `AdvancedPicker` and the three
|
||||
* pickers directly and never see a `ConversionState`.
|
||||
*
|
||||
* The arm most worth guarding is `Ready`'s `enabled = validation.isValid`. The Advanced picker
|
||||
* deliberately lets an impossible container / codec combination be selected -- `AdvancedPicker`'s
|
||||
* KDoc says teaching the constraint beats hiding it -- so that single expression is the only thing
|
||||
* standing between an invalid spec and a job that cannot succeed. `enabled = true` compiles, renders
|
||||
* an identical screen apart from one colour, and passes every other test in this suite.
|
||||
*
|
||||
* Callbacks are asserted by **identity, over the whole log**: [fired] records all twelve of them and
|
||||
* each assertion compares the complete list against one expected entry. A bare "the callback ran"
|
||||
* check stays green when an arm fires the right callback for the wrong reason, and a check on one
|
||||
* callback alone stays green when an arm fires two.
|
||||
*
|
||||
* ### Not asserted here, so that each is a decision rather than an omission
|
||||
*
|
||||
* - **`Failed`'s error colour.** #62's table asks for the message "in the error colour". Compose
|
||||
* publishes no text colour to the semantics tree -- there is no `SemanticsProperties` entry for
|
||||
* it -- so it is unobservable from a JVM test, the same limit `FileCardTest` records for
|
||||
* `HorizontalDivider`. The message text itself is asserted; the colour would need a screenshot.
|
||||
* - **The three `assertDoesNotExist` checks on [TestTags.Converter.FILE_CARD] are compile-guarded,
|
||||
* not guarded by this file.** `Idle` is a `data object`, and `Saved` and `Failed` carry only a
|
||||
* `displayName` and a `message`; none of the three has an `input`, so `FileCard(s.input)` does not
|
||||
* compile in those arms. The lines stay because they state the intent cheaply, but they are not
|
||||
* what stops a `FileCard` appearing there and this file does not claim they are.
|
||||
* - **Which constant each chip hands back** belongs to `ConverterPickerSelectionTest`, and **what
|
||||
* the file card says about an unknown size** to `FileCardTest`. This file asserts that `Ready`
|
||||
* puts those leaves on screen at all, not what they then do.
|
||||
* - **The suggested name `Converted` hands to the save dialog** is pinned by
|
||||
* `ConverterScreenContentTest`; repeating it here would be a second copy of one assertion.
|
||||
* - **`ConverterScreen`'s permission dance.** `requestNotifications` calls `convert()` on both grant
|
||||
* and deny, deliberately -- the KDoc explains that the foreground service runs either way -- and
|
||||
* it lives in the entry point, above the seam this file composes.
|
||||
* - **`is ConversionState.Idle -> Unit` in the nested `when`.** The outer `when` peels `Idle` off
|
||||
* first, so that arm is permanently unreachable and no test can reach it.
|
||||
*/
|
||||
@UnstableApi
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class ConverterStateAffordancesTest {
|
||||
|
||||
// Not `createComposeRule()` directly: see [org.libremediaconverter.drainEscapedCoroutineErrors].
|
||||
@get:Rule
|
||||
val composeRule = createDrainedComposeRule()
|
||||
|
||||
/**
|
||||
* Every callback the screen fired, in order, tagged with the value it carried.
|
||||
*
|
||||
* All twelve are recorded rather than only the one under test, so an assertion can be
|
||||
* `assertEquals(listOf("cancel"), fired)` -- which says "this one and nothing else".
|
||||
*/
|
||||
private val fired = mutableListOf<String>()
|
||||
|
||||
// -------------------------------------------------------------------- Idle
|
||||
|
||||
@Test
|
||||
fun `an idle screen offers the prompt and the picker, and nothing to act on yet`() {
|
||||
setContent(ConversionState.Idle)
|
||||
|
||||
composeRule.onNodeWithText("Pick a file to convert.").assertExists()
|
||||
composeRule.onNodeWithTag(TestTags.Converter.CHOOSE_FILE).assertExists()
|
||||
composeRule.onNodeWithTag(TestTags.Converter.CONVERT).assertDoesNotExist()
|
||||
composeRule.onNodeWithTag(TestTags.CANCEL).assertDoesNotExist()
|
||||
// Compile-guarded rather than guarded here -- `Idle` has no `input`. See the class KDoc.
|
||||
composeRule.onNodeWithTag(TestTags.Converter.FILE_CARD).assertDoesNotExist()
|
||||
}
|
||||
|
||||
/**
|
||||
* No [performScrollTo] on this one, unlike every other click below. `Idle` is the centred
|
||||
* branch outside the `verticalScroll` column, so it has no scrollable ancestor to scroll in.
|
||||
*/
|
||||
@Test
|
||||
fun `tapping choose file on an idle screen asks for a file and does nothing else`() {
|
||||
setContent(ConversionState.Idle)
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.Converter.CHOOSE_FILE).performClick()
|
||||
|
||||
assertEquals(listOf("pickInput"), fired)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------- Ready
|
||||
|
||||
/**
|
||||
* All four pickers, the card above them and both buttons below, in one assertion each.
|
||||
*
|
||||
* A superset of #62's "all five pickers": which four or five of these count as a picker is not
|
||||
* worth arguing about, so the case names everything the arm emits.
|
||||
*/
|
||||
@Test
|
||||
fun `a picked file offers its card, all four pickers and both buttons`() {
|
||||
setContent(ConversionState.Ready(input()))
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.Converter.FILE_CARD).assertExists()
|
||||
composeRule.onNodeWithTag(TestTags.Converter.FORMAT_CHIPS).assertExists()
|
||||
composeRule.onNodeWithTag(TestTags.Converter.ADVANCED_TOGGLE).assertExists()
|
||||
composeRule.onNodeWithTag(TestTags.Converter.QUALITY_CHIPS).assertExists()
|
||||
composeRule.onNodeWithTag(TestTags.Converter.ENGINE_CHIPS).assertExists()
|
||||
composeRule.onNodeWithTag(TestTags.Converter.CONVERT).assertExists()
|
||||
composeRule.onNodeWithTag(TestTags.Converter.CHOOSE_DIFFERENT_FILE).assertExists()
|
||||
}
|
||||
|
||||
/** The card is handed `s.input`, so the name on it is how the state is shown to have arrived. */
|
||||
@Test
|
||||
fun `the file card on a picked file names the file that was picked`() {
|
||||
setContent(ConversionState.Ready(input()))
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.Converter.FILE_CARD_NAME).assertTextEquals("holiday.mkv")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `convert is offered for a spec that can be produced`() {
|
||||
setContent(ConversionState.Ready(input()), validation = Validation.Valid)
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.Converter.CONVERT).assertIsEnabled()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tapping convert starts the job and does nothing else`() {
|
||||
setContent(ConversionState.Ready(input()), validation = Validation.Valid)
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.Converter.CONVERT).performScrollTo().performClick()
|
||||
|
||||
assertEquals(listOf("convert"), fired)
|
||||
}
|
||||
|
||||
/**
|
||||
* The bite named in #62. Reverting `enabled = validation.isValid` to `enabled = true` reddens
|
||||
* exactly this case, and nothing else in the repository.
|
||||
*/
|
||||
@Test
|
||||
fun `convert is withheld for a spec that cannot be produced`() {
|
||||
setContent(ConversionState.Ready(input()), validation = INVALID)
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.Converter.CONVERT).assertIsNotEnabled()
|
||||
}
|
||||
|
||||
/** The other button on the arm goes back to the picker rather than starting anything. */
|
||||
@Test
|
||||
fun `tapping choose a different file asks for a file rather than converting`() {
|
||||
setContent(ConversionState.Ready(input()))
|
||||
|
||||
composeRule
|
||||
.onNodeWithTag(TestTags.Converter.CHOOSE_DIFFERENT_FILE)
|
||||
.performScrollTo()
|
||||
.performClick()
|
||||
|
||||
assertEquals(listOf("pickInput"), fired)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- Converting
|
||||
|
||||
/**
|
||||
* Two independent readings of the same `percent`, on purpose.
|
||||
*
|
||||
* The heading is a string and the bar is a float, and the arm computes them from the state
|
||||
* separately -- `"${s.percent}%"` against `s.percent / 100f`. A hardcoded bar and a hardcoded
|
||||
* heading are different mistakes, so neither assertion covers the other.
|
||||
*/
|
||||
@Test
|
||||
fun `a running job reports how far it has got, in words and on the bar`() {
|
||||
setContent(ConversionState.Converting(input(), percent = 42))
|
||||
|
||||
composeRule.onNodeWithText("Converting… 42%").assertExists()
|
||||
composeRule
|
||||
.onNodeWithTag(TestTags.Converter.PROGRESS)
|
||||
.assertRangeInfoEquals(ProgressBarRangeInfo(0.42f, 0f..1f))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a running job offers cancel and not start over`() {
|
||||
setContent(ConversionState.Converting(input(), percent = 42))
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.CANCEL).assertExists()
|
||||
composeRule.onNodeWithTag(TestTags.START_OVER).assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tapping cancel on a running job cancels it and does nothing else`() {
|
||||
setContent(ConversionState.Converting(input(), percent = 42))
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.CANCEL).performScrollTo().performClick()
|
||||
|
||||
assertEquals(listOf("cancel"), fired)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- Waiting
|
||||
|
||||
/**
|
||||
* The second bite named in #62. Deleting the `Cancel` button from the `Waiting` arm reddens
|
||||
* this case and the one below it.
|
||||
*
|
||||
* The paragraph is asserted in full rather than by a fragment because it is the only thing the
|
||||
* arm renders besides the card and the button, and because its wording is the arm's whole
|
||||
* job -- `FailureOutcome` records that two different causes land here and the state cannot tell
|
||||
* them apart, so the text has to cover both. A reword should redden one test, and this is it.
|
||||
*/
|
||||
@Test
|
||||
fun `a paused job explains why and still offers cancel`() {
|
||||
setContent(ConversionState.Waiting(input()))
|
||||
|
||||
composeRule.onNodeWithText(PAUSED_PARAGRAPH).assertExists()
|
||||
composeRule.onNodeWithTag(TestTags.CANCEL).assertExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tapping cancel on a paused job cancels it and does nothing else`() {
|
||||
setContent(ConversionState.Waiting(input()))
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.CANCEL).performScrollTo().performClick()
|
||||
|
||||
assertEquals(listOf("cancel"), fired)
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- Converted
|
||||
|
||||
@Test
|
||||
fun `a finished job offers save and start over, and no longer offers cancel`() {
|
||||
setContent(converted())
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.SAVE_FILE).assertExists()
|
||||
composeRule.onNodeWithTag(TestTags.START_OVER).assertExists()
|
||||
composeRule.onNodeWithTag(TestTags.CANCEL).assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tapping start over on a finished job resets and does not save`() {
|
||||
setContent(converted())
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.START_OVER).performScrollTo().performClick()
|
||||
|
||||
assertEquals(listOf("reset"), fired)
|
||||
}
|
||||
|
||||
/**
|
||||
* The chip carries the job's own explanation, so its text is the assertion rather than its
|
||||
* presence: a chip showing the engine name, or the previous job's reason, would still exist.
|
||||
*/
|
||||
@Test
|
||||
fun `a finished job shows the routing decision the job reported`() {
|
||||
setContent(converted(routeReason = "Software — the MKV input needed a re-encode"))
|
||||
|
||||
composeRule
|
||||
.onNodeWithTag(TestTags.Converter.ROUTE_REASON)
|
||||
.assertTextEquals("Software — the MKV input needed a re-encode")
|
||||
}
|
||||
|
||||
/** The other side of the `isNotBlank` guard, which is unguarded without a case of its own. */
|
||||
@Test
|
||||
fun `a finished job that reported no routing decision shows no chip`() {
|
||||
setContent(converted(routeReason = ""))
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.Converter.ROUTE_REASON).assertDoesNotExist()
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------- Saved
|
||||
|
||||
@Test
|
||||
fun `a saved file names itself and offers another conversion`() {
|
||||
setContent(ConversionState.Saved(displayName = "holiday.mp4"))
|
||||
|
||||
composeRule.onNodeWithText("Saved holiday.mp4.").assertExists()
|
||||
composeRule.onNodeWithTag(TestTags.Converter.CONVERT_ANOTHER).assertExists()
|
||||
// Compile-guarded rather than guarded here -- `Saved` has no `input`. See the class KDoc.
|
||||
composeRule.onNodeWithTag(TestTags.Converter.FILE_CARD).assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tapping convert another after a save resets and does nothing else`() {
|
||||
setContent(ConversionState.Saved(displayName = "holiday.mp4"))
|
||||
|
||||
composeRule
|
||||
.onNodeWithTag(TestTags.Converter.CONVERT_ANOTHER)
|
||||
.performScrollTo()
|
||||
.performClick()
|
||||
|
||||
assertEquals(listOf("reset"), fired)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Failed
|
||||
|
||||
/**
|
||||
* The message is the arm's only output that carries information, and it comes from the state.
|
||||
* An arm rendering a fixed apology would look right and say nothing, which is why the assertion
|
||||
* is on the text handed in rather than on a node existing.
|
||||
*/
|
||||
@Test
|
||||
fun `a failed job renders the reason it was given and offers a restart`() {
|
||||
setContent(ConversionState.Failed(message = "Ran out of space while writing the output."))
|
||||
|
||||
composeRule.onNodeWithText("Ran out of space while writing the output.").assertExists()
|
||||
composeRule.onNodeWithTag(TestTags.START_OVER).assertExists()
|
||||
composeRule.onNodeWithTag(TestTags.SAVE_FILE).assertDoesNotExist()
|
||||
// Compile-guarded rather than guarded here -- `Failed` has no `input`. See the class KDoc.
|
||||
composeRule.onNodeWithTag(TestTags.Converter.FILE_CARD).assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tapping start over after a failure resets and does nothing else`() {
|
||||
setContent(ConversionState.Failed(message = "Ran out of space while writing the output."))
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.START_OVER).performScrollTo().performClick()
|
||||
|
||||
assertEquals(listOf("reset"), fired)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Harness
|
||||
|
||||
private fun input() = InputFile(
|
||||
uri = Uri.parse("content://test/holiday.mkv"),
|
||||
displayName = "holiday.mkv",
|
||||
sizeBytes = 12_345_678L,
|
||||
)
|
||||
|
||||
/**
|
||||
* `staged` names a path that does not exist, deliberately: `File.length()` answers `0L` for a
|
||||
* missing file rather than throwing, so the size line reads `0 B` and no temporary folder is
|
||||
* needed to render the arm.
|
||||
*/
|
||||
private fun converted(routeReason: String = "") = ConversionState.Converted(
|
||||
input = input(),
|
||||
staged = File("no-such-staged-output.mp4"),
|
||||
routeReason = routeReason,
|
||||
suggestedName = "holiday.mp4",
|
||||
mimeType = "video/mp4",
|
||||
)
|
||||
|
||||
private fun setContent(state: ConversionState, validation: Validation = Validation.Valid) {
|
||||
composeRule.setContent {
|
||||
ConverterScreenContent(
|
||||
state = state,
|
||||
settings = ConversionSettings(),
|
||||
validation = validation,
|
||||
actions = ConverterActions(
|
||||
onPickInput = { fired += "pickInput" },
|
||||
onPreset = { fired += "preset:$it" },
|
||||
onContainer = { fired += "container:$it" },
|
||||
onVideoCodec = { fired += "videoCodec:$it" },
|
||||
onAudioCodec = { fired += "audioCodec:$it" },
|
||||
onSuggestion = { fired += "suggestion:$it" },
|
||||
onQuality = { fired += "quality:$it" },
|
||||
onEnginePreference = { fired += "engine:$it" },
|
||||
onConvert = { fired += "convert" },
|
||||
onCancel = { fired += "cancel" },
|
||||
onSave = { fired += "save:$it" },
|
||||
onReset = { fired += "reset" },
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
|
||||
/**
|
||||
* A spec no container can hold, with somewhere to go instead.
|
||||
*
|
||||
* Built here rather than run through `ContainerCapabilities` because what makes a spec
|
||||
* invalid is that class's subject; all this arm needs is a `Validation` that answers
|
||||
* `isValid == false`.
|
||||
*/
|
||||
val INVALID = Validation.Invalid(
|
||||
message = "WebM cannot hold H.264 video.",
|
||||
suggestions = listOf(OutputSpec(Container.MKV, VideoCodec.H264, AudioCodec.AAC)),
|
||||
)
|
||||
|
||||
/** Copied from the `Waiting` arm, where it is written as two concatenated fragments. */
|
||||
const val PAUSED_PARAGRAPH =
|
||||
"Paused. Android limits background media processing, so this will " +
|
||||
"resume automatically — keeping the app open helps it along."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package org.libremediaconverter.join
|
||||
|
||||
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 org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.createDrainedComposeRule
|
||||
import org.libremediaconverter.model.ConcatStrategy
|
||||
import org.libremediaconverter.ui.TestTags
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* The join screen's half of the same seam, and the same two directions.
|
||||
*
|
||||
* The defect is the one `ConverterScreenContentTest` describes -- a content composable that
|
||||
* ignores the state handed to it, or renders the finished job's affordances unwired -- and it has
|
||||
* to be asked separately here because the two screens share no code. `JoinScreen` and
|
||||
* `ConverterScreen` were extracted in the same commit by the same hand, which is exactly the
|
||||
* circumstance in which one of them gets the wiring right and the other does not.
|
||||
*
|
||||
* `JoinState.Joined` is unreachable through a real `JoinViewModel` for the same reason
|
||||
* `ConversionState.Converted` is: only a `ConcatWorker` run that has already succeeded produces
|
||||
* one, carrying the strategy it chose and the name it picked.
|
||||
*
|
||||
* `JoinScreenKt` is the honest remaining coverage gap on this repo, and closing it is R38.7 (#63),
|
||||
* not this file. Which affordances each `JoinState` renders belongs there; this asserts only that
|
||||
* the injection point exists.
|
||||
*/
|
||||
@UnstableApi
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class JoinScreenContentTest {
|
||||
|
||||
// Not `createComposeRule()` directly: see [org.libremediaconverter.drainEscapedCoroutineErrors].
|
||||
@get:Rule
|
||||
val composeRule = createDrainedComposeRule()
|
||||
|
||||
/** What the screen asked to save, in the order it asked. Empty until Save is tapped. */
|
||||
private val savedAs = mutableListOf<String>()
|
||||
|
||||
@Test
|
||||
fun `a finished join renders the save button`() {
|
||||
setContent(joined())
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.SAVE_FILE).assertExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tapping save hands back the name the finished join chose`() {
|
||||
setContent(joined())
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.SAVE_FILE).performScrollTo().performClick()
|
||||
|
||||
assertEquals(listOf("joined.mp4"), savedAs)
|
||||
}
|
||||
|
||||
/** `staged` names a missing file deliberately -- see the same helper on the converter side. */
|
||||
private fun joined() = JoinState.Joined(
|
||||
staged = File("no-such-staged-output.mp4"),
|
||||
strategy = ConcatStrategy.STREAM_COPY,
|
||||
suggestedName = "joined.mp4",
|
||||
mimeType = "video/mp4",
|
||||
)
|
||||
|
||||
private fun setContent(state: JoinState) {
|
||||
composeRule.setContent {
|
||||
JoinScreenContent(
|
||||
state = state,
|
||||
actions = JoinActions(
|
||||
onPickInputs = {},
|
||||
onJoin = {},
|
||||
onCancel = {},
|
||||
onSave = { suggestedName -> savedAs += suggestedName },
|
||||
onReset = {},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
package org.libremediaconverter.join
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.ui.semantics.ProgressBarRangeInfo
|
||||
import androidx.compose.ui.semantics.SemanticsProperties
|
||||
import androidx.compose.ui.semantics.getOrNull
|
||||
import androidx.compose.ui.test.SemanticsMatcher
|
||||
import androidx.compose.ui.test.assertRangeInfoEquals
|
||||
import androidx.compose.ui.test.assertTextEquals
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.convert.InputFile
|
||||
import org.libremediaconverter.createDrainedComposeRule
|
||||
import org.libremediaconverter.model.ConcatStrategy
|
||||
import org.libremediaconverter.ui.TestTags
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Every `JoinState` renders its own affordances, wired to its own callback.
|
||||
*
|
||||
* The defect is a branch of `JoinScreenContent`'s `when` that reads the wrong thing: a count taken
|
||||
* from a literal rather than from `inputs`, a strategy line that describes the other strategy, a
|
||||
* button wired to the neighbouring branch's callback, a `Failed` that drops the message it carries.
|
||||
* None of that is visible at compile time -- every branch of the `when` type-checks against the
|
||||
* same `JoinScreenContent` signature -- and none of it is visible from the leaf tests either, which
|
||||
* compose `FileRow` on its own and never see a state.
|
||||
*
|
||||
* `JoinScreenContentTest` deliberately asks only whether the seam exists, using `Joined`. This is
|
||||
* the matrix behind it: seven states, each pinned to what it lets the user do next.
|
||||
*
|
||||
* ### Two assertions here that nothing else in the suite makes
|
||||
*
|
||||
* **Order.** A join is the one flow where the order of the inputs is the content of the output --
|
||||
* the empty state promises "in the order you want them" -- so the rows are read back sorted by
|
||||
* their position on screen and compared as a list, not as a set. `JoinLeafTagsTest` proves a row
|
||||
* tags itself with the file it shows; nothing proved the rows come out in the order they went in.
|
||||
*
|
||||
* **Indeterminate.** The join progress bar carries no percentage, on purpose: FFmpeg reports
|
||||
* progress against one input's duration, which means nothing across a concatenation. The converter
|
||||
* screen's bar is determinate, so "it has a progress bar" is the assertion that would not notice a
|
||||
* fabricated percentage arriving here.
|
||||
*
|
||||
* ### Not asserted here, deliberately
|
||||
*
|
||||
* `JoinState.Joined.mimeType` is not rendered by this composable at all -- it is read by the entry
|
||||
* point, to open the save dialog with a type that matches the finished job. The colour of the
|
||||
* `Failed` message is `MaterialTheme.colorScheme.error`, which is theme lookup rather than state
|
||||
* logic, so it is left to the eye. The `is JoinState.Idle -> Unit` arm inside the scrolling branch
|
||||
* is unreachable by construction: the outer `when` peels `Idle` off first.
|
||||
*/
|
||||
@UnstableApi
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class JoinStateAffordancesTest {
|
||||
|
||||
// Not `createComposeRule()` directly: see [org.libremediaconverter.drainEscapedCoroutineErrors].
|
||||
@get:Rule
|
||||
val composeRule = createDrainedComposeRule()
|
||||
|
||||
/** Which callback the screen invoked, in order, with what it passed. Empty until one fires. */
|
||||
private val events = mutableListOf<String>()
|
||||
|
||||
@Test
|
||||
fun `the empty state asks for files in order and offers the picker`() {
|
||||
setContent(JoinState.Idle)
|
||||
|
||||
composeRule.onNodeWithText("Pick two or more files to join, in the order you want them.").assertExists()
|
||||
// No `performScrollTo` on this one: `Idle` is the centred branch, outside the scrolling
|
||||
// column every other state renders into, so there is nothing to scroll.
|
||||
composeRule.onNodeWithTag(TestTags.Join.CHOOSE_FILES).performClick()
|
||||
|
||||
assertEquals(listOf("pickInputs"), events)
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows come out in the order the inputs went in.
|
||||
*
|
||||
* Sorted by position rather than trusting the order `fetchSemanticsNodes` happens to return, so
|
||||
* the assertion is about what the user sees down the screen. Three inputs, with names whose
|
||||
* alphabetical order is not their picked order, so a list that had been sorted anywhere on the
|
||||
* way through would not be able to pass this.
|
||||
*/
|
||||
@Test
|
||||
fun `the picked inputs are listed in the order they were picked`() {
|
||||
val picked = listOf("intro.mp4", "middle.mp4", "outro.mp4")
|
||||
setContent(JoinState.Ready(inputs = picked.map(::input)))
|
||||
|
||||
val topToBottom = composeRule.onAllNodes(isFileRow)
|
||||
.fetchSemanticsNodes()
|
||||
.sortedBy { it.positionInRoot.y }
|
||||
.map { it.config[SemanticsProperties.TestTag] }
|
||||
|
||||
assertEquals(picked.map(TestTags.Join::fileRow), topToBottom)
|
||||
}
|
||||
|
||||
/**
|
||||
* Three inputs, not two: two is the minimum a join accepts, so a button that had been
|
||||
* hardcoded to the smallest legal join would still read correctly with two on screen.
|
||||
*/
|
||||
@Test
|
||||
fun `the join button counts the files it will join`() {
|
||||
setContent(JoinState.Ready(inputs = listOf(input("intro.mp4"), input("middle.mp4"), input("outro.mp4"))))
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.Join.JOIN).assertTextEquals("Join 3 files")
|
||||
composeRule.onNodeWithTag(TestTags.Join.JOIN).performScrollTo().performClick()
|
||||
|
||||
assertEquals(listOf("join"), events)
|
||||
}
|
||||
|
||||
/** `Ready` is the one working state that still offers the picker, to replace the selection. */
|
||||
@Test
|
||||
fun `a ready join can be repicked`() {
|
||||
setContent(JoinState.Ready(inputs = listOf(input("intro.mp4"), input("outro.mp4"))))
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.Join.CHOOSE_DIFFERENT_FILES).performScrollTo().performClick()
|
||||
|
||||
assertEquals(listOf("pickInputs"), events)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a running join names the count and shows a bar with no percentage`() {
|
||||
setContent(JoinState.Joining(inputs = listOf(input("intro.mp4"), input("outro.mp4"))))
|
||||
|
||||
composeRule.onNodeWithText("Joining 2 files…").assertExists()
|
||||
composeRule.onNodeWithTag(TestTags.Join.PROGRESS).assertRangeInfoEquals(ProgressBarRangeInfo.Indeterminate)
|
||||
composeRule.onNodeWithTag(TestTags.CANCEL).performScrollTo().performClick()
|
||||
|
||||
assertEquals(listOf("cancel"), events)
|
||||
}
|
||||
|
||||
/**
|
||||
* The paragraph is byte-identical to the converter screen's, which is the point of asserting
|
||||
* the whole of it rather than a fragment: the two branches were worded together, and a reword
|
||||
* that lands on one screen only is the failure this notices.
|
||||
*/
|
||||
@Test
|
||||
fun `a paused join explains itself and still offers cancel`() {
|
||||
setContent(JoinState.Waiting(inputs = listOf(input("intro.mp4"), input("outro.mp4"))))
|
||||
|
||||
composeRule.onNodeWithText(PAUSED_PARAGRAPH).assertExists()
|
||||
composeRule.onNodeWithTag(TestTags.CANCEL).performScrollTo().performClick()
|
||||
|
||||
assertEquals(listOf("cancel"), events)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a stream copied join says nothing was re-encoded`() {
|
||||
setContent(joined(ConcatStrategy.STREAM_COPY))
|
||||
|
||||
composeRule.onNodeWithText(STREAM_COPY_EXPLANATION).assertExists()
|
||||
composeRule.onNodeWithText(REENCODE_EXPLANATION).assertDoesNotExist()
|
||||
}
|
||||
|
||||
/**
|
||||
* The other half of the pair. Asserting the absence of the stream-copy line as well, because a
|
||||
* branch that had collapsed to one answer would still render *an* explanation.
|
||||
*/
|
||||
@Test
|
||||
fun `a re-encoded join says the files differed`() {
|
||||
setContent(joined(ConcatStrategy.REENCODE))
|
||||
|
||||
composeRule.onNodeWithText(REENCODE_EXPLANATION).assertExists()
|
||||
composeRule.onNodeWithText(STREAM_COPY_EXPLANATION).assertDoesNotExist()
|
||||
}
|
||||
|
||||
/** The size comes from the staged file, which is missing here, so `length()` answers `0L`. */
|
||||
@Test
|
||||
fun `a finished join reports the size of what it produced`() {
|
||||
setContent(joined(ConcatStrategy.STREAM_COPY))
|
||||
|
||||
composeRule.onNodeWithText("Joined — 0 MB.").assertExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a finished join offers save and start over, and they are not the same button`() {
|
||||
setContent(joined(ConcatStrategy.STREAM_COPY))
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.SAVE_FILE).performScrollTo().performClick()
|
||||
composeRule.onNodeWithTag(TestTags.START_OVER).performScrollTo().performClick()
|
||||
|
||||
assertEquals(listOf("save:joined.mp4", "reset"), events)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a saved join names the file and offers to join more`() {
|
||||
setContent(JoinState.Saved(displayName = "holiday-joined.mp4"))
|
||||
|
||||
composeRule.onNodeWithText("Saved holiday-joined.mp4.").assertExists()
|
||||
composeRule.onNodeWithTag(TestTags.Join.JOIN_MORE).assertTextEquals("Join more")
|
||||
composeRule.onNodeWithTag(TestTags.Join.JOIN_MORE).performScrollTo().performClick()
|
||||
|
||||
assertEquals(listOf("reset"), events)
|
||||
}
|
||||
|
||||
/**
|
||||
* The message is the whole content of this state -- it is the only thing that says why the job
|
||||
* stopped -- and it arrives as a string the failure produced, so a branch that rendered a fixed
|
||||
* apology instead would look correct on screen.
|
||||
*/
|
||||
@Test
|
||||
fun `a failed join renders the message it carries`() {
|
||||
setContent(JoinState.Failed(message = "The second file has no audio track, so joining stopped."))
|
||||
|
||||
composeRule.onNodeWithText("The second file has no audio track, so joining stopped.").assertExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failed join offers start over`() {
|
||||
setContent(JoinState.Failed(message = "The second file has no audio track, so joining stopped."))
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.START_OVER).performScrollTo().performClick()
|
||||
|
||||
assertEquals(listOf("reset"), events)
|
||||
}
|
||||
|
||||
/** Anything `FileRow` tagged, whichever file it is showing. The prefix comes from the table. */
|
||||
private val isFileRow = SemanticsMatcher("is a join file row") { node ->
|
||||
node.config.getOrNull(SemanticsProperties.TestTag)?.startsWith(TestTags.Join.fileRow("")) == true
|
||||
}
|
||||
|
||||
private fun input(displayName: String) = InputFile(
|
||||
uri = Uri.parse("content://test/$displayName"),
|
||||
displayName = displayName,
|
||||
sizeBytes = 4_000_000L,
|
||||
)
|
||||
|
||||
/** `staged` names a missing file deliberately -- see the same helper in `JoinScreenContentTest`. */
|
||||
private fun joined(strategy: ConcatStrategy) = JoinState.Joined(
|
||||
staged = File("no-such-staged-output.mp4"),
|
||||
strategy = strategy,
|
||||
suggestedName = "joined.mp4",
|
||||
mimeType = "video/mp4",
|
||||
)
|
||||
|
||||
private fun setContent(state: JoinState) {
|
||||
composeRule.setContent {
|
||||
JoinScreenContent(
|
||||
state = state,
|
||||
actions = JoinActions(
|
||||
onPickInputs = { events += "pickInputs" },
|
||||
onJoin = { events += "join" },
|
||||
onCancel = { events += "cancel" },
|
||||
onSave = { suggestedName -> events += "save:$suggestedName" },
|
||||
onReset = { events += "reset" },
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** Byte-identical to the converter screen's, and split the same way `main` splits it. */
|
||||
const val PAUSED_PARAGRAPH =
|
||||
"Paused. Android limits background media processing, so this will " +
|
||||
"resume automatically — keeping the app open helps it along."
|
||||
|
||||
const val STREAM_COPY_EXPLANATION =
|
||||
"Files matched, so they were joined without " +
|
||||
"re-encoding — no quality loss."
|
||||
|
||||
const val REENCODE_EXPLANATION =
|
||||
"Files differed in format, so they were re-encoded " +
|
||||
"to match."
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user