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
@@ -33,6 +33,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -51,6 +52,7 @@ import org.libremediaconverter.model.VideoCodec
import org.libremediaconverter.ui.PrimaryButtonHeight
import org.libremediaconverter.ui.ScreenPaddingHorizontal
import org.libremediaconverter.ui.ScreenPaddingVertical
import org.libremediaconverter.ui.TestTags
import java.util.Locale
@UnstableApi
@@ -116,7 +118,8 @@ fun ConverterScreen(modifier: Modifier = Modifier, viewModel: ConversionViewMode
onClick = { pickInput.launch(arrayOf("*/*")) },
modifier = Modifier
.fillMaxWidth()
.height(PrimaryButtonHeight),
.height(PrimaryButtonHeight)
.testTag(TestTags.Converter.CHOOSE_FILE),
) { Text("Choose file") }
}
@@ -147,11 +150,16 @@ fun ConverterScreen(modifier: Modifier = Modifier, viewModel: ConversionViewMode
// The Advanced picker lets an impossible combination be selected on
// purpose, so this is what stops it from being run.
enabled = validation.isValid,
modifier = Modifier.fillMaxWidth().height(PrimaryButtonHeight),
modifier = Modifier
.fillMaxWidth()
.height(PrimaryButtonHeight)
.testTag(TestTags.Converter.CONVERT),
) { Text("Convert") }
OutlinedButton(
onClick = { pickInput.launch(arrayOf("*/*")) },
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.Converter.CHOOSE_DIFFERENT_FILE),
) { Text("Choose a different file") }
}
@@ -160,11 +168,13 @@ fun ConverterScreen(modifier: Modifier = Modifier, viewModel: ConversionViewMode
Text("Converting… ${s.percent}%")
LinearProgressIndicator(
progress = { s.percent / 100f },
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.Converter.PROGRESS),
)
OutlinedButton(
onClick = viewModel::cancel,
modifier = Modifier.fillMaxWidth(),
modifier = Modifier.fillMaxWidth().testTag(TestTags.CANCEL),
) { Text("Cancel") }
}
@@ -183,7 +193,7 @@ fun ConverterScreen(modifier: Modifier = Modifier, viewModel: ConversionViewMode
)
OutlinedButton(
onClick = viewModel::cancel,
modifier = Modifier.fillMaxWidth(),
modifier = Modifier.fillMaxWidth().testTag(TestTags.CANCEL),
) { Text("Cancel") }
}
@@ -202,11 +212,14 @@ fun ConverterScreen(modifier: Modifier = Modifier, viewModel: ConversionViewMode
}
Button(
onClick = { chooseDestination.launch(s.suggestedName) },
modifier = Modifier.fillMaxWidth().height(PrimaryButtonHeight),
modifier = Modifier
.fillMaxWidth()
.height(PrimaryButtonHeight)
.testTag(TestTags.SAVE_FILE),
) { Text("Save file") }
OutlinedButton(
onClick = viewModel::reset,
modifier = Modifier.fillMaxWidth(),
modifier = Modifier.fillMaxWidth().testTag(TestTags.START_OVER),
) { Text("Start over") }
}
@@ -214,7 +227,10 @@ fun ConverterScreen(modifier: Modifier = Modifier, viewModel: ConversionViewMode
Text("Saved ${s.displayName}.", style = MaterialTheme.typography.bodyLarge)
Button(
onClick = viewModel::reset,
modifier = Modifier.fillMaxWidth().height(PrimaryButtonHeight),
modifier = Modifier
.fillMaxWidth()
.height(PrimaryButtonHeight)
.testTag(TestTags.Converter.CONVERT_ANOTHER),
) { Text("Convert another") }
}
@@ -226,7 +242,10 @@ fun ConverterScreen(modifier: Modifier = Modifier, viewModel: ConversionViewMode
)
Button(
onClick = viewModel::reset,
modifier = Modifier.fillMaxWidth().height(PrimaryButtonHeight),
modifier = Modifier
.fillMaxWidth()
.height(PrimaryButtonHeight)
.testTag(TestTags.START_OVER),
) { Text("Start over") }
}
}
@@ -237,9 +256,12 @@ fun ConverterScreen(modifier: Modifier = Modifier, viewModel: ConversionViewMode
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun FormatPicker(selected: OutputFormat?, onSelect: (OutputFormat) -> Unit) {
internal fun FormatPicker(selected: OutputFormat?, onSelect: (OutputFormat) -> Unit) {
Text("Output format", style = MaterialTheme.typography.titleSmall)
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.testTag(TestTags.Converter.FORMAT_CHIPS),
) {
OutputFormat.entries.forEach { format ->
FilterChip(
selected = format == selected,
@@ -267,7 +289,7 @@ private fun FormatPicker(selected: OutputFormat?, onSelect: (OutputFormat) -> Un
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun AdvancedPicker(
internal fun AdvancedPicker(
spec: OutputSpec,
validation: Validation,
onContainer: (Container) -> Unit,
@@ -277,14 +299,23 @@ private fun AdvancedPicker(
) {
var expanded by rememberSaveable { mutableStateOf(false) }
TextButton(onClick = { expanded = !expanded }) {
TextButton(
onClick = { expanded = !expanded },
modifier = Modifier.testTag(TestTags.Converter.ADVANCED_TOGGLE),
) {
Text(if (expanded) "Hide advanced" else "Advanced")
}
AnimatedVisibility(visible = expanded) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Column(
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.testTag(TestTags.Converter.ADVANCED_PANEL),
) {
Text("Container", style = MaterialTheme.typography.titleSmall)
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.testTag(TestTags.Converter.ADVANCED_CONTAINER_CHIPS),
) {
Container.entries.forEach { container ->
FilterChip(
selected = container == spec.container,
@@ -295,7 +326,10 @@ private fun AdvancedPicker(
}
Text("Video", style = MaterialTheme.typography.titleSmall)
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.testTag(TestTags.Converter.ADVANCED_VIDEO_CHIPS),
) {
VideoCodec.entries.forEach { codec ->
FilterChip(
selected = codec == spec.videoCodec,
@@ -306,7 +340,10 @@ private fun AdvancedPicker(
}
Text("Audio", style = MaterialTheme.typography.titleSmall)
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.testTag(TestTags.Converter.ADVANCED_AUDIO_CHIPS),
) {
AudioCodec.entries.forEach { codec ->
FilterChip(
selected = codec == spec.audioCodec,
@@ -331,9 +368,11 @@ private fun AdvancedPicker(
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun ValidationError(invalid: Validation.Invalid, onSuggestion: (OutputSpec) -> Unit) {
internal fun ValidationError(invalid: Validation.Invalid, onSuggestion: (OutputSpec) -> Unit) {
Card(
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.Converter.VALIDATION_ERROR),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer,
@@ -347,10 +386,11 @@ private fun ValidationError(invalid: Validation.Invalid, onSuggestion: (OutputSp
if (invalid.suggestions.isNotEmpty()) {
Text("Try instead:", style = MaterialTheme.typography.labelMedium)
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
invalid.suggestions.forEach { suggestion ->
invalid.suggestions.forEachIndexed { index, suggestion ->
AssistChip(
onClick = { onSuggestion(suggestion) },
label = { Text(describe(suggestion)) },
modifier = Modifier.testTag(TestTags.Converter.suggestion(index)),
)
}
}
@@ -359,7 +399,7 @@ private fun ValidationError(invalid: Validation.Invalid, onSuggestion: (OutputSp
}
}
private fun describe(spec: OutputSpec): String {
internal fun describe(spec: OutputSpec): String {
val video = when (spec.videoCodec) {
VideoCodec.NONE -> null
else -> spec.videoCodec.label
@@ -374,9 +414,12 @@ private fun describe(spec: OutputSpec): String {
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun QualityPicker(selected: QualityTier, onSelect: (QualityTier) -> Unit) {
internal fun QualityPicker(selected: QualityTier, onSelect: (QualityTier) -> Unit) {
Text("Quality", style = MaterialTheme.typography.titleSmall)
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.testTag(TestTags.Converter.QUALITY_CHIPS),
) {
QualityTier.entries.forEach { tier ->
FilterChip(
selected = tier == selected,
@@ -390,9 +433,12 @@ private fun QualityPicker(selected: QualityTier, onSelect: (QualityTier) -> Unit
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun EnginePicker(selected: EnginePreference, onSelect: (EnginePreference) -> Unit) {
internal fun EnginePicker(selected: EnginePreference, onSelect: (EnginePreference) -> Unit) {
Text("Engine", style = MaterialTheme.typography.titleSmall)
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.testTag(TestTags.Converter.ENGINE_CHIPS),
) {
EnginePreference.entries.forEach { preference ->
FilterChip(
selected = preference == selected,
@@ -403,7 +449,7 @@ private fun EnginePicker(selected: EnginePreference, onSelect: (EnginePreference
}
}
private fun EnginePreference.label(): String = when (this) {
internal fun EnginePreference.label(): String = when (this) {
EnginePreference.AUTO -> "Automatic"
EnginePreference.PREFER_HARDWARE -> "Prefer hardware"
EnginePreference.FORCE_SOFTWARE -> "Force software"
@@ -418,21 +464,34 @@ private fun EnginePreference.label(): String = when (this) {
* pretending it has an unknown codec.
*/
@Composable
private fun FileCard(input: InputFile) {
Card(modifier = Modifier.fillMaxWidth()) {
internal fun FileCard(input: InputFile) {
Card(
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.Converter.FILE_CARD),
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(input.displayName, style = MaterialTheme.typography.titleMedium)
Text(
input.displayName,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.testTag(TestTags.Converter.FILE_CARD_NAME),
)
// The null is handled here rather than inside formatBytes, because "no provider would
// say" is not a number and a formatter that invented one -- "0 B" -- is the defect
// this card would be showing. It degrades in words, like the codec rows below it.
Text(
input.sizeBytes?.let(::formatBytes) ?: "Size unknown",
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.testTag(TestTags.Converter.FILE_CARD_BYTES),
)
val probe = input.probe
if (probe == null) {
Text("Reading…", style = MaterialTheme.typography.bodySmall)
Text(
"Reading…",
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.testTag(TestTags.Converter.FILE_CARD_NOTE),
)
return@Column
}
@@ -442,6 +501,7 @@ private fun FileCard(input: InputFile) {
InputKind.UNPARSEABLE -> Text(
"Could not identify this file. It will be converted with FFmpeg.",
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.testTag(TestTags.Converter.FILE_CARD_NOTE),
)
InputKind.IMAGE -> {
@@ -481,22 +541,23 @@ private fun FileCard(input: InputFile) {
}
@Composable
private fun DetailRow(label: String, value: String) {
internal fun DetailRow(label: String, value: String) {
Text(
"$label: $value",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.testTag(TestTags.Converter.detailRow(label)),
)
}
private fun formatDuration(ms: Long): String {
internal fun formatDuration(ms: Long): String {
val totalSeconds = ms / 1000
val minutes = totalSeconds / 60
val seconds = totalSeconds % 60
return String.format(Locale.US, "%d:%02d", minutes, seconds)
}
private fun formatBytes(bytes: Long): String = when {
internal fun formatBytes(bytes: Long): String = when {
bytes >= 1_000_000_000 -> String.format(Locale.US, "%.1f GB", bytes / 1e9)
bytes >= 1_000_000 -> String.format(Locale.US, "%.1f MB", bytes / 1e6)
bytes >= 1_000 -> String.format(Locale.US, "%.0f kB", bytes / 1e3)
@@ -21,6 +21,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -31,6 +32,7 @@ import org.libremediaconverter.model.ConcatStrategy
import org.libremediaconverter.ui.PrimaryButtonHeight
import org.libremediaconverter.ui.ScreenPaddingHorizontal
import org.libremediaconverter.ui.ScreenPaddingVertical
import org.libremediaconverter.ui.TestTags
import org.libremediaconverter.work.ConcatWorker
@UnstableApi
@@ -82,7 +84,8 @@ fun JoinScreen(modifier: Modifier = Modifier, viewModel: JoinViewModel = viewMod
onClick = { pickInputs.launch(arrayOf("video/*")) },
modifier = Modifier
.fillMaxWidth()
.height(PrimaryButtonHeight),
.height(PrimaryButtonHeight)
.testTag(TestTags.Join.CHOOSE_FILES),
) { Text("Choose files") }
}
@@ -97,11 +100,16 @@ fun JoinScreen(modifier: Modifier = Modifier, viewModel: JoinViewModel = viewMod
s.inputs.forEach { FileRow(it) }
Button(
onClick = viewModel::join,
modifier = Modifier.fillMaxWidth().height(PrimaryButtonHeight),
modifier = Modifier
.fillMaxWidth()
.height(PrimaryButtonHeight)
.testTag(TestTags.Join.JOIN),
) { Text("Join ${s.inputs.size} files") }
OutlinedButton(
onClick = { pickInputs.launch(arrayOf("video/*")) },
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.Join.CHOOSE_DIFFERENT_FILES),
) { Text("Choose different files") }
}
@@ -110,10 +118,14 @@ fun JoinScreen(modifier: Modifier = Modifier, viewModel: JoinViewModel = viewMod
// Indeterminate on purpose: FFmpeg reports progress against a
// single input's duration, which means nothing across a
// concatenation. A fabricated percentage would be worse than none.
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
LinearProgressIndicator(
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.Join.PROGRESS),
)
OutlinedButton(
onClick = viewModel::cancel,
modifier = Modifier.fillMaxWidth(),
modifier = Modifier.fillMaxWidth().testTag(TestTags.CANCEL),
) { Text("Cancel") }
}
@@ -127,7 +139,7 @@ fun JoinScreen(modifier: Modifier = Modifier, viewModel: JoinViewModel = viewMod
)
OutlinedButton(
onClick = viewModel::cancel,
modifier = Modifier.fillMaxWidth(),
modifier = Modifier.fillMaxWidth().testTag(TestTags.CANCEL),
) { Text("Cancel") }
}
@@ -146,11 +158,14 @@ fun JoinScreen(modifier: Modifier = Modifier, viewModel: JoinViewModel = viewMod
)
Button(
onClick = { chooseDestination.launch(s.suggestedName) },
modifier = Modifier.fillMaxWidth().height(PrimaryButtonHeight),
modifier = Modifier
.fillMaxWidth()
.height(PrimaryButtonHeight)
.testTag(TestTags.SAVE_FILE),
) { Text("Save file") }
OutlinedButton(
onClick = viewModel::reset,
modifier = Modifier.fillMaxWidth(),
modifier = Modifier.fillMaxWidth().testTag(TestTags.START_OVER),
) { Text("Start over") }
}
@@ -158,7 +173,10 @@ fun JoinScreen(modifier: Modifier = Modifier, viewModel: JoinViewModel = viewMod
Text("Saved ${s.displayName}.", style = MaterialTheme.typography.bodyLarge)
Button(
onClick = viewModel::reset,
modifier = Modifier.fillMaxWidth().height(PrimaryButtonHeight),
modifier = Modifier
.fillMaxWidth()
.height(PrimaryButtonHeight)
.testTag(TestTags.Join.JOIN_MORE),
) { Text("Join more") }
}
@@ -170,7 +188,10 @@ fun JoinScreen(modifier: Modifier = Modifier, viewModel: JoinViewModel = viewMod
)
Button(
onClick = viewModel::reset,
modifier = Modifier.fillMaxWidth().height(PrimaryButtonHeight),
modifier = Modifier
.fillMaxWidth()
.height(PrimaryButtonHeight)
.testTag(TestTags.START_OVER),
) { Text("Start over") }
}
}
@@ -180,8 +201,12 @@ fun JoinScreen(modifier: Modifier = Modifier, viewModel: JoinViewModel = viewMod
}
@Composable
private fun FileRow(input: InputFile) {
Card(modifier = Modifier.fillMaxWidth()) {
internal fun FileRow(input: InputFile) {
Card(
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.Join.fileRow(input.displayName)),
) {
Column(modifier = Modifier.padding(12.dp)) {
Text(input.displayName, style = MaterialTheme.typography.bodyMedium)
}
@@ -0,0 +1,142 @@
package org.libremediaconverter.ui
/**
* Where a test finds each affordance on the two screens.
*
* Every button, picker and card in `ConverterScreen` and `JoinScreen` carries one of these through
* `Modifier.testTag`, so a test names a symbol and never a literal. That is the whole reason the
* table exists: `"Cancel"`, `"Start over"` and `"Save file"` are each rendered by both screens and
* by more than one state branch, so rewording one of them would otherwise redden several
* independent test files at once, and none of those diffs would explain why.
*
* Tags are applied inside `main`, never handed in by the caller. A tag a test passes down as a
* `Modifier` proves only that the test set it -- it would stay green with the affordance's own tag
* deleted, which is exactly the vacuous test `CLAUDE.md` records nine of.
*
* ### Public rather than `internal`, deliberately
*
* `androidTest` **is** a friend source set of `main` here: an `androidTest` file referencing the
* `internal` `Destination.CONVERT` compiles clean through `:app:compileDebugAndroidTestKotlin`
* under AGP 9.3.1 (measured 2026-08-24 -- nothing in the repo referenced a main `internal` from
* `androidTest`, so the question had no in-tree answer until then). `internal` would compile today.
*
* It is public anyway. That friendship is AGP wiring rather than something this project states, and
* this table is a contract read from three source sets: `main` applies the tags, `src/test` and
* `src/androidTest` name them. Public buys no external exposure in an application module -- nothing
* consumes it from outside -- so the durable answer costs nothing here.
*
* ### Invariants
*
* Values are distinct, which `TagTableUniquenessTest` asserts. Two affordances sharing a tag would
* break the "resolves to exactly one node" assertion in a file nobody had touched.
*/
object TestTags {
/**
* Affordances both screens render, under one name each.
*
* Shared rather than per-screen because only one screen is composed at a time -- the shell
* swaps them -- so a tag can only ever resolve within the screen under test.
*/
const val CANCEL: String = "action.cancel"
/** Rendered by `Converted`/`Joined` and again by `Failed` on both screens. */
const val START_OVER: String = "action.startOver"
const val SAVE_FILE: String = "action.saveFile"
/** `ConverterScreen`. */
object Converter {
const val CHOOSE_FILE: String = "converter.chooseFile"
const val CONVERT: String = "converter.convert"
const val CHOOSE_DIFFERENT_FILE: String = "converter.chooseDifferentFile"
const val CONVERT_ANOTHER: String = "converter.convertAnother"
/** The determinate bar in `Converting`. It carries no text, so nothing else can find it. */
const val PROGRESS: String = "converter.progress"
const val FILE_CARD: String = "converter.fileCard"
const val FILE_CARD_NAME: String = "converter.fileCard.name"
/**
* The byte size, or `"Size unknown"`.
*
* Named for bytes rather than "size" because the `IMAGE` branch also renders a row labelled
* `Size` -- pixel dimensions -- through [detailRow], and the two mean different things.
*/
const val FILE_CARD_BYTES: String = "converter.fileCard.bytes"
/**
* The one-line explanation that stands in for the detail rows: `"Reading…"` while the probe
* is still running, or the unreadable-file line once it has finished and found nothing.
* The two are mutually exclusive, so one tag covers both.
*/
const val FILE_CARD_NOTE: String = "converter.fileCard.note"
/**
* The chip rows, not the pickers around them.
*
* Each tag sits on the `FlowRow` of chips, so the prose a picker renders beside it -- the
* `"Custom — set below."` line under the formats, the tier description under the quality
* chips -- is outside the tagged node. Tagging the picker as a whole would mean wrapping
* three sibling emissions in a layout that does not exist today.
*/
const val FORMAT_CHIPS: String = "converter.formatChips"
const val QUALITY_CHIPS: String = "converter.qualityChips"
const val ENGINE_CHIPS: String = "converter.engineChips"
/** The `Advanced` / `Hide advanced` toggle. Present whether or not the panel is open. */
const val ADVANCED_TOGGLE: String = "converter.advanced.toggle"
/** The panel the toggle gates. Absent from the tree while collapsed. */
const val ADVANCED_PANEL: String = "converter.advanced.panel"
/**
* The three chip rows inside the panel, separately.
*
* Separately because their labels collide: `"Copy"` and `"None"` are both a `VideoCodec`
* and an `AudioCodec`, and `"MP3"` and `"FLAC"` are both a `Container` and an `AudioCodec`,
* so a text matcher over the open panel is ambiguous for four of the chips.
*/
const val ADVANCED_CONTAINER_CHIPS: String = "converter.advanced.containerChips"
const val ADVANCED_VIDEO_CHIPS: String = "converter.advanced.videoChips"
const val ADVANCED_AUDIO_CHIPS: String = "converter.advanced.audioChips"
/** The error card. Rendered outside the panel, so it is reachable while collapsed. */
const val VALIDATION_ERROR: String = "converter.validationError"
/** One detail line of the file card, by the label it renders: `Container`, `Video`, ... */
fun detailRow(label: String): String = "converter.fileCard.row:$label"
/**
* One suggested output on the validation card, by position.
*
* By position rather than by the text of the suggestion, because that text comes from
* `describe`, which is itself under test -- a tag derived from it would move whenever the
* thing it is meant to locate changed.
*/
fun suggestion(index: Int): String = "converter.validationError.suggestion:$index"
}
/** `JoinScreen`. */
object Join {
const val CHOOSE_FILES: String = "join.chooseFiles"
const val JOIN: String = "join.join"
const val CHOOSE_DIFFERENT_FILES: String = "join.chooseDifferentFiles"
const val JOIN_MORE: String = "join.joinMore"
/** The indeterminate bar in `Joining`. */
const val PROGRESS: String = "join.progress"
/**
* One picked input, by the name it displays.
*
* By name rather than by position, so the tag is derived from data the row already holds
* and can stay inside `FileRow`. Passing an index down would mean the call site owned the
* tag, and a test that supplies its own tag asserts nothing about the screen.
*/
fun fileRow(displayName: String): String = "join.fileRow:$displayName"
}
}