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:
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user