• Added missing tests and a 100% coverage gate for the new folder scheduler package.

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.
This commit is contained in:
JSLMPR 2026-07-10 01:02:08 +02:00
parent 97cc4ff4e4
commit 702d768e33
7 changed files with 335 additions and 1 deletions

View File

@ -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.

1
input/source/.gitkeep Normal file
View File

@ -0,0 +1 @@

1
output/clips/.gitkeep Normal file
View File

@ -0,0 +1 @@

46
pom.xml
View File

@ -91,6 +91,52 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.12</version>
<executions>
<execution>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<execution>
<id>folder-coverage-check</id>
<phase>verify</phase>
<goals>
<goal>check</goal>
</goals>
<configuration>
<rules>
<rule>
<element>PACKAGE</element>
<includes>
<include>org.example.videoclips.folder</include>
</includes>
<limits>
<limit>
<counter>INSTRUCTION</counter>
<value>COVEREDRATIO</value>
<minimum>1.00</minimum>
</limit>
<limit>
<counter>LINE</counter>
<value>COVEREDRATIO</value>
<minimum>1.00</minimum>
</limit>
<limit>
<counter>BRANCH</counter>
<value>COVEREDRATIO</value>
<minimum>1.00</minimum>
</limit>
</limits>
</rule>
</rules>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>

View File

@ -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<Path> 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<Path> 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<Path> 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);
}
}

View File

@ -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;
}
}

View File

@ -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<Path> 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<Path> candidate = scheduler(tempDir).findNextCandidate();
assertTrue(candidate.isPresent());
assertEquals("valid.mp4", candidate.get().getFileName().toString());
}
@Test
void returnsEmptyWhenInputDirectoryDoesNotExist() {
Optional<Path> candidate = scheduler(tempDir.resolve("missing")).findNextCandidate();
assertTrue(candidate.isEmpty());
}
@Test
void throwsWhenInputDirectoryCannotBeListed() throws Exception {
Path restricted = tempDir.resolve("restricted");
Files.createDirectory(restricted);
Set<PosixFilePermission> 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);
}
}