From d0a72d13e51f606d01319f9e094e7a93538f282a Mon Sep 17 00:00:00 2001 From: Aaron Rodriguez Date: Sat, 13 Jun 2026 12:54:50 -0700 Subject: [PATCH 1/7] add some test coverage and fix some bugs --- build.xml | 224 ++++++-- ivy.xml | 7 + .../adashrod/mkvscanner/Eac3toScanner.java | 32 +- .../mkvscanner/util/ProcessBuilderRunner.java | 28 + .../mkvscanner/util/ProcessResult.java | 29 + .../mkvscanner/util/ProcessRunner.java | 20 + .../resources/eac3to-format-extensions.json | 6 + .../mkvscanner/Eac3toScannerTest.java | 509 ++++++++++++++++++ 8 files changed, 804 insertions(+), 51 deletions(-) create mode 100644 src/main/java/com/adashrod/mkvscanner/util/ProcessBuilderRunner.java create mode 100644 src/main/java/com/adashrod/mkvscanner/util/ProcessResult.java create mode 100644 src/main/java/com/adashrod/mkvscanner/util/ProcessRunner.java create mode 100644 src/test/java/com/adashrod/mkvscanner/Eac3toScannerTest.java diff --git a/build.xml b/build.xml index 1770408..a1a1922 100644 --- a/build.xml +++ b/build.xml @@ -1,16 +1,20 @@ + + + @@ -20,8 +24,23 @@ + + + + + + + + + + + + + + + @@ -30,7 +49,7 @@ - + @@ -45,6 +64,20 @@ + + + + + + + + + + + + + + @@ -52,6 +85,17 @@ + + + + + + + + + + + @@ -75,6 +119,82 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -97,41 +217,73 @@ - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ivy.xml b/ivy.xml index 5afffb7..a7d32f9 100644 --- a/ivy.xml +++ b/ivy.xml @@ -4,6 +4,7 @@ + @@ -23,5 +24,11 @@ + + + + + + diff --git a/src/main/java/com/adashrod/mkvscanner/Eac3toScanner.java b/src/main/java/com/adashrod/mkvscanner/Eac3toScanner.java index 8b7138a..9076bea 100644 --- a/src/main/java/com/adashrod/mkvscanner/Eac3toScanner.java +++ b/src/main/java/com/adashrod/mkvscanner/Eac3toScanner.java @@ -4,7 +4,9 @@ import com.adashrod.mkvscanner.model.Track; import com.adashrod.mkvscanner.model.Video; import com.adashrod.mkvscanner.util.FormatExtensionConfig; -import com.adashrod.mkvscanner.util.StreamConsumer; +import com.adashrod.mkvscanner.util.ProcessBuilderRunner; +import com.adashrod.mkvscanner.util.ProcessResult; +import com.adashrod.mkvscanner.util.ProcessRunner; import com.adashrod.mkvscanner.util.StringLineIterator; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; @@ -48,12 +50,22 @@ public class Eac3toScanner implements FileScanner { private final String executableLocation; private final File outputDirectory; + private final ProcessRunner processRunner; private final Collection eac3toLanguages = new HashSet<>(); private final Map formatExtensionConfigs = new HashMap<>(); public Eac3toScanner(final String executableLocation, final File outputDirectory) { + this(executableLocation, outputDirectory, new ProcessBuilderRunner()); + } + + /** + * Package-private constructor that allows injecting a {@link ProcessRunner}, used by tests to feed fake process + * output (stdout/exit code) without launching the real executable. + */ + Eac3toScanner(final String executableLocation, final File outputDirectory, final ProcessRunner processRunner) { this.executableLocation = Objects.requireNonNull(executableLocation, "Eac3toScanner.executableLocation can't be null"); this.outputDirectory = Objects.requireNonNull(outputDirectory, "Eac3toScanner.outputDirectory can't be null"); + this.processRunner = Objects.requireNonNull(processRunner, "Eac3toScanner.processRunner can't be null"); try { loadEac3toLanguages(); loadEac3toFormatExtensions(); @@ -105,29 +117,19 @@ public String exec(final File file, final String... arguments) throws DemuxerExc command.add(executableLocation); command.add(file.getPath()); Collections.addAll(command, arguments); - final ProcessBuilder builder = new ProcessBuilder(command); - Process process = null; - final int exitValue; - final StreamConsumer stdOut; - final StreamConsumer stdErr; + final ProcessResult result; try { - process = builder.start(); - stdOut = new StreamConsumer(process.getInputStream()); - stdErr = new StreamConsumer(process.getErrorStream()); - stdOut.start(); - stdErr.start(); - exitValue = process.waitFor(); + result = processRunner.run(command); } catch (final InterruptedException ie) { - process.destroy(); logger.error("process interrupted", ie);// todo: propagate a wrapper exception or re-throw; if not re-throwing, re-interrupt return null; } // when piping eac3to output to a file or stream, each line is prepended by a bunch of backspace characters: \b // also: the first line has the eac3to progress bar "----" and some whitespace - final String output = stdOut.getStreamContent().replaceAll("\b", "").replaceAll("^-+\\s+", "").trim(); + final String output = result.getStdout().replaceAll("\b", "").replaceAll("^-+\\s+", "").trim(); - if (exitValue == 0) { + if (result.getExitValue() == 0) { return output; } else { final StringBuilder argumentsBuilder = new StringBuilder(file.getPath()).append(" "); diff --git a/src/main/java/com/adashrod/mkvscanner/util/ProcessBuilderRunner.java b/src/main/java/com/adashrod/mkvscanner/util/ProcessBuilderRunner.java new file mode 100644 index 0000000..3c4b880 --- /dev/null +++ b/src/main/java/com/adashrod/mkvscanner/util/ProcessBuilderRunner.java @@ -0,0 +1,28 @@ +package com.adashrod.mkvscanner.util; + +import java.io.IOException; +import java.util.List; + +/** + * Production {@link ProcessRunner} that launches the process with a {@link ProcessBuilder} and drains its stdout and + * stderr with {@link StreamConsumer} threads to avoid the buffer-deadlock described in {@link StreamConsumer}. + */ +public class ProcessBuilderRunner implements ProcessRunner { + @Override + public ProcessResult run(final List command) throws IOException, InterruptedException { + final ProcessBuilder builder = new ProcessBuilder(command); + Process process = null; + try { + process = builder.start(); + final StreamConsumer stdOut = new StreamConsumer(process.getInputStream()); + final StreamConsumer stdErr = new StreamConsumer(process.getErrorStream()); + stdOut.start(); + stdErr.start(); + final int exitValue = process.waitFor(); + return new ProcessResult(exitValue, stdOut.getStreamContent(), stdErr.getStreamContent()); + } catch (final InterruptedException ie) { + process.destroy(); + throw ie; + } + } +} diff --git a/src/main/java/com/adashrod/mkvscanner/util/ProcessResult.java b/src/main/java/com/adashrod/mkvscanner/util/ProcessResult.java new file mode 100644 index 0000000..05916ac --- /dev/null +++ b/src/main/java/com/adashrod/mkvscanner/util/ProcessResult.java @@ -0,0 +1,29 @@ +package com.adashrod.mkvscanner.util; + +/** + * Immutable result of running an external process: its exit value and the full contents of its stdout and stderr + * streams. + */ +public class ProcessResult { + private final int exitValue; + private final String stdout; + private final String stderr; + + public ProcessResult(final int exitValue, final String stdout, final String stderr) { + this.exitValue = exitValue; + this.stdout = stdout; + this.stderr = stderr; + } + + public int getExitValue() { + return exitValue; + } + + public String getStdout() { + return stdout; + } + + public String getStderr() { + return stderr; + } +} diff --git a/src/main/java/com/adashrod/mkvscanner/util/ProcessRunner.java b/src/main/java/com/adashrod/mkvscanner/util/ProcessRunner.java new file mode 100644 index 0000000..192ae5e --- /dev/null +++ b/src/main/java/com/adashrod/mkvscanner/util/ProcessRunner.java @@ -0,0 +1,20 @@ +package com.adashrod.mkvscanner.util; + +import java.io.IOException; +import java.util.List; + +/** + * An abstraction over launching an external process and capturing its output. This is the seam that allows the process + * execution to be mocked in tests so that fake stdout/exit codes can be fed to the code that parses and interprets + * scanner output. + */ +public interface ProcessRunner { + /** + * Runs the given command, blocks until it exits, and returns its exit value and captured stdout/stderr. + * @param command the command and its arguments (e.g. as passed to {@link ProcessBuilder}) + * @return the result of running the process + * @throws IOException if the process can't be started or an I/O error occurs + * @throws InterruptedException if the current thread is interrupted while waiting for the process to finish + */ + ProcessResult run(List command) throws IOException, InterruptedException; +} diff --git a/src/main/resources/eac3to-format-extensions.json b/src/main/resources/eac3to-format-extensions.json index c2740ed..1d12a38 100644 --- a/src/main/resources/eac3to-format-extensions.json +++ b/src/main/resources/eac3to-format-extensions.json @@ -13,6 +13,12 @@ "extension": "dts", "flags": ["-core"] }], + "DTS-HD Master Audio": [{ + "extension": "dtshd" + }, { + "extension": "dts", + "flags": ["-core"] + }], "DTS-ES": "dts", "EAC3": "eac3", "MP3": "mp3", diff --git a/src/test/java/com/adashrod/mkvscanner/Eac3toScannerTest.java b/src/test/java/com/adashrod/mkvscanner/Eac3toScannerTest.java new file mode 100644 index 0000000..ab389d5 --- /dev/null +++ b/src/test/java/com/adashrod/mkvscanner/Eac3toScannerTest.java @@ -0,0 +1,509 @@ +package com.adashrod.mkvscanner; + +import com.adashrod.mkvscanner.model.Track; +import com.adashrod.mkvscanner.model.Video; +import com.adashrod.mkvscanner.util.ProcessResult; +import com.adashrod.mkvscanner.util.ProcessRunner; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.io.File; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Created by aaron on 2026-06-13. + */ +@ExtendWith(MockitoExtension.class) +public class Eac3toScannerTest { + @Mock + private ProcessRunner processRunner; + + // hard-coded stdout of running eac3to on a blu-ray dir + private static final String BLU_RAY_DISC_LEGACY = String.join("\n", + "1) 00000.mpls, 00000.m2ts, 2:28:52", + " - Chapters, 34 chapters", + " - VC-1, 1080p24 /1.001 (16:9)", + " - AC3, English, multi-channel, 48kHz", + " - RAW/PCM, English, multi-channel, 48kHz", + " - AC3, French, multi-channel, 48kHz", + " - AC3, Spanish, multi-channel, 48kHz", + " - AC3, German, multi-channel, 48kHz", + " - AC3, Italian, multi-channel, 48kHz", + " - AC3, Spanish, multi-channel, 48kHz", + " - AC3, English, stereo, 48kHz", + "", + "2) 00033.mpls, 00049.m2ts, 1:16:31", + " - Chapters, 15 chapters", + " - VC-1, 1080i60 /1.001 (16:9)", + " - AC3, English, stereo, 48kHz", + " - AC3, English, stereo, 48kHz", + " - AC3, English, stereo, 48kHz", + " - AC3, English, stereo, 48kHz", + " - AC3, English, stereo, 48kHz", + " - AC3, English, stereo, 48kHz", + " - AC3, English, stereo, 48kHz", + " - AC3, English, stereo, 48kHz", + "", + "3) 00003.mpls, 00004.m2ts, 0:43:08", + " - MPEG2, 480i60 /1.001 (16:9)", + " - AC3, English, stereo, 48kHz", + " - AC3, English, stereo, 48kHz", + " - AC3, English, stereo, 48kHz", + " - AC3, English, stereo, 48kHz", + " - AC3, English, stereo, 48kHz", + " - AC3, English, stereo, 48kHz", + " - AC3, English, stereo, 48kHz", + " - AC3, English, stereo, 48kHz", + "", + "4) 00006.mpls, 00007.m2ts, 0:23:11", + " - MPEG2, 480i60 /1.001 (4:3)", + " - AC3, English, stereo, 48kHz", + " - AC3, English, stereo, 48kHz", + " - AC3, English, stereo, 48kHz", + " - AC3, English, stereo, 48kHz", + " - AC3, English, stereo, 48kHz", + " - AC3, English, stereo, 48kHz", + " - AC3, English, stereo, 48kHz", + " - AC3, English, stereo, 48kHz", + "", + "5) 00005.mpls, 00006.m2ts, 0:21:31", + " - MPEG2, 480i60 /1.001 (16:9)", + " - AC3, English, stereo, 48kHz", + " - AC3, English, stereo, 48kHz", + " - AC3, English, stereo, 48kHz", + " - AC3, English, stereo, 48kHz", + " - AC3, English, stereo, 48kHz", + " - AC3, English, stereo, 48kHz", + " - AC3, English, stereo, 48kHz", + " - AC3, English, stereo, 48kHz", + "", + "6) 00004.mpls, 00005.m2ts, 0:21:25", + " - MPEG2, 480i60 /1.001 (16:9)", + " - AC3, English, stereo, 48kHz", + " - AC3, English, stereo, 48kHz", + " - AC3, English, stereo, 48kHz", + " - AC3, English, stereo, 48kHz", + " - AC3, English, stereo, 48kHz", + " - AC3, English, stereo, 48kHz", + " - AC3, English, stereo, 48kHz", + " - AC3, English, stereo, 48kHz", + "", + "7) 00007.mpls, 00008.m2ts, 0:20:42", + " - MPEG2, 480i60 /1.001 (16:9)", + " - AC3, English, stereo, 48kHz", + " - AC3, English, stereo, 48kHz", + " - AC3, English, stereo, 48kHz", + " - AC3, English, stereo, 48kHz", + " - AC3, English, stereo, 48kHz", + " - AC3, English, stereo, 48kHz", + " - AC3, English, stereo, 48kHz", + " - AC3, English, stereo, 48kHz", + ""); + + // hard-coded stdout of running eac3to on a single blu-ray title + private static final String BLU_RAY_TITLE_LEGACY = String.join("\n", + "M2TS, 1 video track, 8 audio tracks, 18 subtitle tracks, 2:28:52, 24p /1.001", + "1: Chapters, 34 chapters", + "2: VC-1, 1080p24 /1.001 (16:9)", + "3: AC3, English, 5.1 channels, 448kbps, 48kHz, dialnorm: -27dB", + "4: RAW/PCM, English, 5.1 channels, 16 bits, 48kHz", + "5: AC3, French, 5.1 channels, 448kbps, 48kHz, dialnorm: -27dB", + "6: AC3, Spanish, 5.1 channels, 448kbps, 48kHz, dialnorm: -27dB", + "7: AC3, German, 5.1 channels, 448kbps, 48kHz, dialnorm: -27dB", + "8: AC3, Italian, 5.1 channels, 448kbps, 48kHz, dialnorm: -27dB", + "9: AC3, Spanish, 5.1 channels, 448kbps, 48kHz, dialnorm: -27dB", + "10: AC3 Surround, English, 2.0 channels, 192kbps, 48kHz, dialnorm: -27dB", + "11: Subtitle (PGS), English", + "12: Subtitle (PGS), English", + "13: Subtitle (PGS), French", + "14: Subtitle (PGS), German", + "15: Subtitle (PGS), German", + "16: Subtitle (PGS), Italian", + "17: Subtitle (PGS), Italian", + "18: Subtitle (PGS), Spanish", + "19: Subtitle (PGS), Dutch", + "20: Subtitle (PGS), Chinese", + "21: Subtitle (PGS), Danish", + "22: Subtitle (PGS), Finnish", + "23: Subtitle (PGS), Korean", + "24: Subtitle (PGS), Norwegian", + "25: Subtitle (PGS), Portuguese", + "26: Subtitle (PGS), Portuguese", + "27: Subtitle (PGS), Spanish", + "28: Subtitle (PGS), Swedish"); + + // hard-coded stdout of running eac3to on an mkv file with a TrueHD track; has quoted track names, both on their + // own line and inline + private static final String MKV_FILE_TRUEHD_LEGACY = String.join("\n", + "MKV, 1 video track, 3 audio tracks, 2 subtitle tracks, 2:12:06, 24p/1.001", + "1: h264/AVC, English, 1080p24/1.001 (16:9)", + "2: TrueHD, English, 7.1 channels, 48kHz, dialnorm: -31dB", + " \"TrueHD\"", + "3: AC3, English, 5.1 channels, 640kbps, 48kHz, dialnorm: -31dB", + "4: AC3, Spanish, 5.1 channels, 640kbps, 48kHz, dialnorm: -31dB", + " \"Spanish\"", + "5: Subtitle (PGS), English", + "6: Subtitle (PGS), Spanish, \"Spanish\""); + + // hard-coded stdout of running eac3to on an mkv file with a DTS-HD MA track; includes a parenthetical "(core: ...)" + // metadata line that the parser must ignore + private static final String MKV_FILE_DTSHD_LEGACY = String.join("\n", + "MKV, 1 video track, 2 audio tracks, 2 subtitle tracks, 2:44:34, 24p/1.001", + "1: h264/AVC, English, 1080p24/1.001 (16:9)", + "2: DTS-HD Master Audio, English, 5.1 channels, 24 bits, 48kHz, dialnorm: 0dB", + " (core: DTS, 5.1 channels, 1509kbps, 48kHz, dialnorm: 0dB)", + " \"DTS-HD\"", + "3: AC3, Spanish, 5.1 channels, 448kbps, 48kHz, dialnorm: -31dB", + " \"Spanish\"", + "4: Subtitle (PGS), English", + "5: Subtitle (PGS), Spanish, \"Spanish\""); + + // a track line whose format type (audio/video/subtitle/chapters) can't be determined: triggers the + // FormatTypeParseException -> ParseException -> RuntimeException path in parseSingleTitleOrFile + private static final String MALFORMED_TRACK_LEGACY = String.join("\n", + "MKV, 1 video track, 0 audio tracks, 0 subtitle tracks, 1:00:00, 24p/1.001", + "1: BogusFormat, gibberish, more gibberish"); + + // VobSub has no entry in eac3to-format-extensions.json, so demuxing track 2 is skipped while track 1 (AC3) is not + private static final String MKV_FILE_VOBSUB_LEGACY = String.join("\n", + "MKV, 1 video track, 1 audio track, 1 subtitle track, 1:00:00, 24p/1.001", + "1: AC3, English, 5.1 channels, 448kbps, 48kHz", + "2: Subtitle (VobSub), English"); + + @Test + public void scanBluRayDir_returnsAllTitleNumbers(@TempDir final File bluRayDir) throws Exception { + final FileScanner scanner = scannerReturning(0, BLU_RAY_DISC_LEGACY); + final Set titles = scanner.scanBluRayDir(bluRayDir); + + assertEquals(new HashSet<>(Arrays.asList(1, 2, 3, 4, 5, 6, 7)), titles); + } + + @Test + public void scanAndParseBluRayTitle_parsesEveryTrack(@TempDir final File bluRayDir) throws Exception { + final FileScanner scanner = scannerReturning(0, BLU_RAY_TITLE_LEGACY); + + final Video video = scanner.scanAndParseBluRayTitle(bluRayDir, 1); + final List tracks = video.getTracks(); + + // the leading "M2TS, 1 video track, ..." summary line is not a track; the 28 numbered lines are + assertEquals(28, tracks.size()); + assertTrack(tracks.get(0), 1, "Matroska", "Chapters", "Undetermined"); + assertTrack(tracks.get(1), 2, "VC-1", "Video", "Undetermined"); + assertTrack(tracks.get(2), 3, "AC3", "Audio", "English"); + assertTrack(tracks.get(3), 4, "RAW/PCM", "Audio", "English"); + assertTrack(tracks.get(4), 5, "AC3", "Audio", "French"); + assertTrack(tracks.get(5), 6, "AC3", "Audio", "Spanish"); + assertTrack(tracks.get(6), 7, "AC3", "Audio", "German"); + assertTrack(tracks.get(7), 8, "AC3", "Audio", "Italian"); + assertTrack(tracks.get(8), 9, "AC3", "Audio", "Spanish"); + assertTrack(tracks.get(9), 10, "AC3 Surround", "Audio", "English"); + assertTrack(tracks.get(10), 11, "PGS", "Subtitles", "English"); + assertTrack(tracks.get(11), 12, "PGS", "Subtitles", "English"); + assertTrack(tracks.get(12), 13, "PGS", "Subtitles", "French"); + assertTrack(tracks.get(13), 14, "PGS", "Subtitles", "German"); + assertTrack(tracks.get(14), 15, "PGS", "Subtitles", "German"); + assertTrack(tracks.get(15), 16, "PGS", "Subtitles", "Italian"); + assertTrack(tracks.get(16), 17, "PGS", "Subtitles", "Italian"); + assertTrack(tracks.get(17), 18, "PGS", "Subtitles", "Spanish"); + assertTrack(tracks.get(18), 19, "PGS", "Subtitles", "Dutch"); + assertTrack(tracks.get(19), 20, "PGS", "Subtitles", "Chinese"); + assertTrack(tracks.get(20), 21, "PGS", "Subtitles", "Danish"); + assertTrack(tracks.get(21), 22, "PGS", "Subtitles", "Finnish"); + assertTrack(tracks.get(22), 23, "PGS", "Subtitles", "Korean"); + assertTrack(tracks.get(23), 24, "PGS", "Subtitles", "Norwegian"); + assertTrack(tracks.get(24), 25, "PGS", "Subtitles", "Portuguese"); + assertTrack(tracks.get(25), 26, "PGS", "Subtitles", "Portuguese"); + assertTrack(tracks.get(26), 27, "PGS", "Subtitles", "Spanish"); + assertTrack(tracks.get(27), 28, "PGS", "Subtitles", "Swedish"); + } + + @Test + public void scanAndParseFile_parsesTracksAndQuotedNames() throws Exception { + final FileScanner scanner = scannerReturning(0, MKV_FILE_TRUEHD_LEGACY); + + final List tracks = scanner.scanAndParseFile(new File("movie.mkv")).getTracks(); + + // the leading "MKV, 1 video track, ..." summary line is not a track; the 6 numbered lines are + assertEquals(6, tracks.size()); + assertTrack(tracks.get(0), 1, null, "h264/AVC", "Video", "English"); + assertTrack(tracks.get(1), 2, "TrueHD", "TrueHD", "Audio", "English"); // name on its own line + assertTrack(tracks.get(2), 3, null, "AC3", "Audio", "English"); + assertTrack(tracks.get(3), 4, "Spanish", "AC3", "Audio", "Spanish"); // name on its own line + assertTrack(tracks.get(4), 5, null, "PGS", "Subtitles", "English"); + assertTrack(tracks.get(5), 6, "Spanish", "PGS", "Subtitles", "Spanish"); // name inline on the track line + } + + @Test + public void scanAndParseFile_ignoresParentheticalMetadataLines() throws Exception { + final FileScanner scanner = scannerReturning(0, MKV_FILE_DTSHD_LEGACY); + + final List tracks = scanner.scanAndParseFile(new File("movie.mkv")).getTracks(); + + assertEquals(5, tracks.size()); + assertTrack(tracks.get(0), 1, null, "h264/AVC", "Video", "English"); + // the "(core: ...)" line is ignored; the following "DTS-HD" line sets this track's name + assertTrack(tracks.get(1), 2, "DTS-HD", "DTS-HD Master Audio", "Audio", "English"); + assertTrack(tracks.get(2), 3, "Spanish", "AC3", "Audio", "Spanish"); + assertTrack(tracks.get(3), 4, null, "PGS", "Subtitles", "English"); + assertTrack(tracks.get(4), 5, "Spanish", "PGS", "Subtitles", "Spanish"); + } + + @Test + public void scanAndParseFile_whenTrackFormatTypeUnparseable_throwsRuntimeException() throws Exception { + final FileScanner scanner = scannerReturning(0, MALFORMED_TRACK_LEGACY); + + final RuntimeException ex = assertThrows(RuntimeException.class, + () -> scanner.scanAndParseFile(new File("movie.mkv"))); + assertTrue(ex.getMessage().contains("Possible bug in MkvScanner"), + "unexpected message: " + ex.getMessage()); + } + + @Test + public void demuxFileByTracks_buildsArgsAndIgnoresTrackNames() throws Exception { + final File outputDir = new File("out"); + final FileScanner scanner = scannerWithOutputDir(outputDir, MKV_FILE_TRUEHD_LEGACY); + final File file = new File("movie.mkv"); + + // tracks 4 and 6 carry quoted names ("Spanish") that must NOT leak into the output filenames + final Collection filenames = scanner.demuxFileByTracks(file, Arrays.asList(2, 4, 6)); + + final List> commands = capturedCommands(2); + assertEquals(Arrays.asList("eac3to", "movie.mkv"), commands.get(0)); + assertEquals(Arrays.asList("eac3to", "movie.mkv", + "2:", out(outputDir, "movie_tr2_English_TrueHD.thd+ac3"), + "4:", out(outputDir, "movie_tr4_Spanish_AC3.ac3"), + "6:", out(outputDir, "movie_tr6_Spanish_PGS.sup")), commands.get(1)); + assertEquals(new HashSet<>(Arrays.asList("movie_tr2_English_TrueHD.thd+ac3", + "movie_tr4_Spanish_AC3.ac3", "movie_tr6_Spanish_PGS.sup")), new HashSet<>(filenames)); + } + + @Test + public void demuxFileByTracks_emitsEveryConfigForMultiConfigFormat() throws Exception { + final File outputDir = new File("out"); + final FileScanner scanner = scannerWithOutputDir(outputDir, MKV_FILE_DTSHD_LEGACY); + final File file = new File("movie.mkv"); + + // DTS-HD Master Audio maps to two ExtensionConfigs: {dtshd} and {dts, -core}, so track 2 demuxes to two files, + // the second of which appends the "-core" flag as an argument (and "core" into its filename) + final Collection filenames = scanner.demuxFileByTracks(file, Arrays.asList(2, 3)); + + final List> commands = capturedCommands(2); + assertEquals(Arrays.asList("eac3to", "movie.mkv"), commands.get(0)); + + final List demux = commands.get(1); + assertEquals(Arrays.asList("eac3to", "movie.mkv"), demux.subList(0, 2)); + // the two configs come from a HashSet, so their relative order isn't guaranteed: compare order-insensitively + assertEquals(sorted(Arrays.asList( + "2:", out(outputDir, "movie_tr2_English_DTS-HD_Master_Audio.dtshd"), + "2:", out(outputDir, "movie_tr2_English_core_DTS-HD_Master_Audio.dts"), "-core", + "3:", out(outputDir, "movie_tr3_Spanish_AC3.ac3"))), + sorted(demux.subList(2, demux.size()))); + + assertEquals(new HashSet<>(Arrays.asList( + "movie_tr2_English_DTS-HD_Master_Audio.dtshd", + "movie_tr2_English_core_DTS-HD_Master_Audio.dts", + "movie_tr3_Spanish_AC3.ac3")), new HashSet<>(filenames)); + } + + @Test + public void demuxFileByTracks_skipsTrackWithNoFormatExtensionConfig() throws Exception { + final File outputDir = new File("out"); + final FileScanner scanner = scannerWithOutputDir(outputDir, MKV_FILE_VOBSUB_LEGACY); + final File file = new File("movie.mkv"); + + // track 2 (VobSub) has no entry in eac3to-format-extensions.json, so it is skipped; only track 1 (AC3) demuxes + final Collection filenames = scanner.demuxFileByTracks(file, Arrays.asList(1, 2)); + + final List> commands = capturedCommands(2); + assertEquals(Arrays.asList("eac3to", "movie.mkv"), commands.get(0)); + assertEquals(Arrays.asList("eac3to", "movie.mkv", + "1:", out(outputDir, "movie_tr1_English_AC3.ac3")), commands.get(1)); + assertEquals(Collections.singleton("movie_tr1_English_AC3.ac3"), new HashSet<>(filenames)); + } + + private static void assertTrack(final Track track, final int number, final String formatName, + final String formatType, final String language) { + assertTrack(track, number, null, formatName, formatType, language); + } + + private static void assertTrack(final Track track, final int number, final String name, final String formatName, + final String formatType, final String language) { + assertEquals(number, track.getNumber()); + assertEquals(name, track.getName()); + assertNotNull(track.getFormat()); + assertEquals(formatName, track.getFormat().getName()); + assertEquals(formatType, track.getFormat().getFormatType()); + assertEquals(language, track.getLanguage()); + } + + /** + * Builds a scanner whose (mocked) executable returns the given exit code and stdout. exec() doesn't touch the + * filesystem, so the output directory is just a dummy and the scanned file need not exist. + */ + private FileScanner scannerReturning(final int exitValue, final String stdout) throws Exception { + when(processRunner.run(anyList())).thenReturn(new ProcessResult(exitValue, stdout, "")); + return new Eac3toScanner("eac3to", new File("out"), processRunner); + } + + @Test + public void exec_whenFormatNotDetected_throwsCorruptBluRayStructureException() throws Exception { + final FileScanner scanner = scannerReturning(1, "The format of the source file could not be detected."); + + final CorruptBluRayStructureException ex = assertThrows(CorruptBluRayStructureException.class, + () -> scanner.exec(new File("movie.m2ts"))); + assertEquals("movie.m2ts", ex.getFilename()); + assertEquals("The format of the source file could not be detected.", ex.getDemuxerOutput()); + } + + @Test + public void exec_whenFileUnreadable_throwsUnreadableFileException() throws Exception { + final FileScanner scanner = scannerReturning(1, "Error reading file \"movie.txt\"."); + + assertThrows(UnreadableFileException.class, () -> scanner.exec(new File("movie.txt"))); + } + + @Test + public void exec_whenConversionUnsupported_throwsFormatConversionException() throws Exception { + final FileScanner scanner = scannerReturning(1, "This audio conversion is not supported."); + + assertThrows(FormatConversionException.class, () -> scanner.exec(new File("movie.m2ts"))); + } + + @Test + public void exec_whenNotBluRayDirectory_throwsNotBluRayDirectoryException() throws Exception { + final FileScanner scanner = scannerReturning(1, "HD DVD / Blu-Ray disc structure not found."); + + assertThrows(NotBluRayDirectoryException.class, () -> scanner.exec(new File("not-a-bd-dir"))); + } + + @Test + public void exec_whenUnrecognizedError_throwsGenericDemuxerException() throws Exception { + final FileScanner scanner = scannerReturning(1, "some unrecognized eac3to failure"); + + final DemuxerException ex = assertThrows(DemuxerException.class, () -> scanner.exec(new File("movie.m2ts"))); + // a non-zero exit with no recognized message yields the base type, not one of the subclasses + assertEquals(DemuxerException.class, ex.getClass()); + } + + @Test + public void demuxFileByTracks_demuxesSelectedTrackNumbers() throws Exception { + final File outputDir = new File("out"); + final FileScanner scanner = scannerWithOutputDir(outputDir, BLU_RAY_TITLE_LEGACY); + final File file = new File("movie.m2ts"); + + final Collection filenames = scanner.demuxFileByTracks(file, Arrays.asList(3, 11)); + + // demuxHelper scans first, then runs the demux; the scan of a plain file passes no extra args + final List> commands = capturedCommands(2); + assertEquals(Arrays.asList("eac3to", "movie.m2ts"), commands.get(0)); + assertEquals(Arrays.asList("eac3to", "movie.m2ts", + "3:", out(outputDir, "movie_tr3_English_AC3.ac3"), + "11:", out(outputDir, "movie_tr11_English_PGS.sup")), commands.get(1)); + assertEquals(new HashSet<>(Arrays.asList( + "movie_tr3_English_AC3.ac3", "movie_tr11_English_PGS.sup")), new HashSet<>(filenames)); + } + + @Test + public void demuxFileByLanguages_demuxesEveryTrackInLanguage() throws Exception { + final File outputDir = new File("out"); + final FileScanner scanner = scannerWithOutputDir(outputDir, BLU_RAY_TITLE_LEGACY); + final File file = new File("movie.m2ts"); + + // German appears on tracks 7 (AC3), 14 (PGS) and 15 (PGS) + final Collection filenames = scanner.demuxFileByLanguages(file, Collections.singletonList("German")); + + final List> commands = capturedCommands(2); + assertEquals(Arrays.asList("eac3to", "movie.m2ts"), commands.get(0)); + assertEquals(Arrays.asList("eac3to", "movie.m2ts", + "7:", out(outputDir, "movie_tr7_German_AC3.ac3"), + "14:", out(outputDir, "movie_tr14_German_PGS.sup"), + "15:", out(outputDir, "movie_tr15_German_PGS.sup")), commands.get(1)); + assertEquals(new HashSet<>(Arrays.asList("movie_tr7_German_AC3.ac3", + "movie_tr14_German_PGS.sup", "movie_tr15_German_PGS.sup")), new HashSet<>(filenames)); + } + + @Test + public void demuxBluRayTitleByTracks_buildsArgsForTitleAndMixedFormats(@TempDir final File tempDir) throws Exception { + final File outputDir = new File("out"); + final FileScanner scanner = scannerWithOutputDir(outputDir, BLU_RAY_TITLE_LEGACY); + final File bluRayDir = new File(tempDir, "MOVIE_BD"); + assertTrue(bluRayDir.mkdir()); + + // track 1 = chapters (looked up by format type, no friendly-name suffix), 2 = VC-1 video, 4 = RAW/PCM audio + final Collection filenames = scanner.demuxBluRayTitleByTracks(bluRayDir, 1, Arrays.asList(1, 2, 4)); + + final List> commands = capturedCommands(2); + // scanning a title passes the ")" selector to the executable + assertEquals(Arrays.asList("eac3to", bluRayDir.getPath(), "1)"), commands.get(0)); + assertEquals(Arrays.asList("eac3to", bluRayDir.getPath(), "1)", + "1:", out(outputDir, "MOVIE_BD_ti1_tr1_Undetermined.txt"), + "2:", out(outputDir, "MOVIE_BD_ti1_tr2_Undetermined_VC-1.mkv"), + "4:", out(outputDir, "MOVIE_BD_ti1_tr4_English_RAW_PCM.pcm")), commands.get(1)); + assertEquals(new HashSet<>(Arrays.asList("MOVIE_BD_ti1_tr1_Undetermined.txt", + "MOVIE_BD_ti1_tr2_Undetermined_VC-1.mkv", "MOVIE_BD_ti1_tr4_English_RAW_PCM.pcm")), + new HashSet<>(filenames)); + } + + @Test + public void demuxBluRayTitleByLanguages_buildsArgsForTitleAndLanguage(@TempDir final File tempDir) throws Exception { + final File outputDir = new File("out"); + final FileScanner scanner = scannerWithOutputDir(outputDir, BLU_RAY_TITLE_LEGACY); + final File bluRayDir = new File(tempDir, "MOVIE_BD"); + assertTrue(bluRayDir.mkdir()); + + // French appears on tracks 5 (AC3) and 13 (PGS) + final Collection<String> filenames = scanner.demuxBluRayTitleByLanguages(bluRayDir, 1, Collections.singletonList("French")); + + final List<List<String>> commands = capturedCommands(2); + assertEquals(Arrays.asList("eac3to", bluRayDir.getPath(), "1)"), commands.get(0)); + assertEquals(Arrays.asList("eac3to", bluRayDir.getPath(), "1)", + "5:", out(outputDir, "MOVIE_BD_ti1_tr5_French_AC3.ac3"), + "13:", out(outputDir, "MOVIE_BD_ti1_tr13_French_PGS.sup")), commands.get(1)); + assertEquals(new HashSet<>(Arrays.asList( + "MOVIE_BD_ti1_tr5_French_AC3.ac3", "MOVIE_BD_ti1_tr13_French_PGS.sup")), new HashSet<>(filenames)); + } + + private FileScanner scannerWithOutputDir(final File outputDir, final String scanOutput) throws Exception { + when(processRunner.run(anyList())).thenReturn(new ProcessResult(0, scanOutput, "")); + return new Eac3toScanner("eac3to", outputDir, processRunner); + } + + /** Captures the command list of every {@code processRunner.run(...)} call, asserting there were exactly {@code n}. */ + @SuppressWarnings("unchecked") + private List<List<String>> capturedCommands(final int n) throws Exception { + final ArgumentCaptor<List<String>> captor = ArgumentCaptor.forClass(List.class); + verify(processRunner, times(n)).run(captor.capture()); + return captor.getAllValues(); + } + + /** The absolute demux output path the scanner would build for a file named {@code name} in {@code outputDir}. */ + private static String out(final File outputDir, final String name) { + return outputDir.getAbsolutePath() + File.separator + name; + } + + /** Returns a sorted copy of the given list, for order-insensitive comparison of command arguments. */ + private static List<String> sorted(final List<String> values) { + final List<String> copy = new ArrayList<>(values); + Collections.sort(copy); + return copy; + } +} From 72de3da83f28a970a4025fcbcc976846d7b372d4 Mon Sep 17 00:00:00 2001 From: Aaron Rodriguez <adashrod@gmail.com> Date: Sat, 13 Jun 2026 14:00:54 -0700 Subject: [PATCH 2/7] github action config --- .github/workflows/ant.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 .github/workflows/ant.yml diff --git a/.github/workflows/ant.yml b/.github/workflows/ant.yml new file mode 100644 index 0000000..f44316f --- /dev/null +++ b/.github/workflows/ant.yml @@ -0,0 +1,16 @@ +name: Java CI + +on: [push] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v1 + - name: Set up JDK 11 + uses: actions/setup-java@v1 + with: + java-version: 11 + - name: run test suite with ant + run: ant -noinput -buildfile build.xml test -DbuildEnv=ci From 80ed7c9cee47f680029cbf4b5a98bd1ebba9609f Mon Sep 17 00:00:00 2001 From: Aaron Rodriguez <adashrod@gmail.com> Date: Sat, 13 Jun 2026 14:11:23 -0700 Subject: [PATCH 3/7] verbose --- .github/workflows/ant.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ant.yml b/.github/workflows/ant.yml index f44316f..3db4d65 100644 --- a/.github/workflows/ant.yml +++ b/.github/workflows/ant.yml @@ -13,4 +13,4 @@ jobs: with: java-version: 11 - name: run test suite with ant - run: ant -noinput -buildfile build.xml test -DbuildEnv=ci + run: ant -v -noinput -buildfile build.xml test -DbuildEnv=ci From 7bb28e0b079b4d34785e946fff425f5387b40723 Mon Sep 17 00:00:00 2001 From: Aaron Rodriguez <adashrod@gmail.com> Date: Sat, 13 Jun 2026 14:13:11 -0700 Subject: [PATCH 4/7] java 8 --- .github/workflows/ant.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ant.yml b/.github/workflows/ant.yml index 3db4d65..2ee12e9 100644 --- a/.github/workflows/ant.yml +++ b/.github/workflows/ant.yml @@ -8,9 +8,9 @@ jobs: steps: - uses: actions/checkout@v1 - - name: Set up JDK 11 + - name: Set up JDK 8 uses: actions/setup-java@v1 with: - java-version: 11 + java-version: 8 - name: run test suite with ant run: ant -v -noinput -buildfile build.xml test -DbuildEnv=ci From 6f203498ed09a16b37339e8b0d6244c7aeb47fad Mon Sep 17 00:00:00 2001 From: Aaron Rodriguez <adashrod@gmail.com> Date: Sat, 13 Jun 2026 14:18:09 -0700 Subject: [PATCH 5/7] disable junit.install --- build.xml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/build.xml b/build.xml index a1a1922..86d7ed1 100644 --- a/build.xml +++ b/build.xml @@ -69,13 +69,13 @@ </target> <target name="junit.install" depends="ivy.resolve.test" unless="junit.installed" description="installs junit libs into ANT_HOME"> - <echo message="installing ant junit libs to ${ant.home}; IF THIS FAILS, YOU MUST FIRST RUN 'sudo ant junit.install'"/> - <copy file="lib/test/org.junit.jupiter-junit-jupiter-api-5.4.2.jar" tofile="${ant.home}/lib/junit-jupiter-api.jar"/> - <copy file="lib/test/org.junit.jupiter-junit-jupiter-engine-5.4.2.jar" tofile="${ant.home}/lib/junit-jupiter-engine.jar"/> - <copy file="lib/test/org.junit.platform-junit-platform-commons-1.4.2.jar" tofile="${ant.home}/lib/junit-platform-commons.jar"/> - <copy file="lib/test/org.junit.platform-junit-platform-engine-1.4.2.jar" tofile="${ant.home}/lib/junit-platform-engine.jar"/> - <copy file="lib/test/org.junit.platform-junit-platform-launcher-1.4.2.jar" tofile="${ant.home}/lib/junit-platform-launcher.jar"/> - <copy file="lib/test/org.opentest4j-opentest4j-1.1.1.jar" tofile="${ant.home}/lib/opentest4j.jar"/> +<!-- <echo message="installing ant junit libs to ${ant.home}; IF THIS FAILS, YOU MUST FIRST RUN 'sudo ant junit.install'"/>--> +<!-- <copy file="lib/test/org.junit.jupiter-junit-jupiter-api-5.4.2.jar" tofile="${ant.home}/lib/junit-jupiter-api.jar"/>--> +<!-- <copy file="lib/test/org.junit.jupiter-junit-jupiter-engine-5.4.2.jar" tofile="${ant.home}/lib/junit-jupiter-engine.jar"/>--> +<!-- <copy file="lib/test/org.junit.platform-junit-platform-commons-1.4.2.jar" tofile="${ant.home}/lib/junit-platform-commons.jar"/>--> +<!-- <copy file="lib/test/org.junit.platform-junit-platform-engine-1.4.2.jar" tofile="${ant.home}/lib/junit-platform-engine.jar"/>--> +<!-- <copy file="lib/test/org.junit.platform-junit-platform-launcher-1.4.2.jar" tofile="${ant.home}/lib/junit-platform-launcher.jar"/>--> +<!-- <copy file="lib/test/org.opentest4j-opentest4j-1.1.1.jar" tofile="${ant.home}/lib/opentest4j.jar"/>--> </target> <target name="compile.main" depends="ivy.resolve.main" description="compile source"> From a6b4ffcef52776ca9774c0724c53c6f577e84b26 Mon Sep 17 00:00:00 2001 From: Aaron Rodriguez <adashrod@gmail.com> Date: Sat, 13 Jun 2026 14:44:14 -0700 Subject: [PATCH 6/7] use ConsoleLauncher instead of <junitlauncher/> --- build.xml | 123 ++++++++++++++++-------------------------------------- ivy.xml | 3 +- 2 files changed, 36 insertions(+), 90 deletions(-) diff --git a/build.xml b/build.xml index 86d7ed1..a3dd21a 100644 --- a/build.xml +++ b/build.xml @@ -14,7 +14,6 @@ <property name="build.main.dir" value="${build.dir}/main"/> <property name="build.test.dir" value="${build.dir}/test"/> <property name="target.dir" value="target"/> - <property name="report.dir" value="${build.test.dir}/report"/> <property name="dist.dir" value="dist"/> <property name="javadoc.dir" value="javadoc"/> <property name="artifact.name" value="mkvscanner"/> @@ -29,18 +28,7 @@ <include name="**/*.jar"/> </fileset> </path> - <path id="classpath.test"> - <fileset dir="${lib.dir}/test"> - <include name="**/*.jar"/> - </fileset> - <pathelement path="${build.main.dir}"/> - <pathelement path="${build.test.dir}"/> - <!-- so Eac3toScanner can load /eac3to-languages.txt and /eac3to-format-extensions.json at test time --> - <pathelement location="${resources.main.dir}"/> - </path> - <available file="${ivy.jar.file}" property="ivy.installed"/> - <available file="${ant.home}/lib/junit-jupiter-api.jar" property="junit.installed"/> <target name="ivy.load-settings" depends="ivy.install"> <ivy:settings file="ivySettings.xml"/> @@ -68,16 +56,6 @@ <ivy:retrieve pattern="${lib.dir}/[conf]/[organisation]-[artifact]-[revision].[ext]" conf="test"/> </target> - <target name="junit.install" depends="ivy.resolve.test" unless="junit.installed" description="installs junit libs into ANT_HOME"> -<!-- <echo message="installing ant junit libs to ${ant.home}; IF THIS FAILS, YOU MUST FIRST RUN 'sudo ant junit.install'"/>--> -<!-- <copy file="lib/test/org.junit.jupiter-junit-jupiter-api-5.4.2.jar" tofile="${ant.home}/lib/junit-jupiter-api.jar"/>--> -<!-- <copy file="lib/test/org.junit.jupiter-junit-jupiter-engine-5.4.2.jar" tofile="${ant.home}/lib/junit-jupiter-engine.jar"/>--> -<!-- <copy file="lib/test/org.junit.platform-junit-platform-commons-1.4.2.jar" tofile="${ant.home}/lib/junit-platform-commons.jar"/>--> -<!-- <copy file="lib/test/org.junit.platform-junit-platform-engine-1.4.2.jar" tofile="${ant.home}/lib/junit-platform-engine.jar"/>--> -<!-- <copy file="lib/test/org.junit.platform-junit-platform-launcher-1.4.2.jar" tofile="${ant.home}/lib/junit-platform-launcher.jar"/>--> -<!-- <copy file="lib/test/org.opentest4j-opentest4j-1.1.1.jar" tofile="${ant.home}/lib/opentest4j.jar"/>--> - </target> - <target name="compile.main" depends="ivy.resolve.main" description="compile source"> <mkdir dir="${build.main.dir}"/> <javac destdir="${build.main.dir}" includeantruntime="false" classpathref="classpath.ivy-runtime"> @@ -119,79 +97,48 @@ <jar basedir="${javadoc.dir}" file="${dist.dir}/${artifact.name}-${ivy.revision}-javadoc.jar"/> </target> - <target name="test" depends="compile.test, junit.install" description="Runs all of the tests. Use test.name (FQ class name) and optionally test.functions (comma-separated list) to run specific tests"> - <delete dir="${report.dir}" failonerror="false"/> - <mkdir dir="${report.dir}"/> - <junitlauncher failureproperty="test-failed"> - <classpath refid="classpath.test"/> - <testclasses unless:set="test.name" outputdir="${report.dir}"> - <fileset dir="${build.test.dir}"> - <include name="**/*" /> - </fileset> - <listener type="legacy-xml" sendSysOut="true" sendSysErr="true"/> - <listener type="legacy-plain" sendSysOut="true" sendSysErr="true"/> - </testclasses> - </junitlauncher> - <condition property="test-one-class.test-some"> + <target name="test" depends="compile.test" description="Runs the tests via the JUnit Platform ConsoleLauncher (forked). Use test.name (FQ class name) and optionally test.functions (a single method; include parameter types for methods that take them, e.g. myTest(java.io.File)) to run specific tests"> + <!-- map the optional test.name/test.functions to ConsoleLauncher selectors; first-set wins, so order + from most specific to least --> + <condition property="test.select" value="--select-method=${test.name}#${test.functions}"> <and> <isset property="test.name"/> <isset property="test.functions"/> </and> </condition> - <condition property="test-one-class.test-all"> - <and> - <isset property="test.name"/> - <not><isset property="test.functions"/></not> - </and> + <condition property="test.select" value="--select-class=${test.name}"> + <isset property="test.name"/> </condition> - <antcall target="-test.test-one-class.test-all-functions"/> - <antcall target="-test.test-one-class.test-some-functions"/> - <junitreport todir="${report.dir}"> - <fileset dir="${report.dir}" includes="TEST-*.xml"/> - <report todir="${report.dir}"/> - </junitreport> - <concat fixlastline="true"> - <fileset dir="${report.dir}" includes="*.txt"/> - </concat> - <fail if="test-failed" message="ONE OR MORE TESTS FAILED."/> - </target> - - <target name="-test.test-one-class.test-all-functions" if="test-one-class.test-all"> - <echo message="Testing `${test.name}`, all tests"/> - <junitlauncher failureproperty="test-failed"> - <classpath refid="classpath.test"/> - <test outputdir="${report.dir}" name="${test.name}"> - <listener type="legacy-xml" sendSysOut="true" sendSysErr="true"/> - <listener type="legacy-plain" sendSysOut="true" sendSysErr="true"/> - </test> - </junitlauncher> - <junitreport todir="${report.dir}"> - <fileset dir="${report.dir}" includes="TEST-*.xml"/> - <report todir="${report.dir}"/> - </junitreport> - <concat fixlastline="true"> - <fileset dir="${report.dir}" includes="*.txt"/> - </concat> - <fail if="test-failed" message="ONE OR MORE TESTS FAILED."/> - </target> + <property name="test.select" value="--scan-classpath=${build.test.dir}"/> - <target name="-test.test-one-class.test-some-functions" if="test-one-class.test-some"> - <echo message="Testing `${test.name}#${test.functions}`"/> - <junitlauncher failureproperty="test-failed"> - <classpath refid="classpath.test"/> - <test outputdir="${report.dir}" name="${test.name}" methods="${test.functions}"> - <listener type="legacy-xml" sendSysOut="true" sendSysErr="true"/> - <listener type="legacy-plain" sendSysOut="true" sendSysErr="true"/> - </test> - </junitlauncher> - <junitreport todir="${report.dir}"> - <fileset dir="${report.dir}" includes="TEST-*.xml"/> - <report todir="${report.dir}"/> - </junitreport> - <concat fixlastline="true"> - <fileset dir="${report.dir}" includes="*.txt"/> - </concat> - <fail if="test-failed" message="ONE OR MORE TESTS FAILED."/> + <!-- forked, fully self-contained classpath: the console-standalone fat jar supplies all junit/platform + classes, so the other junit jars are excluded to avoid duplicate classes (mockito/jackson/slf4j/ + byte-buddy/objenesis are kept). Nothing here references ${ant.home}. --> + <java fork="true" failonerror="false" resultproperty="test.exitcode" + classname="org.junit.platform.console.ConsoleLauncher"> + <classpath> + <fileset dir="${lib.dir}/test" includes="org.junit.platform-junit-platform-console-standalone-*.jar"/> + <pathelement path="${build.main.dir}"/> + <pathelement path="${build.test.dir}"/> + <!-- so Eac3toScanner can load /eac3to-languages.txt and /eac3to-format-extensions.json --> + <pathelement location="${resources.main.dir}"/> + <fileset dir="${lib.dir}/test"> + <include name="*.jar"/> + <exclude name="org.junit.*"/> + <exclude name="junit-junit-*"/> + <exclude name="org.opentest4j-*"/> + <exclude name="org.apiguardian-*"/> + </fileset> + </classpath> + <arg line="${test.select}"/> + <arg value="--details=tree"/> + <arg value="--fail-if-no-tests"/> + </java> + <fail message="ONE OR MORE TESTS FAILED."> + <condition> + <not><equals arg1="${test.exitcode}" arg2="0"/></not> + </condition> + </fail> </target> diff --git a/ivy.xml b/ivy.xml index a7d32f9..e3ff356 100644 --- a/ivy.xml +++ b/ivy.xml @@ -26,8 +26,7 @@ <dependency org="com.fasterxml.jackson.core" name="jackson-databind" rev="2.7.0"/> <dependency org="org.junit.jupiter" name="junit-jupiter-engine" rev="5.4.2" conf="test->default"/> <dependency org="org.junit.platform" name="junit-platform-engine" rev="1.4.2" conf="test->default"/> - <dependency org="org.junit.platform" name="junit-platform-runner" rev="1.4.2" conf="test->default"/> - <dependency org="org.apache.ant" name="ant-junitlauncher" rev="1.10.5" conf="test->default"/> + <dependency org="org.junit.platform" name="junit-platform-console-standalone" rev="1.4.2" conf="test->default"/> <dependency org="org.mockito" name="mockito-core" rev="2.27.0" conf="test->default"/> <dependency org="org.mockito" name="mockito-junit-jupiter" rev="2.27.0" conf="test->default"/> </dependencies> From de1dfb6107870a1118ab94f01377ce004d8c90d4 Mon Sep 17 00:00:00 2001 From: Aaron Rodriguez <adashrod@gmail.com> Date: Sat, 13 Jun 2026 15:15:23 -0700 Subject: [PATCH 7/7] cleanup --- .github/workflows/ant.yml | 2 +- build.xml | 27 ++++++++------------------- ivy.xml | 10 +++++----- 3 files changed, 14 insertions(+), 25 deletions(-) diff --git a/.github/workflows/ant.yml b/.github/workflows/ant.yml index 2ee12e9..87c3e99 100644 --- a/.github/workflows/ant.yml +++ b/.github/workflows/ant.yml @@ -13,4 +13,4 @@ jobs: with: java-version: 8 - name: run test suite with ant - run: ant -v -noinput -buildfile build.xml test -DbuildEnv=ci + run: ant -noinput -buildfile build.xml test -DbuildEnv=ci diff --git a/build.xml b/build.xml index a3dd21a..8ff5481 100644 --- a/build.xml +++ b/build.xml @@ -23,11 +23,6 @@ <include name="**/*.jar"/> </fileset> </path> - <path id="classpath.ivy-test"> - <fileset dir="${lib.dir}/test"> - <include name="**/*.jar"/> - </fileset> - </path> <available file="${ivy.jar.file}" property="ivy.installed"/> <target name="ivy.load-settings" depends="ivy.install"> @@ -97,19 +92,14 @@ <jar basedir="${javadoc.dir}" file="${dist.dir}/${artifact.name}-${ivy.revision}-javadoc.jar"/> </target> - <target name="test" depends="compile.test" description="Runs the tests via the JUnit Platform ConsoleLauncher (forked). Use test.name (FQ class name) and optionally test.functions (a single method; include parameter types for methods that take them, e.g. myTest(java.io.File)) to run specific tests"> - <!-- map the optional test.name/test.functions to ConsoleLauncher selectors; first-set wins, so order - from most specific to least --> - <condition property="test.select" value="--select-method=${test.name}#${test.functions}"> - <and> - <isset property="test.name"/> - <isset property="test.functions"/> - </and> + <target name="test" depends="compile.test" description="Runs the tests via the JUnit Platform ConsoleLauncher (forked). Runs all tests by default. Narrow with -Dtest.match=SUBSTRING for a partial class-name match, or -Dtest.args='...' for raw ConsoleLauncher selectors (e.g. --select-class=FQCN or --select-method=FQCN#method(paramTypes))"> + <!-- test.match: convenience for partial class-name matching; it supplies the classpath scan for you. + test.args: full raw override (default just scans the test build dir). ConsoleLauncher forbids combining + a classpath scan with explicit select selectors, which is why these are two separate knobs. --> + <condition property="test.args" value="--scan-classpath=${build.test.dir} --include-classname=.*${test.match}.*"> + <isset property="test.match"/> </condition> - <condition property="test.select" value="--select-class=${test.name}"> - <isset property="test.name"/> - </condition> - <property name="test.select" value="--scan-classpath=${build.test.dir}"/> + <property name="test.args" value="--scan-classpath=${build.test.dir}"/> <!-- forked, fully self-contained classpath: the console-standalone fat jar supplies all junit/platform classes, so the other junit jars are excluded to avoid duplicate classes (mockito/jackson/slf4j/ @@ -130,7 +120,7 @@ <exclude name="org.apiguardian-*"/> </fileset> </classpath> - <arg line="${test.select}"/> + <arg line="${test.args}"/> <arg value="--details=tree"/> <arg value="--fail-if-no-tests"/> </java> @@ -141,7 +131,6 @@ </fail> </target> - <target name="publish" depends="ivy.resolve.main, publish.validate, create-jar.sources, create-jar.runtime, create-jar.javadoc" description="publishes artifact to an ivy/maven-compliant repository. Required parameters: publish.resolver, key.password, (and for non-local publishing) publish.user, publish.password"> <copy file="${target.dir}/${artifact.name}.jar" tofile="${dist.dir}/${artifact.name}-${ivy.revision}.jar"/> <ivy:makepom ivyfile="ivy.xml" templatefile="pomTemplate.xml" pomfile="${dist.dir}/${artifact.name}-${ivy.revision}.pom"/> diff --git a/ivy.xml b/ivy.xml index e3ff356..a120411 100644 --- a/ivy.xml +++ b/ivy.xml @@ -24,10 +24,10 @@ <dependency org="org.slf4j" name="slf4j-api" rev="1.7.25"/> <dependency org="com.fasterxml.jackson.core" name="jackson-core" rev="2.7.0"/> <dependency org="com.fasterxml.jackson.core" name="jackson-databind" rev="2.7.0"/> - <dependency org="org.junit.jupiter" name="junit-jupiter-engine" rev="5.4.2" conf="test->default"/> - <dependency org="org.junit.platform" name="junit-platform-engine" rev="1.4.2" conf="test->default"/> - <dependency org="org.junit.platform" name="junit-platform-console-standalone" rev="1.4.2" conf="test->default"/> - <dependency org="org.mockito" name="mockito-core" rev="2.27.0" conf="test->default"/> - <dependency org="org.mockito" name="mockito-junit-jupiter" rev="2.27.0" conf="test->default"/> + <dependency org="org.junit.jupiter" name="junit-jupiter-engine" rev="5.4.2" conf="test->default"/> + <dependency org="org.junit.platform" name="junit-platform-engine" rev="1.4.2" conf="test->default"/> + <dependency org="org.junit.platform" name="junit-platform-console-standalone" rev="1.4.2" conf="test->default"/> + <dependency org="org.mockito" name="mockito-core" rev="2.27.0" conf="test->default"/> + <dependency org="org.mockito" name="mockito-junit-jupiter" rev="2.27.0" conf="test->default"/> </dependencies> </ivy-module>