Rename the project to LibreMediaConverter

Done now rather than later: the application ID is permanent once published --
Play treats a change as an entirely different app -- so this is the last
cheap moment to choose it.

  applicationId / namespace  dev.jasonmross.mediaconverter -> org.libremediaconverter
  source tree                java/dev/jasonmross/mediaconverter -> java/org/libremediaconverter
  gradle project             AndroidMediaConverter -> LibreMediaConverter
  theme                      Theme.MediaConverter -> Theme.LibreMediaConverter
  compose theme              MediaConverterTheme -> LibreMediaConverterTheme
  display name               "Media Converter" -> "LibreMediaConverter"

org.* rather than dev.jasonmross.* because "Libre" signals a project rather
than a personal app, and a project-owned namespace lets maintainership move
later without the identifier contradicting reality.

The source trees moved with git mv so history follows the files instead of
showing 42 deletions beside 42 additions.

Verified after the rename: 66 unit tests, and 40 instrumented tests on an
API 36 emulator, 0 failures. The built APK reports org.libremediaconverter,
and no stale jasonmross, AndroidMediaConverter or MediaConverterTheme
identifiers remain anywhere in the tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-20 18:31:43 -05:00
co-authored by Claude Opus 5
parent 128763e99c
commit dd2fcf0a19
51 changed files with 169 additions and 169 deletions
@@ -0,0 +1,231 @@
package org.libremediaconverter.ffmpeg
import org.libremediaconverter.model.ConversionRequest
import org.libremediaconverter.model.OutputFormat
import org.libremediaconverter.model.QualityTier
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class FFmpegCommandBuilderTest {
private fun cmd(
format: OutputFormat,
quality: QualityTier = QualityTier.BEST,
hardwareEncodeAvailable: Boolean = true,
): List<String> = FFmpegCommandBuilder.build(
ConversionRequest(
format = format,
quality = quality,
hardwareEncodeAvailable = hardwareEncodeAvailable,
),
inputPath = "/cache/in.mp4",
outputPath = "/cache/out.${format.extension}",
)
/** Asserts `flag` is present and immediately followed by `value`. */
private fun assertPair(args: List<String>, flag: String, value: String) {
val i = args.indexOf(flag)
assertTrue("missing $flag in $args", i >= 0)
assertEquals("wrong value for $flag in $args", value, args[i + 1])
}
// --- structure ---------------------------------------------------------
@Test
fun `input precedes output and both are present`() {
val args = cmd(OutputFormat.MP4_H264)
assertPair(args, "-i", "/cache/in.mp4")
assertEquals("/cache/out.mp4", args.last())
}
@Test
fun `overwrite is enabled because the output path is our own cache file`() {
assertTrue(cmd(OutputFormat.MP4_H264).contains("-y"))
}
// --- the GPL quality path ---------------------------------------------
@Test
fun `best quality h264 uses libx264 with crf, not a bitrate target`() {
val args = cmd(OutputFormat.MP4_H264, QualityTier.BEST)
assertPair(args, "-c:v", "libx264")
assertPair(args, "-crf", "20")
assertFalse("CRF and -b:v are mutually exclusive", args.contains("-b:v"))
}
@Test
fun `best quality h265 uses libx265 with a higher crf than h264`() {
val args = cmd(OutputFormat.MP4_H265, QualityTier.BEST)
assertPair(args, "-c:v", "libx265")
// x265 is roughly a step stronger at the same number, so sharing a constant
// with x264 would silently change the size target.
assertPair(args, "-crf", "24")
}
@Test
fun `hevc in mp4 is tagged hvc1 for player compatibility`() {
assertPair(cmd(OutputFormat.MP4_H265, QualityTier.BEST), "-tag:v", "hvc1")
}
/**
* Regression test for a failure found with real footage on a Pixel 10 Pro XL.
*
* The source was H.264 High 4:4:4 Predictive. FFmpeg decodes that to yuv444p and,
* without an explicit pixel format, hands those frames straight to an encoder that
* cannot accept them — hevc_mediacodec died with "Invalid to call at Released
* state" partway through. Every video encode path has to name the format so FFmpeg
* inserts the conversion, not just the one path that happened to have it.
*/
@Test
fun `every video encode path forces yuv420p`() {
val videoFormats = listOf(
OutputFormat.MP4_H264, OutputFormat.MP4_H265,
OutputFormat.MKV_H264, OutputFormat.MKV_H265,
OutputFormat.WEBM_VP9,
)
videoFormats.forEach { format ->
listOf(QualityTier.FAST, QualityTier.BEST).forEach { quality ->
listOf(true, false).forEach { hw ->
val args = cmd(format, quality, hardwareEncodeAvailable = hw)
assertPair(args, "-pix_fmt", "yuv420p")
}
}
}
}
@Test
fun `audio only outputs do not set a pixel format`() {
listOf(OutputFormat.MP3, OutputFormat.FLAC, OutputFormat.WAV).forEach {
assertFalse("${it.name} should not set -pix_fmt", cmd(it).contains("-pix_fmt"))
}
}
// --- the hardware fallback path ---------------------------------------
/**
* Regression test for a defect found on a device with no hardware HEVC encoder.
*
* FFmpeg's *_mediacodec wrappers do not fail on such a device — they quietly bind
* to the platform software codec (c2.android.*) and encode far slower than
* libx264/libx265 would, while still presenting as the fast path. When no hardware
* encoder exists, a real software encoder on a fast preset is both quicker and
* honest about what it is doing.
*/
@Test
fun `the encoder choice no longer depends on hardware availability`() {
// Once FFmpeg stopped selecting MediaCodec encoders, this flag only affects
// whether the router sends the job to Media3 at all -- not what FFmpeg does.
listOf(OutputFormat.MP4_H264, OutputFormat.MP4_H265).forEach { format ->
assertEquals(
cmd(format, QualityTier.FAST, hardwareEncodeAvailable = true),
cmd(format, QualityTier.FAST, hardwareEncodeAvailable = false),
)
}
}
@Test
fun `fast software preset is faster than the best quality preset`() {
// Both use libx264; the distinction between the tiers has to survive the
// fallback, otherwise Fast and Best become the same slow thing.
val fast = cmd(OutputFormat.MP4_H264, QualityTier.FAST, hardwareEncodeAvailable = false)
val best = cmd(OutputFormat.MP4_H264, QualityTier.BEST)
assertEquals("veryfast", fast[fast.indexOf("-preset") + 1])
assertEquals("medium", best[best.indexOf("-preset") + 1])
}
// --- audio -------------------------------------------------------------
@Test
fun `mp3 uses libmp3lame and drops video`() {
val args = cmd(OutputFormat.MP3)
assertPair(args, "-c:a", "libmp3lame")
assertTrue("audio-only output must drop the video stream", args.contains("-vn"))
}
@Test
fun `flac wav and opus select the right encoders`() {
assertPair(cmd(OutputFormat.FLAC), "-c:a", "flac")
assertPair(cmd(OutputFormat.WAV), "-c:a", "pcm_s16le")
assertPair(cmd(OutputFormat.OPUS), "-c:a", "libopus")
}
@Test
fun `audio only formats never carry a video encoder`() {
listOf(OutputFormat.MP3, OutputFormat.FLAC, OutputFormat.WAV, OutputFormat.OPUS)
.forEach { format ->
val args = cmd(format)
assertFalse("$format should not set -c:v", args.contains("-c:v"))
assertTrue("$format should set -vn", args.contains("-vn"))
}
}
// --- image outputs -----------------------------------------------------
@Test
fun `gif generates a palette to avoid banding and drops audio`() {
val args = cmd(OutputFormat.GIF)
val filter = args[args.indexOf("-vf") + 1]
assertTrue("gif needs palettegen: $filter", filter.contains("palettegen"))
assertTrue("gif needs paletteuse: $filter", filter.contains("paletteuse"))
assertTrue(args.contains("-an"))
}
@Test
fun `gif loops forever`() {
assertPair(cmd(OutputFormat.GIF), "-loop", "0")
}
@Test
fun `frame export sets an fps filter and numbered output`() {
val args = cmd(OutputFormat.FRAMES_PNG)
assertPair(args, "-vf", "fps=1")
assertEquals(
"shot_%04d.png",
FFmpegCommandBuilder.outputPattern(OutputFormat.FRAMES_PNG, "shot"),
)
}
@Test
fun `single file formats keep their plain name`() {
assertEquals(
"clip.mp4",
FFmpegCommandBuilder.outputPattern(OutputFormat.MP4_H264, "clip.mp4"),
)
}
// --- containers --------------------------------------------------------
@Test
fun `mp4 output enables faststart`() {
assertPair(cmd(OutputFormat.MP4_H264), "-movflags", "+faststart")
}
@Test
fun `non mp4 containers do not get faststart`() {
assertFalse(cmd(OutputFormat.MKV_H264).contains("-movflags"))
assertFalse(cmd(OutputFormat.WAV).contains("-movflags"))
}
@Test
fun `mkv accepts h264 and h265 without faststart`() {
assertPair(cmd(OutputFormat.MKV_H264, QualityTier.BEST), "-c:v", "libx264")
assertPair(cmd(OutputFormat.MKV_H265, QualityTier.BEST), "-c:v", "libx265")
}
// --- exhaustiveness ----------------------------------------------------
@Test
fun `every format builds a well formed command`() {
OutputFormat.entries.forEach { format ->
listOf(QualityTier.FAST, QualityTier.BEST).forEach { quality ->
val args = cmd(format, quality)
assertTrue("$format/$quality has no input", args.contains("-i"))
assertTrue("$format/$quality has too few args", args.size >= 5)
// Every flag that takes a value must actually have one.
assertFalse("$format/$quality ends on a dangling flag", args.last().startsWith("-"))
}
}
}
}
@@ -0,0 +1,96 @@
package org.libremediaconverter.ffmpeg
import org.libremediaconverter.model.ConcatStrategy
import org.libremediaconverter.model.OutputFormat
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.File
class FFmpegConcatCommandTest {
private val listFile = File("/cache/list.txt")
private val output = File("/cache/joined.mp4")
private val inputs = listOf("/cache/a.mp4", "/cache/b.mp4")
@Test
fun `list file quotes each entry for the concat demuxer`() {
val text = FFmpegConcatCommand.listFileContents(inputs)
assertEquals("file '/cache/a.mp4'\nfile '/cache/b.mp4'\n", text)
}
@Test
fun `apostrophes in filenames are escaped`() {
// An unescaped apostrophe silently truncates the entry, so the file would be
// skipped rather than the command failing.
val text = FFmpegConcatCommand.listFileContents(listOf("/cache/jason's clip.mp4"))
assertTrue("apostrophe not escaped: $text", text.contains("""jason'\''s clip.mp4"""))
}
@Test
fun `stream copy uses the concat demuxer and copies codecs`() {
val args = FFmpegConcatCommand.build(
ConcatStrategy.STREAM_COPY, inputs, listFile, output, OutputFormat.MP4_H264,
)
assertTrue(args.contains("concat"))
assertEquals("copy", args[args.indexOf("-c") + 1])
assertEquals(listFile.absolutePath, args[args.indexOf("-i") + 1])
assertFalse("stream copy must not re-encode", args.contains("libx264"))
}
@Test
fun `stream copy allows absolute paths in the list file`() {
val args = FFmpegConcatCommand.build(
ConcatStrategy.STREAM_COPY, inputs, listFile, output, OutputFormat.MP4_H264,
)
// Without -safe 0 the demuxer rejects the absolute paths we generate.
assertEquals("0", args[args.indexOf("-safe") + 1])
}
@Test
fun `re-encode passes every input separately and builds a filter graph`() {
val args = FFmpegConcatCommand.build(
ConcatStrategy.REENCODE, inputs, listFile, output, OutputFormat.MP4_H264,
)
assertEquals(2, args.count { it == "-i" })
val filter = args[args.indexOf("-filter_complex") + 1]
assertTrue("missing concat filter: $filter", filter.contains("concat=n=2:v=1:a=1"))
assertTrue("inputs must be normalised before joining", filter.contains("scale="))
assertTrue("frame rates must be normalised", filter.contains("fps=30"))
assertTrue(args.contains("libx264"))
}
@Test
fun `re-encode maps the filter outputs rather than raw streams`() {
val args = FFmpegConcatCommand.build(
ConcatStrategy.REENCODE, inputs, listFile, output, OutputFormat.MP4_H264,
)
assertTrue(args.contains("[v]"))
assertTrue(args.contains("[a]"))
}
@Test
fun `mp4 output gets faststart on both strategies`() {
listOf(ConcatStrategy.STREAM_COPY, ConcatStrategy.REENCODE).forEach { strategy ->
val args = FFmpegConcatCommand.build(strategy, inputs, listFile, output, OutputFormat.MP4_H264)
assertTrue("$strategy missing faststart", args.contains("+faststart"))
}
}
@Test
fun `mkv output does not get faststart`() {
val args = FFmpegConcatCommand.build(
ConcatStrategy.STREAM_COPY, inputs, listFile, File("/cache/j.mkv"), OutputFormat.MKV_H264,
)
assertFalse(args.contains("+faststart"))
}
@Test
fun `output path is always last`() {
listOf(ConcatStrategy.STREAM_COPY, ConcatStrategy.REENCODE).forEach { strategy ->
val args = FFmpegConcatCommand.build(strategy, inputs, listFile, output, OutputFormat.MP4_H264)
assertEquals(output.absolutePath, args.last())
}
}
}
@@ -0,0 +1,83 @@
package org.libremediaconverter.model
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* The stream-copy-vs-re-encode decision.
*
* This matters more than it looks: the `concat` demuxer does not reject mismatched
* inputs loudly, it can emit a file whose later segments are garbled. So the default
* on any doubt has to be re-encoding.
*/
class ConcatPlannerTest {
private fun clip(
video: String? = "h264",
audio: String? = "aac",
width: Int = 1920,
height: Int = 1080,
fps: Int = 30,
) = ConcatInput(video, audio, width, height, fps)
@Test
fun `identical clips can be stream copied`() {
assertEquals(
ConcatStrategy.STREAM_COPY,
ConcatPlanner.plan(listOf(clip(), clip())),
)
}
@Test
fun `a single input needs no re-encode`() {
assertEquals(ConcatStrategy.STREAM_COPY, ConcatPlanner.plan(listOf(clip())))
}
@Test
fun `different video codecs force a re-encode`() {
assertEquals(
ConcatStrategy.REENCODE,
ConcatPlanner.plan(listOf(clip(video = "h264"), clip(video = "hevc"))),
)
}
@Test
fun `different audio codecs force a re-encode`() {
assertEquals(
ConcatStrategy.REENCODE,
ConcatPlanner.plan(listOf(clip(audio = "aac"), clip(audio = "opus"))),
)
}
@Test
fun `different resolutions force a re-encode`() {
assertEquals(
ConcatStrategy.REENCODE,
ConcatPlanner.plan(listOf(clip(width = 1920, height = 1080), clip(width = 1280, height = 720))),
)
}
@Test
fun `different frame rates force a re-encode`() {
assertEquals(
ConcatStrategy.REENCODE,
ConcatPlanner.plan(listOf(clip(fps = 30), clip(fps = 60))),
)
}
@Test
fun `an unknown codec is not treated as a match`() {
// Two nulls are not evidence of agreement. Assuming they match is exactly how
// a silent corrupt concat happens.
assertEquals(
ConcatStrategy.REENCODE,
ConcatPlanner.plan(listOf(clip(video = null), clip(video = null))),
)
}
@Test
fun `a mismatch anywhere in a longer list is caught`() {
val clips = listOf(clip(), clip(), clip(), clip(width = 640, height = 480), clip())
assertEquals(ConcatStrategy.REENCODE, ConcatPlanner.plan(clips))
}
}
@@ -0,0 +1,197 @@
package org.libremediaconverter.model
import org.libremediaconverter.model.ConversionRouter.Reason
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* One test per routing predicate.
*
* These run on the JVM against fabricated device profiles rather than on hardware, so
* every branch is reachable — including "this device cannot encode AV1", which would
* otherwise depend on which phone the suite happened to run on.
*/
class ConversionRouterTest {
private fun route(
format: OutputFormat,
quality: QualityTier = QualityTier.FAST,
preference: EnginePreference = EnginePreference.AUTO,
probe: InputProbe = InputProbe(videoCodec = "h264"),
device: DeviceCodecs = DeviceCodecs.PERMISSIVE,
) = ConversionRouter.route(
ConversionRequest(format, quality, preference, probe),
device,
)
// --- the happy path -----------------------------------------------------
@Test
fun `mp4 h264 on a capable device uses hardware`() {
val d = route(OutputFormat.MP4_H264)
assertEquals(Engine.MEDIA3, d.engine)
assertEquals(Reason.HARDWARE_CAPABLE, d.reason)
}
@Test
fun `mp4 h265 uses hardware`() {
assertEquals(Engine.MEDIA3, route(OutputFormat.MP4_H265).engine)
}
@Test
fun `webm vp9 routes to ffmpeg because media3 cannot encode vp9`() {
// Transformer.setVideoMimeType accepts only H.263/H.264/H.265/MP4V. The WebM
// muxer exists, but there is no VP9 *encoder* behind it, so producing WebM
// video is FFmpeg's job even though the container is nominally supported.
val d = route(OutputFormat.WEBM_VP9)
assertEquals(Engine.FFMPEG, d.engine)
assertEquals(Reason.NO_PLATFORM_ENCODER, d.reason)
}
@Test
fun `aac audio extraction stays on hardware`() {
assertEquals(Engine.MEDIA3, route(OutputFormat.M4A_AAC).engine)
}
// --- 1. container ------------------------------------------------------
@Test
fun `mkv routes to ffmpeg because media3 has no matroska muxer`() {
val d = route(OutputFormat.MKV_H264)
assertEquals(Engine.FFMPEG, d.engine)
assertEquals(Reason.CONTAINER_UNSUPPORTED, d.reason)
}
// --- 3. no platform encoder -------------------------------------------
@Test
fun `mp3 routes to ffmpeg because android has no mp3 encoder at any api level`() {
val d = route(OutputFormat.MP3)
assertEquals(Engine.FFMPEG, d.engine)
assertEquals(Reason.NO_PLATFORM_ENCODER, d.reason)
}
@Test
fun `flac routes to ffmpeg`() {
assertEquals(Engine.FFMPEG, route(OutputFormat.FLAC).engine)
}
// --- 4. image output ---------------------------------------------------
@Test
fun `gif routes to ffmpeg as an image output`() {
val d = route(OutputFormat.GIF)
assertEquals(Engine.FFMPEG, d.engine)
assertEquals(Reason.IMAGE_OUTPUT, d.reason)
}
@Test
fun `png frame export routes to ffmpeg`() {
assertEquals(Engine.FFMPEG, route(OutputFormat.FRAMES_PNG).engine)
}
// --- 5. no platform decoder -------------------------------------------
@Test
fun `av1 input on a device without av1 decode routes to ffmpeg`() {
val noAv1 = object : DeviceCodecs {
override fun canEncode(codec: VideoCodec) = true
override fun canDecode(codecName: String) = codecName != "av1"
}
val d = route(OutputFormat.MP4_H264, probe = InputProbe(videoCodec = "av1"), device = noAv1)
assertEquals(Engine.FFMPEG, d.engine)
assertEquals(Reason.NO_PLATFORM_DECODER, d.reason)
}
@Test
fun `av1 input on a device with av1 decode stays on hardware`() {
val d = route(OutputFormat.MP4_H264, probe = InputProbe(videoCodec = "av1"))
assertEquals(Engine.MEDIA3, d.engine)
}
@Test
fun `an input the platform cannot parse routes to ffmpeg`() {
// MediaProbe reports this when the platform extractor fails to open the file.
// Media3 cannot convert what the platform cannot read, so the job must not be
// sent down the hardware path only to fail there.
val d = route(
OutputFormat.MP4_H264,
probe = InputProbe(videoCodec = InputProbe.UNPARSEABLE),
)
assertEquals(Engine.FFMPEG, d.engine)
assertEquals(Reason.NO_PLATFORM_DECODER, d.reason)
}
@Test
fun `a null input codec is treated as no information, not as a failure`() {
// Nothing known about the input is different from "known to be unreadable":
// the former should still take the fast path.
val d = route(OutputFormat.MP4_H264, probe = InputProbe(videoCodec = null))
assertEquals(Engine.MEDIA3, d.engine)
}
// --- 6. quality tier ---------------------------------------------------
@Test
fun `best quality routes to ffmpeg because mediacodec exposes no crf`() {
val d = route(OutputFormat.MP4_H264, quality = QualityTier.BEST)
assertEquals(Engine.FFMPEG, d.engine)
assertEquals(Reason.QUALITY_TIER_REQUIRES_CRF, d.reason)
}
@Test
fun `fast quality is the hardware path`() {
assertEquals(Engine.MEDIA3, route(OutputFormat.MP4_H264, quality = QualityTier.FAST).engine)
}
// --- 7. hardware encoder availability ---------------------------------
@Test
fun `h265 target on a device without hevc encode falls back to ffmpeg`() {
val noHevc = object : DeviceCodecs {
override fun canEncode(codec: VideoCodec) = codec != VideoCodec.H265
override fun canDecode(codecName: String) = true
}
val d = route(OutputFormat.MP4_H265, device = noHevc)
assertEquals(Engine.FFMPEG, d.engine)
assertEquals(Reason.NO_HARDWARE_ENCODER, d.reason)
}
// --- user override -----------------------------------------------------
@Test
fun `forcing software overrides an otherwise hardware-capable job`() {
val d = route(OutputFormat.MP4_H264, preference = EnginePreference.FORCE_SOFTWARE)
assertEquals(Engine.FFMPEG, d.engine)
assertEquals(Reason.USER_FORCED_SOFTWARE, d.reason)
}
@Test
fun `forcing software wins even for formats ffmpeg would take anyway`() {
val d = route(OutputFormat.MP3, preference = EnginePreference.FORCE_SOFTWARE)
assertEquals(Engine.FFMPEG, d.engine)
assertEquals(Reason.USER_FORCED_SOFTWARE, d.reason)
}
// --- exhaustiveness ----------------------------------------------------
@Test
fun `every output format routes somewhere without throwing`() {
OutputFormat.entries.forEach { format ->
val decision = route(format)
assertEquals(
"format $format produced no engine",
true,
decision.engine == Engine.MEDIA3 || decision.engine == Engine.FFMPEG,
)
}
}
@Test
fun `audio-only formats are flagged as such`() {
assertEquals(true, OutputFormat.MP3.isAudioOnly)
assertEquals(true, OutputFormat.FLAC.isAudioOnly)
assertEquals(false, OutputFormat.MP4_H264.isAudioOnly)
assertEquals(false, OutputFormat.GIF.isAudioOnly)
}
}
@@ -0,0 +1,64 @@
package org.libremediaconverter.model
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Guards the format table itself.
*
* The router, the FFmpeg command builder and the output filename all read from these
* entries, so a wrong extension or a mislabelled codec propagates everywhere.
*/
class OutputFormatTest {
@Test
fun `every format has a non-empty label and extension`() {
OutputFormat.entries.forEach {
assertTrue("${it.name} has no label", it.label.isNotBlank())
assertTrue("${it.name} has no extension", it.extension.isNotBlank())
assertFalse("${it.name} extension should not include a dot", it.extension.startsWith("."))
assertTrue("${it.name} has no mime type", it.mimeType.contains('/'))
}
}
@Test
fun `audio only formats carry no video codec`() {
OutputFormat.entries.filter { it.isAudioOnly }.forEach {
assertEquals("${it.name} should have no video codec", VideoCodec.NONE, it.videoCodec)
}
}
@Test
fun `image outputs carry neither video nor audio codecs`() {
OutputFormat.entries.filter { it.isImageOutput }.forEach {
assertEquals(VideoCodec.NONE, it.videoCodec)
assertEquals(AudioCodec.NONE, it.audioCodec)
assertFalse("${it.name} is not audio-only", it.isAudioOnly)
}
}
@Test
fun `extensions match their containers`() {
assertEquals("mp4", OutputFormat.MP4_H264.extension)
assertEquals("mkv", OutputFormat.MKV_H264.extension)
assertEquals("mp3", OutputFormat.MP3.extension)
assertEquals("gif", OutputFormat.GIF.extension)
assertEquals("wav", OutputFormat.WAV.extension)
}
@Test
fun `video formats are not misreported as audio only`() {
listOf(OutputFormat.MP4_H264, OutputFormat.MP4_H265, OutputFormat.MKV_H265, OutputFormat.WEBM_VP9)
.forEach { assertFalse("${it.name} should not be audio-only", it.isAudioOnly) }
}
@Test
fun `quality tiers describe themselves for the UI`() {
QualityTier.entries.forEach {
assertTrue(it.label.isNotBlank())
assertTrue(it.description.isNotBlank())
}
}
}
@@ -0,0 +1,58 @@
package org.libremediaconverter.work
import androidx.work.WorkInfo
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* The retry-versus-fail rule.
*
* Isolated from the worker precisely so it can be tested: the condition that triggers
* a retry in production is the foreground-service budget running out, six hours per
* twenty-four, which no test can reach. Extracting the decision means the rule is still
* verified even though its trigger cannot be reproduced.
*/
class FailureOutcomeTest {
@Test
fun `a foreground service timeout is a retry, not a failure`() {
// The work is still valid; there is simply no budget right now. Telling the
// user their conversion failed would be wrong.
assertEquals(
FailureOutcome.RETRY,
FailureOutcome.forStopReason(WorkInfo.STOP_REASON_FOREGROUND_SERVICE_TIMEOUT),
)
}
@Test
fun `an ordinary failure is reported as a failure`() {
assertEquals(
FailureOutcome.FAIL,
FailureOutcome.forStopReason(WorkInfo.STOP_REASON_NOT_STOPPED),
)
}
@Test
fun `every other stop reason fails rather than retrying forever`() {
// Retrying on, say, a user cancellation or a battery constraint would either
// ignore the user or spin. Only the timeout earns a retry.
val others = listOf(
WorkInfo.STOP_REASON_CANCELLED_BY_APP,
WorkInfo.STOP_REASON_USER,
WorkInfo.STOP_REASON_CONSTRAINT_BATTERY_NOT_LOW,
WorkInfo.STOP_REASON_CONSTRAINT_CHARGING,
WorkInfo.STOP_REASON_CONSTRAINT_CONNECTIVITY,
WorkInfo.STOP_REASON_CONSTRAINT_DEVICE_IDLE,
WorkInfo.STOP_REASON_CONSTRAINT_STORAGE_NOT_LOW,
WorkInfo.STOP_REASON_DEVICE_STATE,
WorkInfo.STOP_REASON_QUOTA,
WorkInfo.STOP_REASON_BACKGROUND_RESTRICTION,
WorkInfo.STOP_REASON_APP_STANDBY,
WorkInfo.STOP_REASON_TIMEOUT,
WorkInfo.STOP_REASON_UNKNOWN,
)
others.forEach {
assertEquals("stop reason $it should fail", FailureOutcome.FAIL, FailureOutcome.forStopReason(it))
}
}
}