Merge remote-tracking branch 'origin/main' into merge-117-tmp

This commit is contained in:
2026-08-25 22:35:01 -05:00
13 changed files with 901 additions and 49 deletions
@@ -93,8 +93,12 @@ class ConversionViewModelCleanupTest {
assertTrue("a failed save must not destroy the only copy", staged.exists())
assertEquals(emptyList<File>(), publisher.discarded)
// Failed carries no file reference at all, so this only works because the handle is
// a ViewModel field rather than something read back out of the state machine.
// The handle is a ViewModel field rather than something read back out of the state
// machine, and stays one now that a save-failed `Failed` also carries a `PendingSave`:
// that is a view for the screen to offer a retry through, never a second owner of the
// file. This delete goes through the field, which is what keeps a state that is dropped
// rather than read from taking the only reference with it. What the state carries, and
// what the screen then does with it, are `FailedSaveRetryTest`'s.
viewModel.reset()
assertEquals(listOf(staged), publisher.discarded)
@@ -53,10 +53,11 @@ import java.io.File
* 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.
* not guarded by this file.** `Idle` is a `data object`, `Saved` carries a `displayName`, and
* `Failed` carries a message and -- after a failed save only -- the staged file it left behind;
* 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.
@@ -326,6 +327,22 @@ class ConverterStateAffordancesTest {
composeRule.onNodeWithTag(TestTags.Converter.FILE_CARD).assertDoesNotExist()
}
/**
* **The assertion that bounds #30's whole change**, and the one worth breaking things to keep.
*
* A transcode that died staged nothing, so its `Failed` carries no [PendingSave] and there is
* nothing for a save dialog to be handed. Making the retry unconditional -- or making the
* `WorkInfo.State.FAILED` arm of `ConversionViewModel.observe` carry a handle it has no file
* for -- puts a button on screen that can only fail, and this is what notices.
*/
@Test
fun `a transcode failure offers no way to save`() {
setContent(ConversionState.Failed(message = "Ran out of space while writing the output."))
composeRule.onNodeWithTag(TestTags.RETRY_SAVE).assertDoesNotExist()
composeRule.onNodeWithTag(TestTags.SAVE_FILE).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."))
@@ -335,6 +352,50 @@ class ConverterStateAffordancesTest {
assertEquals(listOf("reset"), fired)
}
// ----------------------------------------------------- Failed, carrying a file
/**
* The defect in #30, stated as what the branch must render.
*
* `save()` keeps the staged file on a failure deliberately -- it can be the only copy of an
* hour of transcoding -- and before this the only control here was "Start over", wired to
* `reset()`, which deletes exactly that file. Both buttons, not one: the restart has to stay
* reachable, because leaving a full-size file in cache is the outcome it exists to avoid.
*/
@Test
fun `a failed save offers the file again as well as a restart`() {
setContent(failedSave())
composeRule.onNodeWithTag(TestTags.RETRY_SAVE).assertExists()
composeRule.onNodeWithTag(TestTags.START_OVER).assertExists()
}
/**
* The name, not just that something fired: it comes from the finished job, and a retry wired to
* a literal or to the picker's current guess would hand the dialog a name the job never chose.
*/
@Test
fun `tapping try saving again hands back the name the job chose`() {
setContent(failedSave())
composeRule.onNodeWithTag(TestTags.RETRY_SAVE).performScrollTo().performClick()
assertEquals(listOf("save:holiday.mp4"), fired)
}
/**
* Start over from here still resets, and resetting still deletes -- see `reset()`'s KDoc for
* why that is acceptable now and was not before. What it must not do is save on the way past.
*/
@Test
fun `tapping start over after a failed save resets and does not save`() {
setContent(failedSave())
composeRule.onNodeWithTag(TestTags.START_OVER).performScrollTo().performClick()
assertEquals(listOf("reset"), fired)
}
// ------------------------------------------------------------------ Harness
private fun input() = InputFile(
@@ -348,6 +409,21 @@ class ConverterStateAffordancesTest {
* missing file rather than throwing, so the size line reads `0 B` and no temporary folder is
* needed to render the arm.
*/
/**
* A `Failed` an earlier save left carrying its file, which is the only way [retry] is non-null.
*
* The same missing `staged` path as [converted], and for the same reason: this arm renders no
* size line at all, so nothing here ever touches the filesystem.
*/
private fun failedSave() = ConversionState.Failed(
message = "There was not enough room on the destination.",
retry = PendingSave(
staged = File("no-such-staged-output.mp4"),
suggestedName = "holiday.mp4",
mimeType = "video/mp4",
),
)
private fun converted(routeReason: String = "") = ConversionState.Converted(
input = input(),
staged = File("no-such-staged-output.mp4"),
@@ -0,0 +1,370 @@
package org.libremediaconverter.convert
import android.app.Application
import android.net.Uri
import androidx.media3.common.util.UnstableApi
import androidx.work.workDataOf
import kotlinx.coroutines.Dispatchers
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.join.JoinState
import org.libremediaconverter.join.JoinViewModel
import org.libremediaconverter.join.pendingSave
import org.libremediaconverter.model.InputProbe
import org.libremediaconverter.model.OutputFormat
import org.libremediaconverter.work.ConcatWorker
import org.libremediaconverter.work.ConversionWorker
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import java.io.File
/**
* A failed save has to leave the file *offerable*, not merely undeleted.
*
* The defect is #30, and both halves of it were already written down in `main`. `save()`'s
* `onFailure` kept the staged file on purpose -- "deleting here would destroy the work to tidy up
* a cache directory" -- and then handed the screen a `Failed` carrying a message and nothing else,
* so the single control that branch rendered was "Start over", wired to `reset()`, which deletes
* exactly that file. The intent and the affordance disagreed, and the affordance won.
*
* `ConversionViewModelCleanupTest` already pins the *keeping*: after a failed save the file is
* still on disk and nothing has been discarded. It stays green with the state carrying nothing,
* because it reads the filesystem rather than the state. This file asserts the other half -- that
* the handle reaches the state a screen can read -- and the negative that bounds it: a failure
* with nothing staged behind it must not sprout a save button.
*
* Both ViewModels in one class, following `MissingStagedFileTest`. They are separate state
* machines that can each hold a staged file at once, but this defect and its fix are the same
* shape in both, and splitting them would put the two halves of one invariant in two files.
*
* ### Not asserted here, so each is a decision rather than an omission
*
* - **That the destination received the bytes.** [RecordingPublisher.publish] is a stub, which is
* the only way to make a save fail deterministically -- and making it fail is what every case
* 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.
* - **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
* carried handle is a view of `pendingStaged`, never a second owner of the file.
*/
@UnstableApi
@RunWith(RobolectricTestRunner::class)
class FailedSaveRetryTest {
private lateinit var app: Application
private lateinit var publisher: RecordingPublisher
private lateinit var staged: File
@Before
fun setUp() {
app = RuntimeEnvironment.getApplication()
publisher = RecordingPublisher(app)
ConversionDependencies.publisher = { publisher }
// MediaProbe spawns FFprobe, whose loader throws with no native library present.
ConversionDependencies.probe = { _, _ -> InputProbe() }
staged = publisher.createStagingFile("holiday.mp4").apply { writeBytes(ByteArray(4096)) }
}
@After
fun tearDown() {
ConversionDependencies.reset()
}
// ------------------------------------------------------------------ Convert
/**
* The bite named in #30's fix. Emitting a plain `Failed` from `save()`'s `onFailure` -- which
* is what `main` did -- reddens this case on the null handle, and nothing else in the suite.
*/
@Test
fun `a failed save leaves the staged file offerable, not merely undeleted`() {
val viewModel = failedSaveViewModel()
val failed = viewModel.state.value as ConversionState.Failed
val retry = failed.retry
assertNotNull("a failed save must leave the staged file offerable, not just on disk", retry)
assertEquals("the retry must name the file the conversion actually produced", staged, retry?.staged)
// Both from the job's own output Data rather than from the pickers, so a retry opens the
// same dialog the first attempt did.
assertEquals(SUGGESTED_NAME, retry?.suggestedName)
assertEquals(JOB_MIME_TYPE, retry?.mimeType)
assertTrue("a failed save must not destroy the only copy", staged.exists())
}
/**
* The whole point of carrying the handle: the second attempt is a real save, not a new job.
*
* `publishFailure` is cleared between the two calls, so one `RecordingPublisher` plays both a
* full destination and an empty one -- which is exactly the user's situation.
*/
@Test
fun `retrying a failed save publishes the file and leaves nothing staged`() {
val viewModel = failedSaveViewModel()
publisher.publishFailure = null
viewModel.save(DESTINATION)
val saved = awaitState(viewModel.state, "Saved") { it is ConversionState.Saved }
assertEquals(SUGGESTED_NAME, (saved as ConversionState.Saved).displayName)
assertFalse("a successful retry should have removed the staged file", staged.exists())
// Nothing to collect afterwards: the retry published it, so reset() has no work left.
viewModel.reset()
assertEquals(emptyList<File>(), publisher.discarded)
}
/**
* The second failure must not eat the file the first one kept.
*
* A `Failed` built fresh from `e.message` alone would drop the handle here while every other
* assertion in this file stayed green -- the file is still on disk, and the first failure
* already proved the state can carry it.
*/
@Test
fun `a retry that fails again still carries the file rather than dropping it`() {
val viewModel = failedSaveViewModel()
publisher.publishFailure = IllegalStateException("destination volume still full")
viewModel.save(DESTINATION)
// Waited for by the *second* message rather than by `is Failed`: the state was already
// Failed when the retry started, so the type alone would be satisfied before it ran.
val failed = awaitState(viewModel.state, "the second failure") {
it is ConversionState.Failed && it.message == "destination volume still full"
} as ConversionState.Failed
assertEquals("the second failure must offer the same file the first one did", staged, failed.retry?.staged)
assertTrue(staged.exists())
assertEquals(emptyList<File>(), publisher.discarded)
}
/**
* "Start over" still deletes, and that is the decision `reset()`'s KDoc records: acceptable
* only because "Try saving again" is on screen beside it. Exactly once, through the publisher.
*/
@Test
fun `start over from a failed save discards the carried file exactly once`() {
val viewModel = failedSaveViewModel()
viewModel.reset()
assertEquals(ConversionState.Idle, viewModel.state.value)
assertEquals(listOf(staged), publisher.discarded)
assertFalse(staged.exists())
}
/**
* A retry meets the same existence check the first attempt did, so a file collected by the
* sweep or by the OS in between is reported as a sentence rather than as a raw ENOENT path.
* And the state that reports it carries nothing: there is no file left to offer.
*/
@Test
fun `a retry whose staged file has gone says so and offers nothing further`() {
val viewModel = failedSaveViewModel()
assertTrue("the fixture must start with a real staged file", staged.delete())
publisher.publishFailure = null
viewModel.save(DESTINATION)
val failed = viewModel.state.value as ConversionState.Failed
assertEquals(STAGED_FILE_GONE_MESSAGE, failed.message)
assertNull("a file that has gone cannot be offered again", failed.retry)
}
/**
* The negative that bounds the whole change, and the reason `retry` is nullable.
*
* A transcode that died staged nothing, so there is no file to hand back -- and a `Failed`
* that carried one anyway would put a save button on a screen with nothing to save. Driven
* through a worker that really fails rather than by constructing the state, because the line
* under test is the `WorkInfo.State.FAILED` arm of `observe`.
*/
@Test
fun `a transcode failure carries nothing to save`() {
installFailingTestWorkManager(app, workDataOf(ConversionWorker.KEY_ERROR to "The encoder gave up."))
val viewModel = ConversionViewModel(app, Dispatchers.Unconfined)
viewModel.onInputPicked(Uri.parse("content://test/holiday.mp4"))
awaitState(viewModel.state, "Ready") { it is ConversionState.Ready }
viewModel.convert()
val failed = awaitState(viewModel.state, "Failed") { it is ConversionState.Failed } as ConversionState.Failed
assertEquals("The encoder gave up.", failed.message)
assertNull("a transcode failure has nothing staged, so it must offer no save", failed.retry)
assertNull("and nothing for the save dialog to open with either", failed.pendingSave())
}
/**
* What the save dialog reopens with, which is the entry point's only reader of this state.
*
* The pickers are moved *after* the job finishes, which is what makes this bite: a retry that
* asked the current settings would offer `audio/mpeg` for a file the job wrote as MP4. The
* same gap is permanent for a reattached job, whose spec was never in these settings at all.
*/
@Test
fun `a retry offers the type the job chose, not the one the pickers now show`() {
val viewModel = failedSaveViewModel()
viewModel.setPreset(OutputFormat.MP3)
assertEquals(
"the fixture needs the pickers to disagree with the job",
"audio/mpeg",
viewModel.settings.value.spec.mimeType,
)
assertEquals(JOB_MIME_TYPE, viewModel.state.value.pendingSave()?.mimeType)
}
// --------------------------------------------------------------------- Join
@Test
fun `a failed join save leaves the staged file offerable, not merely undeleted`() {
val viewModel = failedJoinSaveViewModel()
val failed = viewModel.state.value as JoinState.Failed
val retry = failed.retry
assertNotNull("a failed save must leave the staged file offerable, not just on disk", retry)
assertEquals(staged, retry?.staged)
assertEquals(SUGGESTED_NAME, retry?.suggestedName)
assertEquals(JOB_MIME_TYPE, retry?.mimeType)
assertTrue(staged.exists())
}
@Test
fun `retrying a failed join save publishes the file and leaves nothing staged`() {
val viewModel = failedJoinSaveViewModel()
publisher.publishFailure = null
viewModel.save(DESTINATION)
val saved = awaitState(viewModel.state, "Saved") { it is JoinState.Saved }
assertEquals(SUGGESTED_NAME, (saved as JoinState.Saved).displayName)
assertFalse(staged.exists())
viewModel.reset()
assertEquals(emptyList<File>(), publisher.discarded)
}
@Test
fun `a join retry that fails again still carries the file rather than dropping it`() {
val viewModel = failedJoinSaveViewModel()
publisher.publishFailure = IllegalStateException("destination volume still full")
viewModel.save(DESTINATION)
// By the second message, not by `is Failed` -- see the converter case above.
val failed = awaitState(viewModel.state, "the second failure") {
it is JoinState.Failed && it.message == "destination volume still full"
} as JoinState.Failed
assertEquals(staged, failed.retry?.staged)
assertTrue(staged.exists())
assertEquals(emptyList<File>(), publisher.discarded)
}
@Test
fun `start over from a failed join save discards the carried file exactly once`() {
val viewModel = failedJoinSaveViewModel()
viewModel.reset()
assertEquals(JoinState.Idle, viewModel.state.value)
assertEquals(listOf(staged), publisher.discarded)
assertFalse(staged.exists())
}
@Test
fun `a join failure carries nothing to save`() {
installFailingTestWorkManager(app, workDataOf(ConcatWorker.KEY_ERROR to "The files could not be joined."))
val viewModel = JoinViewModel(app, Dispatchers.Unconfined)
viewModel.onInputsPicked(listOf(Uri.parse("content://test/a.mp4"), Uri.parse("content://test/b.mp4")))
awaitState(viewModel.state, "Ready") { it is JoinState.Ready }
viewModel.join()
val failed = awaitState(viewModel.state, "Failed") { it is JoinState.Failed } as JoinState.Failed
assertEquals("The files could not be joined.", failed.message)
assertNull("a join failure has nothing staged, so it must offer no save", failed.retry)
assertNull(failed.pendingSave())
}
// ------------------------------------------------------------------ Harness
/**
* A ViewModel driven to `Converted` and then through a save that threw.
*
* The WorkManager is installed here rather than in `@Before`, because two cases in this class
* need one whose workers fail instead.
*/
private fun failedSaveViewModel(): ConversionViewModel {
installTestWorkManager(app, conversionOutput())
// Unconfined so reset()'s delete runs inline instead of on a real IO thread.
val viewModel = ConversionViewModel(app, Dispatchers.Unconfined)
viewModel.onInputPicked(Uri.parse("content://test/holiday.mkv"))
awaitState(viewModel.state, "Ready") { it is ConversionState.Ready }
viewModel.convert()
awaitState(viewModel.state, "Converted") { it is ConversionState.Converted }
publisher.publishFailure = IllegalStateException("destination volume full")
viewModel.save(DESTINATION)
awaitState(viewModel.state, "Failed") { it is ConversionState.Failed }
return viewModel
}
/** The join tab's equivalent, driven to `Joined` and then through a save that threw. */
private fun failedJoinSaveViewModel(): JoinViewModel {
installTestWorkManager(app, joinOutput())
val viewModel = JoinViewModel(app, Dispatchers.Unconfined)
viewModel.onInputsPicked(listOf(Uri.parse("content://test/a.mp4"), Uri.parse("content://test/b.mp4")))
awaitState(viewModel.state, "Ready") { it is JoinState.Ready }
viewModel.join()
awaitState(viewModel.state, "Joined") { it is JoinState.Joined }
publisher.publishFailure = IllegalStateException("destination volume full")
viewModel.save(DESTINATION)
awaitState(viewModel.state, "Failed") { it is JoinState.Failed }
return viewModel
}
/**
* The output `Data` a finished conversion reports.
*
* The name and type are set rather than left out, so the assertions above are about what the
* *job* chose. Both ViewModels fall back to a derivation when they are missing, and a fixture
* that omitted them would be asserting the fallback while looking like it asserted the job.
*
* Spelled out per worker rather than shared with [joinOutput], even though the two constants
* hold the same strings today. A test that leaned on that would be asserting a coincidence.
*/
private fun conversionOutput() = workDataOf(
ConversionWorker.KEY_OUTPUT_PATH to staged.absolutePath,
ConversionWorker.KEY_SUGGESTED_NAME to SUGGESTED_NAME,
ConversionWorker.KEY_MIME_TYPE to JOB_MIME_TYPE,
)
/** The output `Data` a finished join reports. See [conversionOutput]. */
private fun joinOutput() = workDataOf(
ConcatWorker.KEY_OUTPUT_PATH to staged.absolutePath,
ConcatWorker.KEY_SUGGESTED_NAME to SUGGESTED_NAME,
ConcatWorker.KEY_MIME_TYPE to JOB_MIME_TYPE,
)
private companion object {
val DESTINATION: Uri = Uri.parse("content://test/destination.mp4")
const val SUGGESTED_NAME = "holiday.mp4"
/** What the job wrote. [OutputFormat.MP3]'s `audio/mpeg` is what the pickers move to. */
const val JOB_MIME_TYPE = "video/mp4"
}
}
@@ -82,6 +82,28 @@ class SucceedingWorkerFactory(private val outputData: Data) : WorkerFactory() {
}
}
/**
* Stands in for a worker that died, reporting [outputData] on the way out.
*
* The counterpart to [SucceedingWorkerFactory], and needed for the same reason: the real workers
* cannot run on the JVM, so the only way to ask what a ViewModel does with a `FAILED` `WorkInfo` is
* to produce one. A `Result.failure` carrying `KEY_ERROR` is exactly what both real workers report
* when their engine gives up, and it is the one path where nothing has ever been staged.
*
* `runAttemptCount` is irrelevant here: `Result.failure` is terminal, so WorkManager does not retry
* it and the state goes straight to `Failed` rather than through `Waiting`.
*/
class FailingWorkerFactory(private val outputData: Data) : WorkerFactory() {
override fun createWorker(
appContext: Context,
workerClassName: String,
workerParameters: WorkerParameters,
): ListenableWorker = object : Worker(appContext, workerParameters) {
override fun doWork(): Result = Result.failure(outputData)
}
}
/**
* Installs a synchronous test WorkManager whose workers succeed with [outputData].
*
@@ -89,6 +111,16 @@ class SucceedingWorkerFactory(private val outputData: Data) : WorkerFactory() {
*/
fun installTestWorkManager(context: Context, outputData: Data): SucceedingWorkerFactory {
val factory = SucceedingWorkerFactory(outputData)
installWorkManager(context, factory)
return factory
}
/** Installs a synchronous test WorkManager whose workers fail, reporting [outputData]. */
fun installFailingTestWorkManager(context: Context, outputData: Data) {
installWorkManager(context, FailingWorkerFactory(outputData))
}
private fun installWorkManager(context: Context, factory: WorkerFactory) {
WorkManagerTestInitHelper.initializeTestWorkManager(
context,
Configuration.Builder()
@@ -98,7 +130,6 @@ fun installTestWorkManager(context: Context, outputData: Data): SucceedingWorker
.setWorkerFactory(factory)
.build(),
)
return factory
}
/**
@@ -18,6 +18,7 @@ import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.convert.InputFile
import org.libremediaconverter.convert.PendingSave
import org.libremediaconverter.model.ConcatStrategy
import org.libremediaconverter.ui.TestTags
import org.robolectric.RobolectricTestRunner
@@ -219,6 +220,51 @@ class JoinStateAffordancesTest {
assertEquals(listOf("reset"), events)
}
/**
* The negative that bounds #30 on this screen. A join that died staged nothing, so its `Failed`
* carries no [PendingSave] and there is nothing a save dialog could be handed. A retry button
* rendered unconditionally here could only fail, and this is what notices.
*/
@Test
fun `a failed join offers no way to save`() {
setContent(JoinState.Failed(message = "The second file has no audio track, so joining stopped."))
composeRule.onNodeWithTag(TestTags.RETRY_SAVE).assertDoesNotExist()
composeRule.onNodeWithTag(TestTags.SAVE_FILE).assertDoesNotExist()
}
/**
* #30 on this screen: `save()` keeps the staged file when the copy out throws, and until this
* branch grew a second button the only control it rendered was "Start over" -- `reset()`, which
* deletes exactly that file. Both, not one: the restart still has to be reachable.
*/
@Test
fun `a failed join save offers the file again as well as a restart`() {
setContent(failedSave())
composeRule.onNodeWithTag(TestTags.RETRY_SAVE).assertExists()
composeRule.onNodeWithTag(TestTags.START_OVER).assertExists()
}
/** The name comes from the job, so a retry wired to a literal would hand back the wrong one. */
@Test
fun `tapping try saving again hands back the name the join chose`() {
setContent(failedSave())
composeRule.onNodeWithTag(TestTags.RETRY_SAVE).performScrollTo().performClick()
assertEquals(listOf("save:joined.mp4"), events)
}
@Test
fun `tapping start over after a failed join save resets and does not save`() {
setContent(failedSave())
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
@@ -230,6 +276,19 @@ class JoinStateAffordancesTest {
sizeBytes = 4_000_000L,
)
/**
* A `Failed` an earlier save left carrying its file, which is the only way `retry` is non-null.
* `staged` names a missing file for the same reason [joined] does -- this arm reads no length.
*/
private fun failedSave() = JoinState.Failed(
message = "There was not enough room on the destination.",
retry = PendingSave(
staged = File("no-such-staged-output.mp4"),
suggestedName = "joined.mp4",
mimeType = "video/mp4",
),
)
/** `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"),
@@ -0,0 +1,131 @@
package org.libremediaconverter.ui.theme
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.test.junit4.v2.createComposeRule
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
/**
* The theme has to resolve the scheme its arguments name, and `ThemeKt` had no test at all --
* 23 lines, none of them covered, which is how #68 was found.
*
* Only two of the four branches in [LibreMediaConverterTheme]'s `when` are reachable from the
* app. `MainActivity` is the single call site and passes no arguments, so `dynamicColor` is
* always `true` and the live choice is between the dynamic dark and dynamic light schemes.
* Those two are what ships, and asserting on them survives whichever way #68 is decided.
*
* **The other two branches have no caller.** `dynamicColor = false` is passed below by this
* test and by nothing else in `app/src`, so the coverage it produces is not evidence that a
* switch exists -- misreading it that way is the whole reason #68 was filed. #68 is the open
* decision about whether one ever will exist.
*
* What the assertions distinguish the branches on was measured under Robolectric `sdk=36`
* rather than assumed. The dynamic palette resolves to the platform's own default there --
* dark background `#121318` against light `#FAF8FF`, dark primary `#B0C6FF` -- and that is a
* different hue from the brand palette's [Purple80] / [Purple40]. A dynamic scheme reads the
* device, so those exact values belong to the Robolectric stub and to no particular phone,
* which is why the live-branch tests compare the two resolved schemes against each other
* instead of hard-coding either one.
*/
@RunWith(RobolectricTestRunner::class)
class ThemeColorSchemeTest {
// The **v2** rule (`androidx.compose.ui.test.junit4.v2`), as everywhere else in this
// source set.
@get:Rule
val composeRule = createComposeRule()
/**
* Every scheme the `when` can produce, read out of [MaterialTheme] inside the content
* lambda -- the only place that shows what the theme actually chose, rather than what the
* caller hoped for.
*
* All four are resolved in one composition because `setContent` may be called once per
* test, and a comparison needs at least two of them.
*/
private fun resolveAll(): Schemes {
lateinit var dynamicDark: ColorScheme
lateinit var dynamicLight: ColorScheme
lateinit var brandDark: ColorScheme
lateinit var brandLight: ColorScheme
composeRule.setContent {
LibreMediaConverterTheme(darkTheme = true) { dynamicDark = MaterialTheme.colorScheme }
LibreMediaConverterTheme(darkTheme = false) { dynamicLight = MaterialTheme.colorScheme }
LibreMediaConverterTheme(darkTheme = true, dynamicColor = false) {
brandDark = MaterialTheme.colorScheme
}
LibreMediaConverterTheme(darkTheme = false, dynamicColor = false) {
brandLight = MaterialTheme.colorScheme
}
}
composeRule.waitForIdle()
return Schemes(dynamicDark, dynamicLight, brandDark, brandLight)
}
private class Schemes(
val dynamicDark: ColorScheme,
val dynamicLight: ColorScheme,
val brandDark: ColorScheme,
val brandLight: ColorScheme,
)
/**
* The live branches, and the one assertion that catches them being swapped: both dynamic
* schemes come from the same device palette, so they are similar enough that identity or a
* bare inequality would prove nothing. Background luminance is not similar -- it is the
* thing dark mode is for.
*/
@Test
fun `dark mode resolves a darker scheme than light mode`() {
val schemes = resolveAll()
val dark = schemes.dynamicDark.background.luminance()
val light = schemes.dynamicLight.background.luminance()
assertTrue(
"darkTheme = true should resolve the dynamic dark scheme, whose background " +
"luminance ($dark) is below the light scheme's ($light)",
dark < light,
)
}
/**
* Which of the two dark branches ran, and which of the two light ones: the scheme a live
* call resolves is the dynamic one, not the brand palette sitting next to it.
*/
@Test
fun `the live branches take the dynamic palette rather than the brand one`() {
val schemes = resolveAll()
assertNotEquals(
"the default dynamicColor = true should not resolve the brand dark palette",
Purple80,
schemes.dynamicDark.primary,
)
assertNotEquals(
"the default dynamicColor = true should not resolve the brand light palette",
Purple40,
schemes.dynamicLight.primary,
)
}
/**
* The two dead branches. Nothing in `app/src` passes `dynamicColor = false`; this test
* does it directly, because the parameter is public, and that is the only way either
* branch runs. Covered so a later decision on #68 starts from a tested `when` -- not
* because the brand palette is reachable in the app.
*/
@Test
fun `the brand palette branches run only when dynamicColor is passed explicitly`() {
val schemes = resolveAll()
assertEquals(Purple80, schemes.brandDark.primary)
assertEquals(Purple40, schemes.brandLight.primary)
}
}