Name the screen leaves, and give the tests a tag vocabulary

Kotlin `private` on a top-level declaration is file-scoped, so every leaf
composable in the two screens was invisible even to the JVM test source set,
which is a friend of main. The only three declarations src/test could name were
ConverterScreen, JoinScreen and AppRoot -- there was nothing to write a test
against, which is why #52 could not be started as filed.

`internal` is the same choice MainActivity already documents for Destination:
the unit tests can name it, and it stays invisible to anything outside the
module. Eleven declarations in ConverterScreen and FileRow in JoinScreen.

The tag table is the other half. Tests reference a symbol rather than a literal,
which is what keeps the five children that follow independent: "Cancel", "Start
over" and "Save file" are each rendered by both screens and by more than one
state branch, so rewording one would otherwise redden several PRs at once and no
diff would explain why. There were zero testTag, semantics or contentDescription
calls anywhere in main before this.

Every tag is applied inside main. A tag a test hands down as a Modifier proves
only that the test set it -- it would survive the affordance losing its own tag
entirely, which is the vacuous shape CLAUDE.md records nine of in one review.
That is why FileRow and DetailRow derive theirs from data they already hold
rather than taking an index from the call site.

TestTags is public rather than internal, and R38.8 is the reason. It reads the
table from androidTest, and whether that is a friend source set of main under
AGP 9 had no in-tree answer -- nothing referenced a main internal from there.
Settled by compiling one: it is a friend, so internal would work today. Public
anyway, because that friendship is AGP wiring rather than something this project
states, and the KDoc now carries the measurement so nobody has to repeat it.

Smoke tests cover each leaf: it renders, and its tag resolves to exactly one
node. Counting rather than asserting existence is deliberate, since a duplicated
tag fails differently depending on which finder a later test happens to use. The
state-branch tags -- Convert, Cancel, Save file, Start over, the progress bars --
have no bite yet: reaching a branch needs the state seam R38.5 extracts, and the
state matrix is R38.6/R38.7 by design.

