Ask each join state what it lets the user do next

`JoinScreenContent` decides the whole join UI in one `when`, and until R38.5 gave it a state
parameter nothing could ask it anything: `Waiting` follows a denied foreground start and `Joined`
follows a finished `ConcatWorker` run, so neither is reachable by driving a real `JoinViewModel`.
`JoinScreenContentTest` used that seam to prove it exists, on one state. This is the matrix behind
it -- seven states, each pinned to the affordance it offers and the callback that affordance is
wired to, asserting on the value handed back rather than on something merely having fired.

Two of the thirteen assert things nothing else in the suite has ever asked.

The rows are read back sorted by their position on screen and compared as an ordered list. 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" -- and `JoinLeafTagsTest` proves only that a row tags itself
with the file it shows, which a reversed list would satisfy just as well.

The progress bar is asserted to be indeterminate, not merely present. It carries no percentage on
purpose, because FFmpeg reports progress against one input's duration and that means nothing across
a concatenation; the converter screen's bar is determinate, so "there is a bar" is exactly the
assertion that would let a fabricated percentage land here unnoticed.

Three mutations, each reverted after:

- `Text(s.message)` -> `Text("")` in `Failed`: "a failed join renders the message it carries" fails
  with `could not find any node that satisfies: (Text + InputText + EditableText contains 'The
  second file has no audio track, so joining stopped.')`.
- `when (s.strategy)` -> `when (ConcatStrategy.STREAM_COPY)` in `Joined`: "a re-encoded join says
  the files differed" fails on the copy for the branch that no longer runs.
- `s.inputs.forEach` -> `s.inputs.reversed().forEach` in `Ready`: the ordering test fails
  `expected:<[join.fileRow:intro.mp4, join.fileRow:middle.mp4, join.fileRow:outro.mp4]> but
  was:<[join.fileRow:outro.mp4, join.fileRow:middle.mp4, join.fileRow:intro.mp4]>`.

Test-only: no file under `app/src/main` changes, and no tag is added to `TestTags`, because every
string these states render is either already tagged or unambiguous as text. The typographic
characters in the asserted copy -- U+2026 in "Joining N files...", U+2014 in the `Joined` and
Paused lines -- were checked byte-for-byte against `JoinScreen.kt` rather than retyped; an ASCII
lookalike compiles and then quietly matches nothing.

Closes #63.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-24 19:49:48 -05:00
co-authored by Claude Opus 5
parent 1b1d5c6d04
commit 02555ceb91
@@ -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."
}
}