Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eded47d666 | ||
|
|
b41341a1cb |
@@ -24,9 +24,11 @@ import androidx.compose.runtime.saveable.Saver
|
|||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
import org.libremediaconverter.convert.ConverterScreen
|
import org.libremediaconverter.convert.ConverterScreen
|
||||||
import org.libremediaconverter.join.JoinScreen
|
import org.libremediaconverter.join.JoinScreen
|
||||||
|
import org.libremediaconverter.ui.TestTags
|
||||||
import org.libremediaconverter.ui.theme.LibreMediaConverterTheme
|
import org.libremediaconverter.ui.theme.LibreMediaConverterTheme
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -114,7 +116,7 @@ internal fun AppRoot(
|
|||||||
|
|
||||||
if (useRail) {
|
if (useRail) {
|
||||||
Row(modifier = Modifier.fillMaxSize()) {
|
Row(modifier = Modifier.fillMaxSize()) {
|
||||||
NavigationRail {
|
NavigationRail(modifier = Modifier.testTag(TestTags.Shell.NAVIGATION_RAIL)) {
|
||||||
Destination.entries.forEach { item ->
|
Destination.entries.forEach { item ->
|
||||||
NavigationRailItem(
|
NavigationRailItem(
|
||||||
selected = destination == item,
|
selected = destination == item,
|
||||||
@@ -132,7 +134,7 @@ internal fun AppRoot(
|
|||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
bottomBar = {
|
bottomBar = {
|
||||||
NavigationBar {
|
NavigationBar(modifier = Modifier.testTag(TestTags.Shell.NAVIGATION_BAR)) {
|
||||||
Destination.entries.forEach { item ->
|
Destination.entries.forEach { item ->
|
||||||
NavigationBarItem(
|
NavigationBarItem(
|
||||||
selected = destination == item,
|
selected = destination == item,
|
||||||
|
|||||||
@@ -56,6 +56,20 @@ object TestTags {
|
|||||||
*/
|
*/
|
||||||
const val RETRY_SAVE: String = "action.retrySave"
|
const val RETRY_SAVE: String = "action.retrySave"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The adaptive shell around both screens -- `AppRoot`'s two layouts.
|
||||||
|
*
|
||||||
|
* Named because there is no other way to tell them apart from a test. Both render the same two
|
||||||
|
* destinations with the same labels and the same selection state, so every assertion that could
|
||||||
|
* be written without these tags is satisfied by either layout, and transposing the two bodies
|
||||||
|
* passed the whole suite. Exactly one of the two exists at a time, which is what makes
|
||||||
|
* `assertExists` / `assertDoesNotExist` on this pair a statement about the width class.
|
||||||
|
*/
|
||||||
|
object Shell {
|
||||||
|
const val NAVIGATION_RAIL: String = "shell.navigationRail"
|
||||||
|
const val NAVIGATION_BAR: String = "shell.navigationBar"
|
||||||
|
}
|
||||||
|
|
||||||
/** `ConverterScreen`. */
|
/** `ConverterScreen`. */
|
||||||
object Converter {
|
object Converter {
|
||||||
const val CHOOSE_FILE: String = "converter.chooseFile"
|
const val CHOOSE_FILE: String = "converter.chooseFile"
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
package org.libremediaconverter
|
||||||
|
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
|
||||||
|
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||||
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.compose.ui.test.performClick
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.work.Data
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.libremediaconverter.convert.ConversionDependencies
|
||||||
|
import org.libremediaconverter.convert.installTestWorkManager
|
||||||
|
import org.libremediaconverter.model.InputProbe
|
||||||
|
import org.libremediaconverter.ui.TestTags
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.RuntimeEnvironment
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which navigation affordance the shell actually renders, and which screen it actually shows.
|
||||||
|
*
|
||||||
|
* Assertion gaps rather than coverage gaps, both of them, and that is why they lasted.
|
||||||
|
* `AppRootRestorationTest` already drives `AppRoot` at `Compact` and `Expanded`, so JaCoCo is green
|
||||||
|
* on `useRail` -- but it asserts only that the *selected tab* survives recreation, through a stub
|
||||||
|
* `content` composable. Nothing anywhere queried for a rail or a bar, and nothing rendered the real
|
||||||
|
* screens. Two consequences, both measured before this file existed:
|
||||||
|
*
|
||||||
|
* - **Transposing the `NavigationRail` and `NavigationBar` bodies passed the entire suite.**
|
||||||
|
* - **Transposing `Content`'s two arms passed it too** -- a tablet showing the phone chrome, or the
|
||||||
|
* Convert tab opening the Join screen, and 546 tests with nothing to say about either.
|
||||||
|
*
|
||||||
|
* `AppRoot`'s own KDoc is why this matters more than it looks: from targetSdk 37 the app is resized
|
||||||
|
* and rotated whether or not it is ready, so the width class is not a preference, it is whatever
|
||||||
|
* the system hands over.
|
||||||
|
*
|
||||||
|
* ## Two things this needed that the rest of the suite does not
|
||||||
|
*
|
||||||
|
* **`createAndroidComposeRule`, not `createComposeRule`.** Rendering `AppRoot` with its *default*
|
||||||
|
* content reaches `ConverterScreen`'s `viewModel = viewModel()`, which needs a
|
||||||
|
* `ViewModelStoreOwner`; the plain rule supplies none. It works because both ViewModels are
|
||||||
|
* `@JvmOverloads constructor(app: Application, …)`, so `AndroidViewModelFactory` can build them,
|
||||||
|
* and because `app/build.gradle.kts` already puts `ui-test-manifest`'s `ComponentActivity` in the
|
||||||
|
* merged manifest the unit tests build against -- which that file says in terms.
|
||||||
|
*
|
||||||
|
* **Tags on the two bars.** They are in `TestTags`, applied inside `main`, for the reason that
|
||||||
|
* file's KDoc gives: a tag the test hands down proves only that the test set it.
|
||||||
|
*/
|
||||||
|
@UnstableApi
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
class AdaptiveShellTest {
|
||||||
|
|
||||||
|
@get:Rule
|
||||||
|
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
val app = RuntimeEnvironment.getApplication()
|
||||||
|
installTestWorkManager(app, Data.EMPTY)
|
||||||
|
// The real screens are composed here, so their ViewModels are real too. Neither test is
|
||||||
|
// about probing or publishing; left alone they would reach the FFprobe loader and this
|
||||||
|
// machine's codec list, and decide things no assertion mentions.
|
||||||
|
ConversionDependencies.probe = { _, _ -> InputProbe() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
ConversionDependencies.reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a phone gets the bottom bar and a tablet gets the rail`() {
|
||||||
|
setShell(WindowWidthSizeClass.Compact)
|
||||||
|
|
||||||
|
composeRule.onNodeWithTag(TestTags.Shell.NAVIGATION_BAR).assertExists()
|
||||||
|
composeRule.onNodeWithTag(TestTags.Shell.NAVIGATION_RAIL).assertDoesNotExist()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an expanded window gets the rail`() {
|
||||||
|
setShell(WindowWidthSizeClass.Expanded)
|
||||||
|
|
||||||
|
composeRule.onNodeWithTag(TestTags.Shell.NAVIGATION_RAIL).assertExists()
|
||||||
|
composeRule.onNodeWithTag(TestTags.Shell.NAVIGATION_BAR).assertDoesNotExist()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The width class no test had ever passed.
|
||||||
|
*
|
||||||
|
* `useRail` is `!= Compact`, so Medium takes the rail with Expanded. Narrowing it to
|
||||||
|
* `== Expanded` is a one-character change that breaks every tablet and unfolded foldable and
|
||||||
|
* nothing else -- and until this test, nothing in either source set used `Medium` at all.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `a medium window is a rail window, not a phone`() {
|
||||||
|
setShell(WindowWidthSizeClass.Medium)
|
||||||
|
|
||||||
|
composeRule.onNodeWithTag(TestTags.Shell.NAVIGATION_RAIL).assertExists()
|
||||||
|
composeRule.onNodeWithTag(TestTags.Shell.NAVIGATION_BAR).assertDoesNotExist()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The mapping every other test stubs out: which screen each destination actually opens.
|
||||||
|
*
|
||||||
|
* Matched on each screen's own "choose a file" affordance rather than on a title, because those
|
||||||
|
* tags are applied by the screens themselves -- so this fails if the destinations are
|
||||||
|
* transposed, and it fails for the right reason.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `Convert opens the converter and Join opens the join screen`() {
|
||||||
|
setShell(WindowWidthSizeClass.Compact)
|
||||||
|
|
||||||
|
composeRule.onNodeWithTag(TestTags.Converter.CHOOSE_FILE).assertExists()
|
||||||
|
composeRule.onNodeWithTag(TestTags.Join.CHOOSE_FILES).assertDoesNotExist()
|
||||||
|
|
||||||
|
composeRule.onNodeWithText(Destination.JOIN.label).performClick()
|
||||||
|
|
||||||
|
composeRule.onNodeWithTag(TestTags.Join.CHOOSE_FILES).assertExists()
|
||||||
|
composeRule.onNodeWithTag(TestTags.Converter.CHOOSE_FILE).assertDoesNotExist()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** [AppRoot] with its real content, which is the half nothing else composes. */
|
||||||
|
private fun setShell(width: WindowWidthSizeClass) {
|
||||||
|
composeRule.setContent { AppRoot(width) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -166,6 +166,30 @@ class FFmpegCommandBuilderTest {
|
|||||||
assertPair(cmd(OutputFormat.OPUS), "-c:a", "libopus")
|
assertPair(cmd(OutputFormat.OPUS), "-c:a", "libopus")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The arm most conversions actually take, and the only one in `audioArgs` with no test.
|
||||||
|
*
|
||||||
|
* `flac wav and opus select the right encoders` above covers the three named arms; MP3 has its
|
||||||
|
* own. AAC arrives through the `else`, so nothing named it and nothing pinned either half of
|
||||||
|
* what it emits -- neither `aac` nor `192k` appeared anywhere in this file. Both are shipped
|
||||||
|
* defaults: MP4 and M4A are the formats the picker offers first, so this is the audio
|
||||||
|
* every ordinary conversion gets.
|
||||||
|
*
|
||||||
|
* The bitrate is asserted as well as the encoder because it is the half a refactor is likelier
|
||||||
|
* to lose. An `-b:a` that quietly changed would not fail anything, would not look wrong in a
|
||||||
|
* command line, and would show up only as files that sound different from the ones the app
|
||||||
|
* produced last month.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `aac is the default encoder, at the bitrate the app ships`() {
|
||||||
|
assertPair(cmd(OutputFormat.MP4_H264), "-c:a", "aac")
|
||||||
|
assertPair(cmd(OutputFormat.MP4_H264), "-b:a", "192k")
|
||||||
|
// Through the `else` rather than through a named arm, so an AAC branch added above it later
|
||||||
|
// has to keep answering the same way.
|
||||||
|
assertPair(cmd(OutputFormat.M4A_AAC), "-c:a", "aac")
|
||||||
|
assertPair(cmd(OutputFormat.M4A_AAC), "-b:a", "192k")
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `audio only formats never carry a video encoder`() {
|
fun `audio only formats never carry a video encoder`() {
|
||||||
listOf(OutputFormat.MP3, OutputFormat.FLAC, OutputFormat.WAV, OutputFormat.OPUS)
|
listOf(OutputFormat.MP3, OutputFormat.FLAC, OutputFormat.WAV, OutputFormat.OPUS)
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ class TagTableUniquenessTest {
|
|||||||
fun `every tag constant has its own value`() {
|
fun `every tag constant has its own value`() {
|
||||||
val tags = tagsIn(
|
val tags = tagsIn(
|
||||||
TestTags::class.java,
|
TestTags::class.java,
|
||||||
|
TestTags.Shell::class.java,
|
||||||
TestTags.Converter::class.java,
|
TestTags.Converter::class.java,
|
||||||
TestTags.Join::class.java,
|
TestTags.Join::class.java,
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user