Merge remote-tracking branch 'origin/main' into merge-117-tmp
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user