Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f65578b1f7 | ||
|
|
b38ad6a683 | ||
|
|
9a0f494e26 | ||
|
|
f3478706b3 | ||
|
|
61c400d2c6 | ||
|
|
d83775d5c6 | ||
|
|
e90f5a801c | ||
|
|
68015b3374 | ||
|
|
7e09f010c7 | ||
|
|
49249be280 | ||
|
|
2125763ebf |
@@ -221,12 +221,39 @@ object MediaProbe {
|
||||
null
|
||||
}
|
||||
|
||||
private fun readMediaInformation(path: String): FFprobeInfo? {
|
||||
// ffmpeg-kit-next is compiled from Kotlin with private backing fields, so these have to go
|
||||
// through the Java getters rather than property syntax.
|
||||
val info: MediaInformation = FFprobeKit.getMediaInformation(path).getMediaInformation()
|
||||
?: return null
|
||||
/**
|
||||
* The thin edge: spawn FFprobe, hand what it said to [ffprobeInfoFrom].
|
||||
*
|
||||
* Everything device-bound is on this line and the null check under it. What FFprobe *said* is a
|
||||
* `MediaInformation`, which is an ordinary object over a `JSONObject` — so the reading of it is
|
||||
* a decision a test can choose the inputs for, and it lives below rather than here.
|
||||
*/
|
||||
private fun readMediaInformation(path: String): FFprobeInfo? =
|
||||
FFprobeKit.getMediaInformation(path).getMediaInformation()?.let(::ffprobeInfoFrom)
|
||||
|
||||
/**
|
||||
* What FFprobe's answer means, as a function of the answer alone.
|
||||
*
|
||||
* `internal` for the same reason [Extracted] and [FFprobeInfo] are: a test cannot name it
|
||||
* otherwise, and the JVM test source set is a friend of `main`.
|
||||
*
|
||||
* **JVM-safe, verified rather than assumed.** `javap` over the committed AAR's runtime jar:
|
||||
* `MediaInformation(JSONObject, List<StreamInformation>, List<Chapter>)` and
|
||||
* `StreamInformation(JSONObject)` are plain public constructors, and neither class's `<clinit>`
|
||||
* touches the native library — so a test builds its own without `libffmpegkit` being present.
|
||||
* That is the whole reason this split is worth making: `readMediaInformation` was 114 missed
|
||||
* instructions and 24 missed branches, of which exactly one line needed a device.
|
||||
*
|
||||
* The subtle part is the **second argument to [containerFrom]**. `matroska,webm` is reported
|
||||
* for both MKV and WebM — they share a demuxer — so the video codec is the only thing that
|
||||
* separates them, and dropping it silently turns every VP9 WebM into an MKV. `containerFrom`
|
||||
* has thirty-three covered branches of its own and none of them can notice that, because the
|
||||
* mistake is at the call rather than in the callee.
|
||||
*
|
||||
* ffmpeg-kit-next is compiled from Kotlin with private backing fields, so these go through the
|
||||
* Java getters rather than property syntax.
|
||||
*/
|
||||
internal fun ffprobeInfoFrom(info: MediaInformation): FFprobeInfo {
|
||||
val streams = info.getStreams().orEmpty()
|
||||
val video = streams.firstOrNull { it.getType() == "video" }
|
||||
val audio = streams.firstOrNull { it.getType() == "audio" }
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
package org.libremediaconverter.convert
|
||||
|
||||
import com.arthenica.ffmpegkit.MediaInformation
|
||||
import com.arthenica.ffmpegkit.StreamInformation
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.model.Container
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
|
||||
/**
|
||||
* What FFprobe's answer means, read as a function of the answer alone.
|
||||
*
|
||||
* `readMediaInformation` was 114 missed instructions and 24 missed branches — the second-biggest
|
||||
* block on the wave-4 report — of which **exactly one line needed a device**:
|
||||
*
|
||||
* ```kotlin
|
||||
* FFprobeKit.getMediaInformation(path).getMediaInformation()
|
||||
* ```
|
||||
*
|
||||
* Everything after it reads an ordinary object. `javap` over the committed AAR's runtime jar:
|
||||
* `MediaInformation(JSONObject, List<StreamInformation>, List<Chapter>)` and
|
||||
* `StreamInformation(JSONObject)` are plain public constructors, and neither class's `<clinit>`
|
||||
* loads the native library — so the fixtures below are built without `libffmpegkit` present.
|
||||
*
|
||||
* ## The one that matters
|
||||
*
|
||||
* `containerFrom(formatName, video?.getCodec())`. FFprobe reports `matroska,webm` for **both** MKV
|
||||
* and WebM, because they share a demuxer, so the video codec is the only thing separating them.
|
||||
* `containerFrom` has thirty-three covered branches of its own and not one of them can notice the
|
||||
* argument being dropped — the mistake would be at the call, not in the callee, and every existing
|
||||
* `containerFrom` test would stay green while every VP9 WebM quietly became an MKV.
|
||||
*
|
||||
* Robolectric only for `org.json`, which is a stub in a plain JVM test.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class FFprobeMappingTest {
|
||||
|
||||
@Test
|
||||
fun `the video codec decides between matroska and webm`() {
|
||||
assertEquals(
|
||||
Container.WEBM,
|
||||
MediaProbe.ffprobeInfoFrom(info("matroska,webm", stream("video", "vp9"))).container,
|
||||
)
|
||||
assertEquals(
|
||||
Container.MKV,
|
||||
MediaProbe.ffprobeInfoFrom(info("matroska,webm", stream("video", "h264"))).container,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The same format name with no video stream at all, which is what makes the case above about
|
||||
* the *argument* rather than about the format string.
|
||||
*/
|
||||
@Test
|
||||
fun `a matroska container with no video track cannot be told from webm and is not guessed`() {
|
||||
val read = MediaProbe.ffprobeInfoFrom(info("matroska,webm", stream("audio", "opus")))
|
||||
|
||||
assertEquals(Container.MKV, read.container)
|
||||
assertNull(read.videoCodec)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the first stream of each type wins`() {
|
||||
val read = MediaProbe.ffprobeInfoFrom(
|
||||
info(
|
||||
"mov,mp4,m4a,3gp,3g2,mj2",
|
||||
stream("video", "h264", width = 1920, height = 1080),
|
||||
stream("video", "hevc", width = 640, height = 480),
|
||||
stream("audio", "aac"),
|
||||
stream("audio", "mp3"),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals("h264", read.videoCodec)
|
||||
assertEquals("aac", read.audioCodec)
|
||||
assertEquals(1920, read.width)
|
||||
assertEquals(1080, read.height)
|
||||
}
|
||||
|
||||
/**
|
||||
* Dimensions come from the stream the codec came from, not from whichever stream has some.
|
||||
*
|
||||
* The fixture is deliberately awkward: the chosen video stream carries **no** dimensions and a
|
||||
* later one does. That is a real shape — FFprobe omits `width`/`height` for a stream it could
|
||||
* not measure — and it is the only arrangement that separates the two readings.
|
||||
*
|
||||
* A first version of this file asserted the dimensions inside the case above, where the chosen
|
||||
* stream was also the first one carrying any. Replacing `video?.getWidth()` with
|
||||
* `streams.firstNotNullOfOrNull { it.getWidth() }` gave the same answer there and **the
|
||||
* mutation survived**. It reddens here.
|
||||
*/
|
||||
@Test
|
||||
fun `a video stream with no dimensions reports none rather than borrowing another stream's`() {
|
||||
val read = MediaProbe.ffprobeInfoFrom(
|
||||
info(
|
||||
"mov,mp4,m4a,3gp,3g2,mj2",
|
||||
stream("video", "h264"),
|
||||
stream("video", "hevc", width = 640, height = 480),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals("h264", read.videoCodec)
|
||||
assertEquals(0, read.width)
|
||||
assertEquals(0, read.height)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream order is the file's, not a promise. An audio-first container must read the same as a
|
||||
* video-first one.
|
||||
*/
|
||||
@Test
|
||||
fun `an audio track listed first does not become the video track`() {
|
||||
val read = MediaProbe.ffprobeInfoFrom(
|
||||
info("mov,mp4,m4a,3gp,3g2,mj2", stream("audio", "aac"), stream("video", "h264")),
|
||||
)
|
||||
|
||||
assertEquals("h264", read.videoCodec)
|
||||
assertEquals("aac", read.audioCodec)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a duration in seconds becomes milliseconds`() {
|
||||
assertEquals(12_345L, MediaProbe.ffprobeInfoFrom(info("mp4", duration = "12.345")).durationMs)
|
||||
}
|
||||
|
||||
/**
|
||||
* Both ways a duration can be absent, and neither may throw.
|
||||
*
|
||||
* FFprobe reports `"N/A"` for a stream it could not measure, and omits the key entirely for
|
||||
* some containers. `toDoubleOrNull` is what keeps the second from being an exception on the
|
||||
* file-pick path, where there is no user-visible failure to report it as.
|
||||
*/
|
||||
@Test
|
||||
fun `a duration that is not a number is no duration rather than a crash`() {
|
||||
assertEquals(0L, MediaProbe.ffprobeInfoFrom(info("mp4", duration = "N/A")).durationMs)
|
||||
assertEquals(0L, MediaProbe.ffprobeInfoFrom(info("mp4", duration = null)).durationMs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a file with no streams reports nothing rather than defaults that look measured`() {
|
||||
val read = MediaProbe.ffprobeInfoFrom(info("mp4"))
|
||||
|
||||
assertNull(read.videoCodec)
|
||||
assertNull(read.audioCodec)
|
||||
assertEquals(0, read.width)
|
||||
assertEquals(0, read.height)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an image format is reported as one`() {
|
||||
assertTrue(MediaProbe.ffprobeInfoFrom(info("png_pipe", stream("video", "png"))).isImage)
|
||||
assertFalse(MediaProbe.ffprobeInfoFrom(info("mp4", stream("video", "h264"))).isImage)
|
||||
}
|
||||
|
||||
private fun stream(type: String, codec: String, width: Int? = null, height: Int? = null) = StreamInformation(
|
||||
JSONObject().apply {
|
||||
put(StreamInformation.KEY_TYPE, type)
|
||||
put(StreamInformation.KEY_CODEC, codec)
|
||||
width?.let { put(StreamInformation.KEY_WIDTH, it) }
|
||||
height?.let { put(StreamInformation.KEY_HEIGHT, it) }
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* The format properties are **nested** under `"format"`, which is how FFprobe reports them and
|
||||
* what `MediaInformation` reads: `getFormat()` resolves through `getStringFormatProperty`, not
|
||||
* off the top-level object. A first version of this helper put the keys at the top level and
|
||||
* every format-dependent case failed with a null container, which is worth recording here so
|
||||
* the next fixture does not have to rediscover it.
|
||||
*
|
||||
* Streams are the other half and are *not* nested — they come from the constructor argument.
|
||||
*/
|
||||
private fun info(formatName: String, vararg streams: StreamInformation, duration: String? = "1.0") =
|
||||
MediaInformation(
|
||||
JSONObject().apply {
|
||||
put(
|
||||
MediaInformation.KEY_FORMAT_PROPERTIES,
|
||||
JSONObject().apply {
|
||||
put(MediaInformation.KEY_FORMAT, formatName)
|
||||
duration?.let { put(MediaInformation.KEY_DURATION, it) }
|
||||
},
|
||||
)
|
||||
},
|
||||
streams.toList(),
|
||||
emptyList(),
|
||||
)
|
||||
}
|
||||
@@ -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