From 702d768e3353bdabbba84fabc34fcb6eed2b6902 Mon Sep 17 00:00:00 2001 From: JSLMPR Date: Fri, 10 Jul 2026 01:02:08 +0200 Subject: [PATCH] =?UTF-8?q?=E2=80=A2=20Added=20missing=20tests=20and=20a?= =?UTF-8?q?=20100%=20coverage=20gate=20for=20the=20new=20folder=20schedule?= =?UTF-8?q?r=20package.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changes: - Added JaCoCo to pom.xml with a 100% instruction, line, and branch coverage rule scoped to org.example.videoclips.folder. - Added src/test/java/org/example/videoclips/folder/FolderSchedulerDirectoryInitializerTest.java. - Added src/test/java/org/example/videoclips/folder/FolderVideoScanSchedulerTest.java. - Adjusted the scanner comparator in src/main/java/org/example/videoclips/folder/FolderVideoScanScheduler.java to keep deterministic ordering without an uncovered comparator branch. Verification: mvn -q verify passed. The generated JaCoCo report shows org.example.videoclips.folder at 100% instruction, line, and branch coverage. Scope note: this enforces 100% coverage for the new scheduler package, not the whole existing service. Project-wide 100% would be a much larger pass across pre-existing classes. The next scheduler milestone is repeat-prevention by moving selected inputs to the working folder before processing. --- docs/input-folder-scheduler-plan.md | 2 +- input/source/.gitkeep | 1 + output/clips/.gitkeep | 1 + pom.xml | 46 +++++++ .../folder/FolderVideoScanScheduler.java | 106 ++++++++++++++++ ...lderSchedulerDirectoryInitializerTest.java | 67 +++++++++++ .../folder/FolderVideoScanSchedulerTest.java | 113 ++++++++++++++++++ 7 files changed, 335 insertions(+), 1 deletion(-) create mode 100644 input/source/.gitkeep create mode 100644 output/clips/.gitkeep create mode 100644 src/main/java/org/example/videoclips/folder/FolderVideoScanScheduler.java create mode 100644 src/test/java/org/example/videoclips/folder/FolderSchedulerDirectoryInitializerTest.java create mode 100644 src/test/java/org/example/videoclips/folder/FolderVideoScanSchedulerTest.java diff --git a/docs/input-folder-scheduler-plan.md b/docs/input-folder-scheduler-plan.md index c2df9a5..a2c8c7c 100644 --- a/docs/input-folder-scheduler-plan.md +++ b/docs/input-folder-scheduler-plan.md @@ -21,7 +21,7 @@ Note: the local machine's filesystem root is read-only in this workspace, so abs 1. [x] Create repository-local placeholder directories `input/source` and `output/clips`. 2. [x] Add folder scheduler configuration properties. 3. [x] Create startup directory initialization for input, output, working, processed, and rejected folders. -4. [ ] Add deterministic scanner that picks one candidate video at a time. +4. [x] Add deterministic scanner that picks one candidate video at a time. 5. [ ] Add repeat-prevention by moving selected inputs to a working folder before processing. 6. [ ] Add valid-video detection using extension filtering and `ffprobe`. 7. [ ] Add invalid-file handling that logs the reason and moves files to rejected storage. diff --git a/input/source/.gitkeep b/input/source/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/input/source/.gitkeep @@ -0,0 +1 @@ + diff --git a/output/clips/.gitkeep b/output/clips/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/output/clips/.gitkeep @@ -0,0 +1 @@ + diff --git a/pom.xml b/pom.xml index 15a49e8..2b8e5a5 100644 --- a/pom.xml +++ b/pom.xml @@ -91,6 +91,52 @@ org.springframework.boot spring-boot-maven-plugin + + org.jacoco + jacoco-maven-plugin + 0.8.12 + + + + prepare-agent + + + + folder-coverage-check + verify + + check + + + + + PACKAGE + + org.example.videoclips.folder + + + + INSTRUCTION + COVEREDRATIO + 1.00 + + + LINE + COVEREDRATIO + 1.00 + + + BRANCH + COVEREDRATIO + 1.00 + + + + + + + + diff --git a/src/main/java/org/example/videoclips/folder/FolderVideoScanScheduler.java b/src/main/java/org/example/videoclips/folder/FolderVideoScanScheduler.java new file mode 100644 index 0000000..0178e07 --- /dev/null +++ b/src/main/java/org/example/videoclips/folder/FolderVideoScanScheduler.java @@ -0,0 +1,106 @@ +package org.example.videoclips.folder; + +import org.example.videoclips.config.VideoClippingProperties; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.math.BigInteger; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Comparator; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.stream.Stream; + +@Component +@ConditionalOnProperty(name = "video-clipping.folder-scheduler.enabled", havingValue = "true") +public class FolderVideoScanScheduler { + + private static final Logger log = LoggerFactory.getLogger(FolderVideoScanScheduler.class); + + private final VideoClippingProperties.FolderScheduler properties; + private final AtomicBoolean scanning = new AtomicBoolean(false); + + public FolderVideoScanScheduler(VideoClippingProperties videoClippingProperties) { + this.properties = videoClippingProperties.getFolderScheduler(); + } + + @Scheduled(fixedDelayString = "${video-clipping.folder-scheduler.poll-interval-ms:5000}") + public void scan() { + if (!scanning.compareAndSet(false, true)) { + log.debug("Skipping folder video scan because a previous scan is still active"); + return; + } + + try { + findNextCandidate().ifPresentOrElse( + candidate -> log.info("Selected folder video candidate for processing: {}", candidate.getFileName()), + () -> log.debug("No folder video candidate found in {}", properties.getInputDirectory()) + ); + } finally { + scanning.set(false); + } + } + + Optional findNextCandidate() { + Path inputDirectory = Path.of(properties.getInputDirectory()); + if (!Files.isDirectory(inputDirectory)) { + log.warn("Folder scheduler input directory does not exist or is not a directory: {}", inputDirectory); + return Optional.empty(); + } + + try (Stream files = Files.list(inputDirectory)) { + return files + .filter(this::isCandidateFile) + .sorted(candidateComparator()) + .findFirst(); + } catch (IOException ex) { + throw new IllegalStateException("Unable to scan folder scheduler input directory: " + inputDirectory, ex); + } + } + + private boolean isCandidateFile(Path path) { + if (!Files.isRegularFile(path)) { + return false; + } + String fileName = path.getFileName().toString(); + return !fileName.startsWith(".") + && !fileName.endsWith(".tmp") + && !fileName.endsWith(".part") + && !fileName.endsWith(".download"); + } + + private Comparator candidateComparator() { + return Comparator + .comparing(path -> naturalSortKey(path.getFileName().toString()) + "\0" + path.getFileName()); + } + + private String naturalSortKey(String value) { + StringBuilder key = new StringBuilder(); + StringBuilder number = new StringBuilder(); + for (int i = 0; i < value.length(); i++) { + char current = value.charAt(i); + if (Character.isDigit(current)) { + number.append(current); + } else { + appendNumber(key, number); + key.append(Character.toLowerCase(current)); + } + } + appendNumber(key, number); + return key.toString(); + } + + private void appendNumber(StringBuilder key, StringBuilder number) { + if (number.isEmpty()) { + return; + } + key.append(String.format("%020d", number.length())); + key.append(new BigInteger(number.toString())); + number.setLength(0); + } +} diff --git a/src/test/java/org/example/videoclips/folder/FolderSchedulerDirectoryInitializerTest.java b/src/test/java/org/example/videoclips/folder/FolderSchedulerDirectoryInitializerTest.java new file mode 100644 index 0000000..7d70c24 --- /dev/null +++ b/src/test/java/org/example/videoclips/folder/FolderSchedulerDirectoryInitializerTest.java @@ -0,0 +1,67 @@ +package org.example.videoclips.folder; + +import org.example.videoclips.config.VideoClippingProperties; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class FolderSchedulerDirectoryInitializerTest { + + @TempDir + Path tempDir; + + @Test + void createsAllConfiguredDirectoriesOnStartup() { + VideoClippingProperties properties = properties( + tempDir.resolve("source"), + tempDir.resolve("clips"), + tempDir.resolve("working"), + tempDir.resolve("processed"), + tempDir.resolve("rejected") + ); + + new FolderSchedulerDirectoryInitializer(properties).run(null); + + assertTrue(Files.isDirectory(tempDir.resolve("source"))); + assertTrue(Files.isDirectory(tempDir.resolve("clips"))); + assertTrue(Files.isDirectory(tempDir.resolve("working"))); + assertTrue(Files.isDirectory(tempDir.resolve("processed"))); + assertTrue(Files.isDirectory(tempDir.resolve("rejected"))); + } + + @Test + void failsStartupWhenConfiguredDirectoryCannotBeCreated() throws Exception { + Path existingFile = tempDir.resolve("not-a-directory"); + Files.writeString(existingFile, "file"); + VideoClippingProperties properties = properties( + existingFile, + tempDir.resolve("clips"), + tempDir.resolve("working"), + tempDir.resolve("processed"), + tempDir.resolve("rejected") + ); + + assertThrows(IllegalStateException.class, () -> new FolderSchedulerDirectoryInitializer(properties).run(null)); + } + + private VideoClippingProperties properties( + Path inputDirectory, + Path outputDirectory, + Path workingDirectory, + Path processedDirectory, + Path rejectedDirectory + ) { + VideoClippingProperties properties = new VideoClippingProperties(); + properties.getFolderScheduler().setInputDirectory(inputDirectory.toString()); + properties.getFolderScheduler().setOutputDirectory(outputDirectory.toString()); + properties.getFolderScheduler().setWorkingDirectory(workingDirectory.toString()); + properties.getFolderScheduler().setProcessedDirectory(processedDirectory.toString()); + properties.getFolderScheduler().setRejectedDirectory(rejectedDirectory.toString()); + return properties; + } +} diff --git a/src/test/java/org/example/videoclips/folder/FolderVideoScanSchedulerTest.java b/src/test/java/org/example/videoclips/folder/FolderVideoScanSchedulerTest.java new file mode 100644 index 0000000..b2e6765 --- /dev/null +++ b/src/test/java/org/example/videoclips/folder/FolderVideoScanSchedulerTest.java @@ -0,0 +1,113 @@ +package org.example.videoclips.folder; + +import org.example.videoclips.config.VideoClippingProperties; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.lang.reflect.Field; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermission; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class FolderVideoScanSchedulerTest { + + @TempDir + Path tempDir; + + @Test + void selectsOneCandidateUsingNaturalFilenameOrdering() throws Exception { + Files.writeString(tempDir.resolve("10.mp4"), "video"); + Files.writeString(tempDir.resolve("2.mp4"), "video"); + Files.writeString(tempDir.resolve("1.mp4"), "video"); + + Optional candidate = scheduler(tempDir).findNextCandidate(); + + assertTrue(candidate.isPresent()); + assertEquals("1.mp4", candidate.get().getFileName().toString()); + } + + @Test + void ignoresDirectoriesHiddenFilesAndTemporaryFiles() throws Exception { + Files.createDirectory(tempDir.resolve("directory.mp4")); + Files.writeString(tempDir.resolve(".hidden.mp4"), "video"); + Files.writeString(tempDir.resolve("upload.mp4.tmp"), "video"); + Files.writeString(tempDir.resolve("upload.mp4.part"), "video"); + Files.writeString(tempDir.resolve("upload.mp4.download"), "video"); + Files.writeString(tempDir.resolve("valid.mp4"), "video"); + + Optional candidate = scheduler(tempDir).findNextCandidate(); + + assertTrue(candidate.isPresent()); + assertEquals("valid.mp4", candidate.get().getFileName().toString()); + } + + @Test + void returnsEmptyWhenInputDirectoryDoesNotExist() { + Optional candidate = scheduler(tempDir.resolve("missing")).findNextCandidate(); + + assertTrue(candidate.isEmpty()); + } + + @Test + void throwsWhenInputDirectoryCannotBeListed() throws Exception { + Path restricted = tempDir.resolve("restricted"); + Files.createDirectory(restricted); + Set originalPermissions = Files.getPosixFilePermissions(restricted); + try { + Files.setPosixFilePermissions(restricted, Set.of()); + + assertThrows(IllegalStateException.class, () -> scheduler(restricted).findNextCandidate()); + } finally { + Files.setPosixFilePermissions(restricted, originalPermissions); + } + } + + @Test + void scanDoesNothingWhenAnotherScanIsActive() throws Exception { + FolderVideoScanScheduler scheduler = scheduler(tempDir); + Field scanningField = FolderVideoScanScheduler.class.getDeclaredField("scanning"); + scanningField.setAccessible(true); + ((AtomicBoolean) scanningField.get(scheduler)).set(true); + + scheduler.scan(); + + assertTrue(((AtomicBoolean) scanningField.get(scheduler)).get()); + } + + @Test + void scanReleasesGuardAfterSelectingCandidate() throws Exception { + Files.writeString(tempDir.resolve("1.mp4"), "video"); + FolderVideoScanScheduler scheduler = scheduler(tempDir); + Field scanningField = FolderVideoScanScheduler.class.getDeclaredField("scanning"); + scanningField.setAccessible(true); + + scheduler.scan(); + + assertFalse(((AtomicBoolean) scanningField.get(scheduler)).get()); + } + + @Test + void scanReleasesGuardWhenNoCandidateExists() throws Exception { + FolderVideoScanScheduler scheduler = scheduler(tempDir); + Field scanningField = FolderVideoScanScheduler.class.getDeclaredField("scanning"); + scanningField.setAccessible(true); + + scheduler.scan(); + + assertFalse(((AtomicBoolean) scanningField.get(scheduler)).get()); + } + + private FolderVideoScanScheduler scheduler(Path inputDirectory) { + VideoClippingProperties properties = new VideoClippingProperties(); + properties.getFolderScheduler().setInputDirectory(inputDirectory.toString()); + return new FolderVideoScanScheduler(properties); + } +}