Template
Improve music processing robustness and workflow UX
Unify safe file-move behavior and richer progress semantics across backend tasks, while upgrading traditional-to-simplified conversion and refining the frontend multi-step panels for clearer execution feedback.
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
package com.music.service;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.Assumptions;
|
||||
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
||||
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
class ConvertServiceInternalTest {
|
||||
|
||||
@Test
|
||||
void classifyConvertErrorHandlesInterrupted() throws Exception {
|
||||
ConvertService service = new ConvertService(mock(SimpMessagingTemplate.class), new ProgressStore());
|
||||
|
||||
Method m = ConvertService.class.getDeclaredMethod("classifyConvertError", Exception.class);
|
||||
m.setAccessible(true);
|
||||
|
||||
String reason = (String) m.invoke(service, new InterruptedException("interrupted"));
|
||||
assertEquals("任务线程被中断", reason);
|
||||
}
|
||||
|
||||
@Test
|
||||
void classifyConvertErrorHandlesExitCode() throws Exception {
|
||||
ConvertService service = new ConvertService(mock(SimpMessagingTemplate.class), new ProgressStore());
|
||||
|
||||
Method m = ConvertService.class.getDeclaredMethod("classifyConvertError", Exception.class);
|
||||
m.setAccessible(true);
|
||||
|
||||
String reason = (String) m.invoke(service, new RuntimeException("ffmpeg 退出码: 1"));
|
||||
assertTrue(reason.contains("ffmpeg 退出码"));
|
||||
assertTrue(reason.contains("源文件损坏"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void classifyConvertErrorKeepsTimeoutMessage() throws Exception {
|
||||
ConvertService service = new ConvertService(mock(SimpMessagingTemplate.class), new ProgressStore());
|
||||
|
||||
Method m = ConvertService.class.getDeclaredMethod("classifyConvertError", Exception.class);
|
||||
m.setAccessible(true);
|
||||
|
||||
String reason = (String) m.invoke(service, new RuntimeException("ffmpeg 转码超时(600s)"));
|
||||
assertEquals("ffmpeg 转码超时(600s)", reason);
|
||||
}
|
||||
|
||||
@Test
|
||||
void checkFfmpegAvailableReturnsErrorWhenCommandMissing() throws Exception {
|
||||
ConvertService service = new ConvertService(mock(SimpMessagingTemplate.class), new ProgressStore());
|
||||
Method m = ConvertService.class.getDeclaredMethod("checkFfmpegAvailable");
|
||||
m.setAccessible(true);
|
||||
|
||||
String previous = System.getProperty("mangtool.ffmpeg.bin");
|
||||
try {
|
||||
System.setProperty("mangtool.ffmpeg.bin", "ffmpeg_not_exists_for_test");
|
||||
String error = (String) m.invoke(service);
|
||||
assertTrue(error != null && error.contains("ffmpeg 不可用"));
|
||||
} finally {
|
||||
if (previous == null) {
|
||||
System.clearProperty("mangtool.ffmpeg.bin");
|
||||
} else {
|
||||
System.setProperty("mangtool.ffmpeg.bin", previous);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void runFfmpegReturnsExitCodeErrorForInvalidInput() throws Exception {
|
||||
ConvertService service = new ConvertService(mock(SimpMessagingTemplate.class), new ProgressStore());
|
||||
|
||||
Method check = ConvertService.class.getDeclaredMethod("checkFfmpegAvailable");
|
||||
check.setAccessible(true);
|
||||
String checkError = (String) check.invoke(service);
|
||||
Assumptions.assumeTrue(checkError == null, "ffmpeg 不可用,跳过损坏文件测试");
|
||||
|
||||
Path tempDir = Files.createTempDirectory("convert-invalid-");
|
||||
Path invalidInput = tempDir.resolve("bad.wav");
|
||||
Path output = tempDir.resolve("out.flac");
|
||||
Files.write(invalidInput, "not-audio".getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
Method run = ConvertService.class.getDeclaredMethod("runFfmpeg", Path.class, Path.class);
|
||||
run.setAccessible(true);
|
||||
|
||||
InvocationTargetException ex = assertThrows(InvocationTargetException.class,
|
||||
() -> run.invoke(service, invalidInput, output));
|
||||
Throwable target = ex.getTargetException();
|
||||
assertTrue(target.getMessage().contains("ffmpeg 退出码"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package com.music.service;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
class DedupServiceInternalTest {
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void filterProcessableCandidatesSkipsProcessedAndMissingFiles() throws Exception {
|
||||
DedupService service = new DedupService(mock(SimpMessagingTemplate.class), new ProgressStore());
|
||||
|
||||
Path tempDir = Files.createTempDirectory("dedup-filter-");
|
||||
Path fileA = tempDir.resolve("a.mp3");
|
||||
Path fileB = tempDir.resolve("b.mp3");
|
||||
Path missing = tempDir.resolve("missing.mp3");
|
||||
|
||||
Files.write(fileA, "a".getBytes(StandardCharsets.UTF_8));
|
||||
Files.write(fileB, "b".getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
Set<Path> processed = new HashSet<>();
|
||||
processed.add(fileB.toAbsolutePath().normalize());
|
||||
|
||||
Method m = DedupService.class.getDeclaredMethod("filterProcessableCandidates", List.class, Set.class);
|
||||
m.setAccessible(true);
|
||||
|
||||
List<Path> group = new ArrayList<>();
|
||||
group.add(fileA);
|
||||
group.add(fileB);
|
||||
group.add(missing);
|
||||
|
||||
List<Path> result = (List<Path>) m.invoke(service, group, processed);
|
||||
|
||||
assertEquals(1, result.size());
|
||||
assertEquals(fileA, result.get(0));
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void handleDuplicatesAccumulatesMovedCountAndMarksProcessed() throws Exception {
|
||||
DedupService service = new DedupService(mock(SimpMessagingTemplate.class), new ProgressStore());
|
||||
|
||||
Path tempDir = Files.createTempDirectory("dedup-handle-");
|
||||
Path trashDir = tempDir.resolve("trash");
|
||||
Path keep = tempDir.resolve("keep.mp3");
|
||||
Path dup = tempDir.resolve("dup.mp3");
|
||||
|
||||
Files.createDirectories(trashDir);
|
||||
Files.write(keep, "keep".getBytes(StandardCharsets.UTF_8));
|
||||
Files.write(dup, "dup".getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
Method m = DedupService.class.getDeclaredMethod(
|
||||
"handleDuplicates",
|
||||
List.class,
|
||||
Path.class,
|
||||
Path.class,
|
||||
String.class,
|
||||
String.class,
|
||||
int.class,
|
||||
AtomicInteger.class,
|
||||
AtomicInteger.class,
|
||||
AtomicInteger.class,
|
||||
AtomicInteger.class,
|
||||
Set.class
|
||||
);
|
||||
m.setAccessible(true);
|
||||
|
||||
List<Path> duplicates = new ArrayList<>();
|
||||
duplicates.add(dup);
|
||||
|
||||
AtomicInteger scanned = new AtomicInteger(10);
|
||||
AtomicInteger groups = new AtomicInteger(2);
|
||||
AtomicInteger moved = new AtomicInteger(5);
|
||||
AtomicInteger failed = new AtomicInteger(0);
|
||||
Set<Path> processed = new HashSet<>();
|
||||
|
||||
m.invoke(service, duplicates, keep, trashDir, "copy", "task-1", 20,
|
||||
scanned, groups, moved, failed, processed);
|
||||
|
||||
assertEquals(6, moved.get());
|
||||
assertEquals(0, failed.get());
|
||||
assertTrue(processed.contains(dup.toAbsolutePath().normalize()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.music.service;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
class LibraryMergeServiceInternalTest {
|
||||
|
||||
@Test
|
||||
void isExcludedSystemDirectoryRecognizesManualFixAndReports() throws Exception {
|
||||
LibraryMergeService service = new LibraryMergeService(
|
||||
mock(SimpMessagingTemplate.class),
|
||||
new ProgressStore()
|
||||
);
|
||||
|
||||
Path root = Files.createTempDirectory("merge-root-");
|
||||
Path manualDir = root.resolve("_Manual_Fix_Required_").resolve("Missing_Title");
|
||||
Path reportsDir = root.resolve("_Reports");
|
||||
Path normalDir = root.resolve("A").resolve("Artist");
|
||||
|
||||
Method method = LibraryMergeService.class.getDeclaredMethod(
|
||||
"isExcludedSystemDirectory", Path.class, Path.class);
|
||||
method.setAccessible(true);
|
||||
|
||||
assertTrue((Boolean) method.invoke(service, root, manualDir));
|
||||
assertTrue((Boolean) method.invoke(service, root, reportsDir));
|
||||
assertFalse((Boolean) method.invoke(service, root, normalDir));
|
||||
assertFalse((Boolean) method.invoke(service, root, root));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.music.service;
|
||||
|
||||
import com.music.dto.ProgressMessage;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
class ProgressMessageMappingTest {
|
||||
|
||||
@Test
|
||||
void dedupProgressContainsDedicatedFields() throws Exception {
|
||||
ProgressStore store = new ProgressStore();
|
||||
DedupService service = new DedupService(mock(SimpMessagingTemplate.class), store);
|
||||
|
||||
Method m = DedupService.class.getDeclaredMethod(
|
||||
"sendProgress", String.class, int.class, int.class, int.class, int.class, String.class, boolean.class);
|
||||
m.setAccessible(true);
|
||||
m.invoke(service, "t-dedup", 100, 50, 3, 8, "msg", false);
|
||||
|
||||
ProgressMessage pm = store.get("t-dedup");
|
||||
assertEquals(3, pm.getDuplicateGroups().intValue());
|
||||
assertEquals(8, pm.getMovedFiles().intValue());
|
||||
}
|
||||
|
||||
@Test
|
||||
void organizeProgressContainsDedicatedFields() throws Exception {
|
||||
ProgressStore store = new ProgressStore();
|
||||
OrganizeService service = new OrganizeService(mock(SimpMessagingTemplate.class), store);
|
||||
|
||||
Method m = OrganizeService.class.getDeclaredMethod(
|
||||
"sendProgress", String.class, int.class, int.class, int.class, int.class,
|
||||
String.class, String.class, boolean.class);
|
||||
m.setAccessible(true);
|
||||
m.invoke(service, "t-organize", 100, 80, 70, 10, "f.mp3", "msg", false);
|
||||
|
||||
ProgressMessage pm = store.get("t-organize");
|
||||
assertEquals(70, pm.getOrganizedFiles().intValue());
|
||||
assertEquals(10, pm.getManualFixFiles().intValue());
|
||||
}
|
||||
|
||||
@Test
|
||||
void zhconvertProgressContainsDedicatedFields() throws Exception {
|
||||
ProgressStore store = new ProgressStore();
|
||||
ZhConvertService service = new ZhConvertService(
|
||||
mock(SimpMessagingTemplate.class), store, new TraditionalFilterService());
|
||||
|
||||
Method m = ZhConvertService.class.getDeclaredMethod(
|
||||
"sendProgress", String.class, int.class, int.class, int.class, int.class,
|
||||
String.class, String.class, boolean.class);
|
||||
m.setAccessible(true);
|
||||
m.invoke(service, "t-zh", 10, 5, 12, 1, "f.mp3", "msg", false);
|
||||
|
||||
ProgressMessage pm = store.get("t-zh");
|
||||
assertEquals(12, pm.getTraditionalEntries().intValue());
|
||||
assertEquals(1, pm.getFailedFiles().intValue());
|
||||
}
|
||||
|
||||
@Test
|
||||
void mergeProgressContainsDedicatedFields() throws Exception {
|
||||
ProgressStore store = new ProgressStore();
|
||||
LibraryMergeService service = new LibraryMergeService(mock(SimpMessagingTemplate.class), store);
|
||||
|
||||
Method m = LibraryMergeService.class.getDeclaredMethod(
|
||||
"sendProgress", String.class, int.class, int.class, int.class, int.class,
|
||||
int.class, int.class, String.class, String.class, boolean.class);
|
||||
m.setAccessible(true);
|
||||
m.invoke(service, "t-merge", 100, 30, 4, 28, 3, 2, "f.mp3", "msg", false);
|
||||
|
||||
ProgressMessage pm = store.get("t-merge");
|
||||
assertEquals(4, pm.getAlbumsMerged().intValue());
|
||||
assertEquals(28, pm.getTracksMerged().intValue());
|
||||
assertEquals(3, pm.getUpgradedFiles().intValue());
|
||||
assertEquals(2, pm.getSkippedFiles().intValue());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.music.service;
|
||||
|
||||
import com.music.dto.ProgressMessage;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.lang.reflect.Constructor;
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
|
||||
class ProgressStoreTest {
|
||||
|
||||
@Test
|
||||
void putAndGetReturnsLatestProgress() {
|
||||
ProgressStore store = new ProgressStore();
|
||||
ProgressMessage pm = new ProgressMessage();
|
||||
pm.setTaskId("task-1");
|
||||
pm.setMessage("running");
|
||||
|
||||
store.put(pm);
|
||||
|
||||
ProgressMessage fetched = store.get("task-1");
|
||||
assertEquals("running", fetched.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void getRemovesExpiredProgress() throws Exception {
|
||||
ProgressStore store = new ProgressStore();
|
||||
|
||||
ProgressMessage pm = new ProgressMessage();
|
||||
pm.setTaskId("expired-task");
|
||||
pm.setMessage("old");
|
||||
|
||||
Field latestField = ProgressStore.class.getDeclaredField("latest");
|
||||
latestField.setAccessible(true);
|
||||
Map<String, Object> latest = (Map<String, Object>) latestField.get(store);
|
||||
|
||||
Class<?> storedClass = Class.forName("com.music.service.ProgressStore$StoredProgress");
|
||||
Constructor<?> constructor = storedClass.getDeclaredConstructor(ProgressMessage.class, long.class);
|
||||
constructor.setAccessible(true);
|
||||
|
||||
long oldTimestamp = System.currentTimeMillis() - (31L * 60L * 1000L);
|
||||
Object expired = constructor.newInstance(pm, oldTimestamp);
|
||||
latest.put("expired-task", expired);
|
||||
|
||||
ProgressMessage fetched = store.get("expired-task");
|
||||
assertNull(fetched);
|
||||
assertFalse(latest.containsKey("expired-task"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.music.service;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class TraditionalFilterServiceTest {
|
||||
|
||||
@Test
|
||||
void toSimplifiedConvertsCommonMusicMetadata() {
|
||||
TraditionalFilterService service = new TraditionalFilterService();
|
||||
|
||||
String converted = service.toSimplified("張學友 經典專輯 精選 愛與夢");
|
||||
|
||||
assertEquals("张学友 经典专辑 精选 爱与梦", converted);
|
||||
}
|
||||
|
||||
@Test
|
||||
void analyzeReturnsExpectedTraditionalRatio() {
|
||||
TraditionalFilterService service = new TraditionalFilterService();
|
||||
|
||||
TraditionalFilterService.ZhStats stats = service.analyze("張學友abc");
|
||||
|
||||
assertEquals(3, stats.getChineseCount());
|
||||
assertEquals(2, stats.getTraditionalCount());
|
||||
assertTrue(stats.getRatio() > 0.6 && stats.getRatio() < 0.7);
|
||||
}
|
||||
|
||||
@Test
|
||||
void analyzeDetectsTraditionalCharsCoveredByOpencc() {
|
||||
TraditionalFilterService service = new TraditionalFilterService();
|
||||
|
||||
TraditionalFilterService.ZhStats stats = service.analyze("愛與夢");
|
||||
|
||||
assertEquals(3, stats.getChineseCount());
|
||||
assertEquals(3, stats.getTraditionalCount());
|
||||
assertEquals(1.0, stats.getRatio());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.music.service;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
class ZhConvertServiceInternalTest {
|
||||
|
||||
@Test
|
||||
void resolveTargetFileForExecuteKeepsSourceWhenPreview() throws Exception {
|
||||
ZhConvertService service = new ZhConvertService(
|
||||
mock(SimpMessagingTemplate.class),
|
||||
new ProgressStore(),
|
||||
new TraditionalFilterService()
|
||||
);
|
||||
|
||||
Path scanDir = Files.createTempDirectory("zh-scan-");
|
||||
Path srcFile = scanDir.resolve("a/song.mp3");
|
||||
Files.createDirectories(srcFile.getParent());
|
||||
Files.write(srcFile, "abc".getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
Method m = ZhConvertService.class.getDeclaredMethod(
|
||||
"resolveTargetFileForExecute", Path.class, Path.class, Path.class, boolean.class);
|
||||
m.setAccessible(true);
|
||||
|
||||
Path result = (Path) m.invoke(service, srcFile, scanDir, null, false);
|
||||
assertEquals(srcFile, result);
|
||||
assertTrue(Files.exists(srcFile));
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveTargetFileForExecuteMovesFileToOutputWhenExecute() throws Exception {
|
||||
ZhConvertService service = new ZhConvertService(
|
||||
mock(SimpMessagingTemplate.class),
|
||||
new ProgressStore(),
|
||||
new TraditionalFilterService()
|
||||
);
|
||||
|
||||
Path scanDir = Files.createTempDirectory("zh-scan-exec-");
|
||||
Path outDir = Files.createTempDirectory("zh-out-exec-");
|
||||
Path srcFile = scanDir.resolve("artist/song.mp3");
|
||||
Files.createDirectories(srcFile.getParent());
|
||||
byte[] content = "xyz".getBytes(StandardCharsets.UTF_8);
|
||||
Files.write(srcFile, content);
|
||||
|
||||
Method m = ZhConvertService.class.getDeclaredMethod(
|
||||
"resolveTargetFileForExecute", Path.class, Path.class, Path.class, boolean.class);
|
||||
m.setAccessible(true);
|
||||
|
||||
Path result = (Path) m.invoke(service, srcFile, scanDir, outDir, true);
|
||||
|
||||
assertFalse(Files.exists(srcFile));
|
||||
assertTrue(Files.exists(result));
|
||||
assertEquals(outDir.resolve("artist/song.mp3"), result);
|
||||
assertEquals("xyz", new String(Files.readAllBytes(result), StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user