Four messages were written out in two places each, in a codebase that already
had the convention for this and states it in `OutputPublisher.kt`:
Kept next to [STAGED_FILE_GONE_MESSAGE] for the same reason it is: both
ViewModels need it and staging is what it is about.
The ticket named three. A wider scan -- `"[A-Z][^"]{8,90}[.!]"` rather than the
{15,70} that produced the original list -- found a fourth, `"Joining failed."`,
which is the exact join-side twin of `"Conversion failed."` and had been missed
because it is fifteen characters long.
"Pick at least two files to join." -> ConcatWorker.TOO_FEW_INPUTS_MESSAGE
"Joining failed." -> ConcatWorker.GENERIC_FAILURE_MESSAGE
"Conversion failed." -> ConversionWorker.GENERIC_FAILURE_MESSAGE
"Could not save the file." -> SAVE_FAILED_MESSAGE, beside
STAGED_FILE_GONE_MESSAGE
Each constant sits with the layer that owns the condition, which is what the two
existing constants do. The arity rule is the worker's -- `request(...)` takes a
`List<Uri>` and checks nothing about its length -- so `TOO_FEW_INPUTS_MESSAGE`
lives there and the ViewModel reads it, not the other way round.
WHY THE TWO `Log.e` LITERALS STAY. `"Conversion failed."` and `"Joining failed."`
each also appear in a log line beside the failure they describe. Those keep their
own copies: a log has a different audience and carries the exception with it, and
coupling it to the user-facing wording would mean rewording the screen to change
a log. Stated in the KDoc so the next scan does not read them as a miss.
THE TEST IS A CROSS-LAYER ONE, DELIBERATELY. #158's done-when is explicit that "a
test asserting the constant equals its own value is worth nothing". Sharing a
constant makes the two sites agree by construction; what it cannot show is that
both layers still *reach* it. So `SharedFailureMessagesTest` drives each for real
-- the ViewModel through `onInputsPicked`, the worker through `doWork` -- and
asserts the two answers are the same string, taken from two running layers rather
than from one declaration.
That the sharing was worth doing at all is visible in what was pinned before:
`RefusedJobTest` (#139) pinned the worker's copy of the arity message and nothing
pinned the ViewModel's, so the screen's wording could drift with no test saying
anything.
Mutations:
| mutation | result |
|---|---|
| ViewModel keeps its own drifted literal | red |
| ViewModel's arity guard removed entirely | red |
Gate green: assembleDebug, testDebugUnitTest, compileDebugAndroidTestKotlin,
ktlintCheck, detekt, lintDebug.
Not done here: `"Saved ${s.displayName}."` appears in both screens. It is left
alone, and the reason is a real distinction rather than an oversight -- the four
above are cases where one layer's message is another layer's *fallback*, so drift
means the user sees different words for one condition. Two screens each wording
their own success text is ordinary UI, and drift there is cosmetic.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
103 lines
4.2 KiB
Kotlin
103 lines
4.2 KiB
Kotlin
package org.libremediaconverter.join
|
|
|
|
import android.app.Application
|
|
import android.net.Uri
|
|
import androidx.media3.common.util.UnstableApi
|
|
import androidx.work.ListenableWorker
|
|
import androidx.work.testing.TestListenableWorkerBuilder
|
|
import androidx.work.workDataOf
|
|
import kotlinx.coroutines.runBlocking
|
|
import org.junit.After
|
|
import org.junit.Assert.assertEquals
|
|
import org.junit.Before
|
|
import org.junit.Test
|
|
import org.junit.runner.RunWith
|
|
import org.libremediaconverter.convert.ConversionDependencies
|
|
import org.libremediaconverter.convert.RecordingPublisher
|
|
import org.libremediaconverter.convert.installTestWorkManager
|
|
import org.libremediaconverter.work.ConcatWorker
|
|
import org.robolectric.RobolectricTestRunner
|
|
import org.robolectric.RuntimeEnvironment
|
|
|
|
/**
|
|
* The two layers that refuse a short join, refusing it with one sentence.
|
|
*
|
|
* ## Why this is not "assert a constant equals itself"
|
|
*
|
|
* `ConcatWorker` and `JoinViewModel` both reject a join of fewer than two files, and before #158
|
|
* each carried **its own copy of the literal**. Only the worker's was pinned — by `RefusedJobTest`,
|
|
* added in #139 — so the wording on the screen could drift away from the wording in the job with no
|
|
* test saying anything, for one message the user sees from one condition.
|
|
*
|
|
* Sharing a constant makes them agree by construction. What it does *not* do is prove that both
|
|
* layers still reach it: a refactor that stops `JoinViewModel` refusing at all, or that gives it a
|
|
* different message, passes any test that only reads `TOO_FEW_INPUTS_MESSAGE`. So each layer is
|
|
* driven for real here — the ViewModel through `onInputsPicked`, the worker through `doWork` — and
|
|
* the assertion is that the two answers are **the same string**, taken from two running layers
|
|
* rather than from one declaration.
|
|
*
|
|
* That is the shape `CLAUDE.md` asks for: revert the sharing and this goes red, because the two
|
|
* sites drift the moment they are allowed to.
|
|
*
|
|
* ## Scope
|
|
*
|
|
* The arity guard's own behaviour on the ViewModel side — that it refuses one file, that it accepts
|
|
* two, that it claims ownership first — is #155's, and this deliberately does not duplicate it.
|
|
* This file is about the *agreement between layers*, which is what #158 changed.
|
|
*/
|
|
@UnstableApi
|
|
@RunWith(RobolectricTestRunner::class)
|
|
class SharedFailureMessagesTest {
|
|
|
|
private lateinit var app: Application
|
|
private lateinit var viewModel: JoinViewModel
|
|
|
|
@Before
|
|
fun setUp() {
|
|
app = RuntimeEnvironment.getApplication()
|
|
ConversionDependencies.publisher = { RecordingPublisher(app) }
|
|
installTestWorkManager(app, workDataOf(ConcatWorker.KEY_OUTPUT_PATH to "/dev/null"))
|
|
viewModel = JoinViewModel(app)
|
|
}
|
|
|
|
@After
|
|
fun tearDown() {
|
|
ConversionDependencies.reset()
|
|
}
|
|
|
|
@Test
|
|
fun `both layers refuse a one-file join with the same sentence`() {
|
|
// The ViewModel, refusing before anything is enqueued.
|
|
viewModel.onInputsPicked(listOf(ONE_FILE))
|
|
val fromScreen = (viewModel.state.value as JoinState.Failed).message
|
|
|
|
// The worker, refusing a job that reached the queue anyway -- which it can, because
|
|
// ConcatWorker.request(...) takes a List<Uri> and checks nothing about its length.
|
|
val result = runBlocking { worker(ONE_FILE).doWork() }
|
|
val fromJob = (result as ListenableWorker.Result.Failure)
|
|
.outputData.getString(ConcatWorker.KEY_ERROR)
|
|
|
|
assertEquals(
|
|
"the screen and the job must say the same thing about the same refusal",
|
|
fromScreen,
|
|
fromJob,
|
|
)
|
|
// And that the shared sentence is the one either layer would have written on its own,
|
|
// rather than both having drifted together to something else.
|
|
assertEquals(ConcatWorker.TOO_FEW_INPUTS_MESSAGE, fromScreen)
|
|
}
|
|
|
|
private fun worker(vararg inputs: Uri): ConcatWorker = TestListenableWorkerBuilder<ConcatWorker>(
|
|
context = app,
|
|
inputData = workDataOf(
|
|
ConcatWorker.KEY_INPUT_URIS to inputs.map(Uri::toString).toTypedArray(),
|
|
ConcatWorker.KEY_TOTAL_BYTES to 1024L,
|
|
),
|
|
runAttemptCount = 0,
|
|
).build()
|
|
|
|
private companion object {
|
|
val ONE_FILE: Uri = Uri.parse("content://test/holiday.mp4")
|
|
}
|
|
}
|