Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f65578b1f7 | ||
|
|
b38ad6a683 | ||
|
|
9a0f494e26 | ||
|
|
f3478706b3 | ||
|
|
61c400d2c6 | ||
|
|
d83775d5c6 | ||
|
|
e90f5a801c | ||
|
|
68015b3374 | ||
|
|
7e09f010c7 | ||
|
|
49249be280 |
@@ -0,0 +1,89 @@
|
||||
package org.libremediaconverter.ui.theme
|
||||
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/**
|
||||
* The theme called the way the app calls it: with no arguments at all.
|
||||
*
|
||||
* [ThemeColorSchemeTest] resolves every branch of the `when` and always passes `darkTheme`
|
||||
* explicitly, so the `$default` bridge is never entered and **`isSystemInDarkTheme()` is never
|
||||
* called**. `MainActivity.kt:79` is its only default-argument caller and does not execute on the
|
||||
* JVM, which left the app's actual call shape the one nothing exercised —
|
||||
* `LibreMediaConverterTheme` reported `mi=21, mb=6, cb=12` at method level.
|
||||
*
|
||||
* ## Not #68
|
||||
*
|
||||
* #68 is about the two **unreachable** arms, `DarkColorScheme` and `LightColorScheme`, which cannot
|
||||
* run because `dynamicColor` is always `true` and nothing can flip it. That is an open product
|
||||
* decision. This is the reachable half — whether the default follows the system — and closing it
|
||||
* does not close that.
|
||||
*
|
||||
* ## Why the assertion compares schemes rather than reading a number
|
||||
*
|
||||
* A luminance threshold would be a guess about the device palette. What is asserted instead is that
|
||||
* the no-argument call resolves to **the same scheme** an explicit `darkTheme` of the matching
|
||||
* value does, and a different one from its opposite. That holds whatever palette the platform
|
||||
* hands back, and it is exactly the claim: the default reads the system rather than picking a side.
|
||||
*
|
||||
* Both schemes are resolved in one composition because `setContent` may be called once per test.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class ThemeFollowsSystemTest {
|
||||
|
||||
@get:Rule
|
||||
val composeRule = createComposeRule()
|
||||
|
||||
@Test
|
||||
@Config(qualifiers = "+night")
|
||||
fun `with no arguments the theme follows a system in dark mode`() {
|
||||
val resolved = resolve()
|
||||
|
||||
assertEquals("the default must resolve what darkTheme = true does", resolved.dark, resolved.bare)
|
||||
assertNotEquals(resolved.light, resolved.bare)
|
||||
}
|
||||
|
||||
@Test
|
||||
@Config(qualifiers = "+notnight")
|
||||
fun `with no arguments the theme follows a system in light mode`() {
|
||||
val resolved = resolve()
|
||||
|
||||
assertEquals("the default must resolve what darkTheme = false does", resolved.light, resolved.bare)
|
||||
assertNotEquals(resolved.dark, resolved.bare)
|
||||
}
|
||||
|
||||
/**
|
||||
* The three colours are read together as one value, because any single one could coincide
|
||||
* between the two schemes on some palette while the schemes themselves differ. Background is
|
||||
* what dark mode is chiefly about; primary and surface are along to make a coincidence
|
||||
* implausible rather than merely unlikely.
|
||||
*/
|
||||
private data class Fingerprint(val background: Long, val primary: Long, val surface: Long)
|
||||
|
||||
private fun ColorScheme.fingerprint() =
|
||||
Fingerprint(background.value.toLong(), primary.value.toLong(), surface.value.toLong())
|
||||
|
||||
private class Resolved(val bare: Fingerprint, val dark: Fingerprint, val light: Fingerprint)
|
||||
|
||||
private fun resolve(): Resolved {
|
||||
lateinit var bare: Fingerprint
|
||||
lateinit var dark: Fingerprint
|
||||
lateinit var light: Fingerprint
|
||||
composeRule.setContent {
|
||||
// No arguments — the call MainActivity makes, and the one nothing exercised.
|
||||
LibreMediaConverterTheme { bare = MaterialTheme.colorScheme.fingerprint() }
|
||||
LibreMediaConverterTheme(darkTheme = true) { dark = MaterialTheme.colorScheme.fingerprint() }
|
||||
LibreMediaConverterTheme(darkTheme = false) { light = MaterialTheme.colorScheme.fingerprint() }
|
||||
}
|
||||
composeRule.waitForIdle()
|
||||
return Resolved(bare, dark, light)
|
||||
}
|
||||
}
|
||||
@@ -21,8 +21,10 @@ import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.convert.ConversionDependencies
|
||||
import org.libremediaconverter.convert.HardwareTranscoder
|
||||
import org.libremediaconverter.convert.SoftwareTranscoder
|
||||
import org.libremediaconverter.convert.installTestWorkManager
|
||||
import org.libremediaconverter.model.Container
|
||||
import org.libremediaconverter.model.ConversionRequest
|
||||
import org.libremediaconverter.model.DeviceCodecs
|
||||
import org.libremediaconverter.model.EnginePreference
|
||||
@@ -129,6 +131,40 @@ class ProgressNotificationTest {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The same plumbing on the engine most conversions actually use, which had none.
|
||||
*
|
||||
* `ConversionWorker.kt:208-210` is a second `onProgress` lambda at a second call site — the one
|
||||
* handed to `engine.transcode` — and it reported `ci == 0`. Every test above drives the FFmpeg
|
||||
* path; `HardwareFallbackTest` reaches `runMedia3OrFallBack` but its recording transcoder
|
||||
* records the call and never invokes the callback it was given. So the two engines' progress
|
||||
* wiring was one tested and one not, and the untested one is the default: `ConversionRouter`
|
||||
* sends everything it can to Media3.
|
||||
*
|
||||
* `AUTO` with a real H.264 probe, because `FORCE_SOFTWARE` is precisely what keeps the other
|
||||
* tests out of this branch. The probe and the permissive codec profile are what let the router
|
||||
* choose Media3 at all — `InputProbe()` reports `UNPARSEABLE`, which routes straight to FFmpeg.
|
||||
*
|
||||
* Asserted on the *percentage*, not merely on an update having happened: `publishProgress`
|
||||
* takes a display name and a percent, and replacing the percent with a constant compiles.
|
||||
*/
|
||||
@Test
|
||||
fun `progress from the hardware engine reaches WorkManager the same way FFmpeg's does`() {
|
||||
ConversionDependencies.probe = { _, _ -> H264_SOURCE }
|
||||
val reporting = ReportingHardwareTranscoder { onProgress -> onProgress(PERCENT) }
|
||||
ConversionDependencies.hardware = { reporting }
|
||||
|
||||
runBlocking { workerReporting(EnginePreference.AUTO) { }.doWork() }
|
||||
|
||||
assertEquals("the job must have gone to the hardware engine", 1, reporting.attempts)
|
||||
val progressUpdates = updater.infos.drop(1)
|
||||
assertEquals("one throttled progress update expected", 1, progressUpdates.size)
|
||||
assertEquals(
|
||||
PERCENT,
|
||||
progressUpdates.single().notification.extras.getInt(Notification.EXTRA_PROGRESS),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A worker routed to the software engine, whose engine is [report] and a written output.
|
||||
*
|
||||
@@ -137,7 +173,10 @@ class ProgressNotificationTest {
|
||||
* bridge, which is native. [report] is handed the worker's own progress callback, and runs with
|
||||
* the worker as its receiver so a test can stop it mid-transcode.
|
||||
*/
|
||||
private fun workerReporting(report: ConversionWorker.((Int) -> Unit) -> Unit): ConversionWorker {
|
||||
private fun workerReporting(
|
||||
enginePreference: EnginePreference = EnginePreference.FORCE_SOFTWARE,
|
||||
report: ConversionWorker.((Int) -> Unit) -> Unit,
|
||||
): ConversionWorker {
|
||||
val worker = TestListenableWorkerBuilder<ConversionWorker>(
|
||||
context = app,
|
||||
inputData = workDataOf(
|
||||
@@ -147,7 +186,7 @@ class ProgressNotificationTest {
|
||||
ConversionWorker.KEY_CONTAINER to SPEC.container.name,
|
||||
ConversionWorker.KEY_VIDEO_CODEC to SPEC.videoCodec.name,
|
||||
ConversionWorker.KEY_AUDIO_CODEC to SPEC.audioCodec.name,
|
||||
ConversionWorker.KEY_ENGINE_PREFERENCE to EnginePreference.FORCE_SOFTWARE.name,
|
||||
ConversionWorker.KEY_ENGINE_PREFERENCE to enginePreference.name,
|
||||
),
|
||||
runAttemptCount = 0,
|
||||
).setId(JOB_ID)
|
||||
@@ -171,6 +210,17 @@ class ProgressNotificationTest {
|
||||
const val TICKS = 50
|
||||
val SPEC = OutputFormat.MP4_H265.spec
|
||||
val JOB_ID: UUID = UUID.fromString("00000000-0000-4000-8000-000000000021")
|
||||
|
||||
/**
|
||||
* A probe the router can actually route. `InputProbe()` reports `UNPARSEABLE`, which
|
||||
* `PERMISSIVE.canDecode` refuses, so every job would reach FFmpeg with no test saying why.
|
||||
*/
|
||||
val H264_SOURCE = InputProbe(
|
||||
videoCodec = "h264",
|
||||
audioCodec = "aac",
|
||||
container = Container.MP4,
|
||||
durationMs = 1_000,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,3 +261,22 @@ private class ReportingTranscoder(private val report: ((Int) -> Unit) -> Unit) :
|
||||
const val OUTPUT_BYTES = 512
|
||||
}
|
||||
}
|
||||
|
||||
/** A hardware engine that reports whatever [report] wants reported, then writes an output. */
|
||||
@UnstableApi
|
||||
private class ReportingHardwareTranscoder(private val report: ((Int) -> Unit) -> Unit) : HardwareTranscoder {
|
||||
|
||||
var attempts = 0
|
||||
|
||||
override suspend fun transcode(input: Uri, output: File, request: ConversionRequest, onProgress: (Int) -> Unit) {
|
||||
attempts++
|
||||
report(onProgress)
|
||||
output.writeBytes(ByteArray(OUTPUT_BYTES))
|
||||
}
|
||||
|
||||
override fun close() = Unit
|
||||
|
||||
private companion object {
|
||||
const val OUTPUT_BYTES = 16
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user