package com.agent.management.engine;
import com.agent.management.config.SkillProperties;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
/**
* {@link SkillFileSyncer} 单元测试。
*
*
覆盖镜像同步的跳过规则:
*
* - 技能根目录 SKILL.md(合法主入口)正常复制;
* - {@code _versions/} 子目录整树跳过;
* - 非根子目录直接含 SKILL.md 时整树跳过(任意深度,常见于 AI 生成残留);
* - 目标侧残留清除;
* - 普通子目录与文件正常复制。
*
*/
class SkillFileSyncerTest {
@TempDir
Path tempDir;
private Path sourceRoot;
private Path targetRoot;
private SkillFileSyncer syncer;
@BeforeEach
void setUp() throws IOException {
sourceRoot = tempDir.resolve("uploads-skills");
targetRoot = tempDir.resolve("hermes-home-skills");
Files.createDirectories(sourceRoot);
SkillProperties props = new SkillProperties();
props.setBasePath(sourceRoot.toString());
syncer = new SkillFileSyncer(props);
}
/** 创建技能根目录 + SKILL.md */
private void createSkill(String folderName, String skillMdContent) throws IOException {
Path skillDir = sourceRoot.resolve(folderName);
Files.createDirectories(skillDir);
Files.writeString(skillDir.resolve("SKILL.md"), skillMdContent);
}
/** 在技能内创建子目录(可选拓展子文件) */
private Path mkSubDir(String skillFolder, String subRel) throws IOException {
Path p = sourceRoot.resolve(skillFolder).resolve(subRel);
Files.createDirectories(p);
return p;
}
private void write(Path dir, String fileName, String content) throws IOException {
Files.writeString(dir.resolve(fileName), content);
}
// ==================== 测试用例 ====================
@Test
void copiesRootSkillMdAndRegularFiles() throws IOException {
createSkill("foo", "name: foo\n");
write(sourceRoot.resolve("foo"), "notes.md", "hello");
int copied = syncer.syncTo(targetRoot);
assertThat(copied).isEqualTo(1);
assertThat(targetRoot.resolve("foo/SKILL.md")).exists();
assertThat(targetRoot.resolve("foo/notes.md")).exists();
}
@Test
void skipsVersionsSubtree() throws IOException {
createSkill("foo", "name: foo\n");
Path versions = mkSubDir("foo", "_versions/v1");
write(versions, "SKILL.md", "name: foo-old\n");
syncer.syncTo(targetRoot);
assertThat(targetRoot.resolve("foo/_versions")).doesNotExist();
}
@Test
void skipsChildDirContainingSkillMd_generatedSkills() throws IOException {
createSkill("foo", "name: foo\n");
// 模拟 AI 生成的残留:foo/generated_skills/SKILL.md
Path genDir = mkSubDir("foo", "generated_skills");
write(genDir, "SKILL.md", "name: generated\n");
write(genDir, "extra.txt", "keep me?");
syncer.syncTo(targetRoot);
// generated_skills/ 整树不复制
assertThat(targetRoot.resolve("foo/generated_skills")).doesNotExist();
// 但技能自身的 SKILL.md 仍正常
assertThat(targetRoot.resolve("foo/SKILL.md")).exists();
}
@Test
void skipsChildDirContainingSkillMd_tmpSmokeGenerated() throws IOException {
createSkill("foo", "name: foo\n");
Path genDir = mkSubDir("foo", "tmp_smoke_generated");
write(genDir, "SKILL.md", "name: smoke\n");
syncer.syncTo(targetRoot);
assertThat(targetRoot.resolve("foo/tmp_smoke_generated")).doesNotExist();
assertThat(targetRoot.resolve("foo/SKILL.md")).exists();
}
@Test
void skipsNestedDirContainingSkillMd_atDeeperLevel() throws IOException {
// 结构: foo/subdir/nested/SKILL.md,subdir 自身不含 SKILL.md
// 期望:跳过 subdir/nested/(nested 整树),但 subdir/ 自身被创建为目录
createSkill("foo", "name: foo\n");
Path nested = mkSubDir("foo", "subdir/nested");
write(nested, "SKILL.md", "name: nested\n");
write(nested, "aux.txt", "aux");
syncer.syncTo(targetRoot);
// nested/ 整树被跳过
assertThat(targetRoot.resolve("foo/subdir/nested")).doesNotExist();
// subdir/ 本身仍创建(因为 preVisitDirectory 在 subdir 处返回 CONTINUE)
assertThat(targetRoot.resolve("foo/subdir")).exists().isDirectory();
// 技能根 SKILL.md 仍正常
assertThat(targetRoot.resolve("foo/SKILL.md")).exists();
}
@Test
void regularSubdirWithoutSkillMdIsCopied() throws IOException {
createSkill("foo", "name: foo\n");
Path sub = mkSubDir("foo", "docs");
write(sub, "guide.md", "guide");
write(sub, "examples.py", "print('hi')");
syncer.syncTo(targetRoot);
assertThat(targetRoot.resolve("foo/docs/guide.md")).exists();
assertThat(targetRoot.resolve("foo/docs/examples.py")).exists();
}
@Test
void cleansOrphanedTargetDirs() throws IOException {
// 目标侧已存在一个源侧没有的目录,同步后应被清除
Path orphan = targetRoot.resolve("deleted-skill");
Files.createDirectories(orphan);
Files.writeString(orphan.resolve("SKILL.md"), "name: deleted\n");
createSkill("foo", "name: foo\n");
syncer.syncTo(targetRoot);
assertThat(orphan).doesNotExist();
assertThat(targetRoot.resolve("foo/SKILL.md")).exists();
}
@Test
void mirrorsSourceFileDeletionWithinSkill() throws IOException {
// 首次同步:foo 含 SKILL.md + obsolete.md
createSkill("foo", "name: foo\n");
write(sourceRoot.resolve("foo"), "obsolete.md", "will be deleted");
syncer.syncTo(targetRoot);
assertThat(targetRoot.resolve("foo/obsolete.md")).exists();
// 删除源侧 obsolete.md,再次同步
Files.delete(sourceRoot.resolve("foo").resolve("obsolete.md"));
syncer.syncTo(targetRoot);
// 目标侧 obsolete.md 应被镜像清除(先删后拷语义)
assertThat(targetRoot.resolve("foo/obsolete.md")).doesNotExist();
assertThat(targetRoot.resolve("foo/SKILL.md")).exists();
}
@Test
void multipleSkillsAllChecked() throws IOException {
createSkill("foo", "name: foo\n");
createSkill("bar", "name: bar\n");
// foo 下有残留
Path genFoo = mkSubDir("foo", "generated_skills");
write(genFoo, "SKILL.md", "name: gen\n");
// bar 下正常
Path docsBar = mkSubDir("bar", "docs");
write(docsBar, "x.md", "x");
syncer.syncTo(targetRoot);
assertThat(targetRoot.resolve("foo/SKILL.md")).exists();
assertThat(targetRoot.resolve("foo/generated_skills")).doesNotExist();
assertThat(targetRoot.resolve("bar/SKILL.md")).exists();
assertThat(targetRoot.resolve("bar/docs/x.md")).exists();
}
@Test
void returnsZeroWhenSourceMissing() throws IOException {
// sourceRoot 不存在(删掉)
sourceRoot.toFile().delete();
int copied = syncer.syncTo(targetRoot);
assertThat(copied).isZero();
}
}