Five mutations, all red on the named test alone: FORMAT_CHIPS deleted,
FILE_CARD_NAME deleted, detailRow's tag no longer derived from its label,
FileRow's no longer derived from its name, and JOIN_MORE given JOIN's value --
the last caught only by the uniqueness check, which is what it is for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-24 15:30:52 -05:00
co-authored by Claude Opus 5
parent 64c1a60a97
commit df2e42a2b3
6 changed files with 563 additions and 46 deletions
@@ -0,0 +1,196 @@
package org.libremediaconverter.convert
import android.net.Uri
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.media3.common.util.UnstableApi
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.EnginePreference
import org.libremediaconverter.model.InputKind
import org.libremediaconverter.model.InputProbe
import org.libremediaconverter.model.OutputFormat
import org.libremediaconverter.model.OutputSpec
import org.libremediaconverter.model.QualityTier
import org.libremediaconverter.model.Validation
import org.libremediaconverter.model.VideoCodec
import org.libremediaconverter.ui.TestTags
import org.robolectric.RobolectricTestRunner
/**
* Each leaf of the converter screen renders, and each tag it claims resolves to exactly one node.
*
* The defect this bites on is a tag that is not where the table says it is: dropped by a refactor
* that rewrote a `Modifier` chain, applied to the wrong one of two siblings, or duplicated onto a
* leaf that is rendered twice. None of that is visible at compile time -- a `testTag` is a string
* handed to a modifier -- and none of it shows up in the app either, because nothing but a test
* ever reads one.
*
* It has to be caught here rather than by the children that consume the tags. R38.2, R38.3 and
* R38.4 all *begin* by locating a node through one of these, so a tag that had quietly moved would
* surface as three unrelated PRs failing on a line their own diffs do not touch. Counting the nodes
* rather than asserting existence is deliberate: `onNodeWithTag` on two matches throws about
* ambiguity in one place and passes in another, so "exactly one" is the property worth pinning.
*
* Deliberately *not* the state matrix. Which affordances each `ConversionState` renders is R38.6,
* and it needs the state seam R38.5 extracts -- the branch buttons tagged in this change (Convert,
* Cancel, Save file, Start over, ...) therefore have no bite yet, which the PR body records.
*/
@UnstableApi
@RunWith(RobolectricTestRunner::class)
class ConverterLeafTagsTest {
@get:Rule
val composeRule = createDrainedComposeRule()
private fun assertResolvesToOneNode(tag: String) {
composeRule.onAllNodesWithTag(tag).assertCountEquals(1)
}
private fun input(sizeBytes: Long? = 12_345_678L, probe: InputProbe? = VIDEO_PROBE) = InputFile(
uri = Uri.parse("content://test/clip.mkv"),
displayName = "clip.mkv",
sizeBytes = sizeBytes,
probe = probe,
)
@Test
fun `the format picker tags its chip row`() {
composeRule.setContent { FormatPicker(OutputFormat.MP4_H264) {} }
assertResolvesToOneNode(TestTags.Converter.FORMAT_CHIPS)
}
@Test
fun `the quality picker tags its chip row`() {
composeRule.setContent { QualityPicker(QualityTier.FAST) {} }
assertResolvesToOneNode(TestTags.Converter.QUALITY_CHIPS)
}
@Test
fun `the engine picker tags its chip row`() {
composeRule.setContent { EnginePicker(EnginePreference.AUTO) {} }
assertResolvesToOneNode(TestTags.Converter.ENGINE_CHIPS)
}
@Test
fun `the advanced picker tags its toggle, which is all it renders while collapsed`() {
setAdvancedPicker()
assertResolvesToOneNode(TestTags.Converter.ADVANCED_TOGGLE)
composeRule.onNodeWithTag(TestTags.Converter.ADVANCED_PANEL).assertDoesNotExist()
}
/**
* The panel and its three rows only exist once the toggle has been clicked, which is R38.4's
* subject. Expanding is the only way to reach the tags at all, so the smoke test has to do it.
*/
@Test
fun `expanding the advanced picker tags the panel and each of its three chip rows`() {
setAdvancedPicker()
composeRule.onNodeWithTag(TestTags.Converter.ADVANCED_TOGGLE).performClick()
assertResolvesToOneNode(TestTags.Converter.ADVANCED_PANEL)
assertResolvesToOneNode(TestTags.Converter.ADVANCED_CONTAINER_CHIPS)
assertResolvesToOneNode(TestTags.Converter.ADVANCED_VIDEO_CHIPS)
assertResolvesToOneNode(TestTags.Converter.ADVANCED_AUDIO_CHIPS)
}
@Test
fun `the validation card tags itself and every suggestion on it`() {
composeRule.setContent {
ValidationError(
Validation.Invalid(
message = "WebM cannot hold H.264 video.",
suggestions = listOf(
OutputSpec(Container.MKV, VideoCodec.H264, AudioCodec.AAC),
OutputSpec(Container.WEBM, VideoCodec.VP9, AudioCodec.OPUS),
),
),
) {}
}
assertResolvesToOneNode(TestTags.Converter.VALIDATION_ERROR)
assertResolvesToOneNode(TestTags.Converter.suggestion(0))
assertResolvesToOneNode(TestTags.Converter.suggestion(1))
}
@Test
fun `the file card tags itself, its name and its size line`() {
composeRule.setContent { FileCard(input()) }
assertResolvesToOneNode(TestTags.Converter.FILE_CARD)
assertResolvesToOneNode(TestTags.Converter.FILE_CARD_NAME)
assertResolvesToOneNode(TestTags.Converter.FILE_CARD_BYTES)
}
/**
* Both writers of the note line get their own case. They are two separate `Text` calls in two
* branches that share one tag, so a test of either alone would leave the other unguarded.
*/
@Test
fun `the file card tags the note it shows while the probe is still running`() {
composeRule.setContent { FileCard(input(probe = null)) }
assertResolvesToOneNode(TestTags.Converter.FILE_CARD_NOTE)
}
@Test
fun `the file card tags the note it shows when nothing could read the file`() {
composeRule.setContent { FileCard(input(probe = InputProbe(kind = InputKind.UNPARSEABLE))) }
assertResolvesToOneNode(TestTags.Converter.FILE_CARD_NOTE)
}
@Test
fun `a detail row tags itself with the label it renders`() {
composeRule.setContent { DetailRow("Container", "Matroska") }
assertResolvesToOneNode(TestTags.Converter.detailRow("Container"))
}
/** The rows the file card builds carry the same per-label tags, one per row it renders. */
@Test
fun `the file card's detail rows are each tagged by their own label`() {
composeRule.setContent { FileCard(input()) }
assertResolvesToOneNode(TestTags.Converter.detailRow("Container"))
assertResolvesToOneNode(TestTags.Converter.detailRow("Video"))
assertResolvesToOneNode(TestTags.Converter.detailRow("Audio"))
assertResolvesToOneNode(TestTags.Converter.detailRow("Length"))
}
private fun setAdvancedPicker() {
composeRule.setContent {
AdvancedPicker(
spec = OutputSpec(Container.MP4, VideoCodec.H264, AudioCodec.AAC),
validation = Validation.Valid,
onContainer = {},
onVideoCodec = {},
onAudioCodec = {},
onSuggestion = {},
)
}
}
private companion object {
val VIDEO_PROBE = InputProbe(
videoCodec = "video/avc",
audioCodec = "audio/mp4a-latm",
durationMs = 90_000,
kind = InputKind.VIDEO,
container = Container.MKV,
width = 1920,
height = 1080,
)
}
}
@@ -0,0 +1,53 @@
package org.libremediaconverter.join
import android.net.Uri
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.media3.common.util.UnstableApi
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.ui.TestTags
import org.robolectric.RobolectricTestRunner
/**
* The join screen's one leaf renders, and tags itself with the file it is showing.
*
* `FileRow` is the only place on either screen where the same leaf is rendered more than once at a
* time -- one row per picked input -- so it is the only tag that cannot be a constant. It is
* derived from `displayName`, inside `FileRow` itself, and that is the part worth a test: a row
* that took its tag from the call site would let R38.7 pass a tag in and assert nothing, which is
* the vacuous shape `CLAUDE.md` records nine of in one review.
*
* Two rows are rendered here rather than one, because a tag derived from the wrong thing -- a
* constant, an index the row does not have -- would still resolve to one node with a single input
* on screen.
*
* Deliberately not the state matrix: which affordances each `JoinState` renders is R38.7.
*/
@UnstableApi
@RunWith(RobolectricTestRunner::class)
class JoinLeafTagsTest {
@get:Rule
val composeRule = createDrainedComposeRule()
private fun input(displayName: String) = InputFile(
uri = Uri.parse("content://test/$displayName"),
displayName = displayName,
sizeBytes = 4_000_000L,
)
@Test
fun `each file row is tagged with the name it displays`() {
composeRule.setContent {
FileRow(input("first.mp4"))
FileRow(input("second.mp4"))
}
composeRule.onAllNodesWithTag(TestTags.Join.fileRow("first.mp4")).assertCountEquals(1)
composeRule.onAllNodesWithTag(TestTags.Join.fileRow("second.mp4")).assertCountEquals(1)
}
}
@@ -0,0 +1,40 @@
package org.libremediaconverter.ui
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* No two entries of [TestTags] may share a value.
*
* A duplicated value is the one mistake this table invites -- the constants are added in blocks of
* near-identical lines, and a copy-paste that keeps the old string still compiles, still reads
* correctly at the call site, and still passes every test in the file that placed it. It surfaces
* later, in someone else's PR, as an affordance that "resolves to exactly one node" finding two,
* with nothing in that diff to explain it.
*
* Read by reflection rather than from a hand-written list, because a hand-written list would be a
* second copy of the table with the same copy-paste failure in it.
*/
class TagTableUniquenessTest {
private fun tagsIn(vararg holders: Class<*>): List<String> = holders.flatMap { holder ->
holder.declaredFields
.filter { it.type == String::class.java }
.map { it.get(null) as String }
}
@Test
fun `every tag constant has its own value`() {
val tags = tagsIn(
TestTags::class.java,
TestTags.Converter::class.java,
TestTags.Join::class.java,
)
// Without this the check would pass on an empty list, which is what a reflection call
// that stopped finding the constants would hand it.
assertTrue("reflection found only ${tags.size} tag constants, so it is not reading the table", tags.size > 20)
assertEquals(emptyList<String>(), tags.groupBy { it }.filterValues { it.size > 1 }.keys.toList())
}
}