Files
ttstd-infinite-canvas/backend/internal/skills/skill_packages_test.go

283 lines
9.6 KiB
Go

package skills
import (
"archive/zip"
"bytes"
"strings"
"testing"
"yingce/backend/internal/kernel"
"yingce/backend/internal/model"
"yingce/backend/internal/repository"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
type builtinSkillDefinition struct {
SkillID string
SkillName string
Description string
Instruction string
}
func TestArchiveFromMarkdownInfersMetadata(t *testing.T) {
archive, err := archiveFromMarkdown([]byte("# 小说转分镜\n\n把小说段落拆成可拍摄的镜头。\n"), "", "")
if err != nil {
t.Fatal(err)
}
if archive.Metadata.Name != "小说转分镜" || archive.Metadata.Description != "把小说段落拆成可拍摄的镜头。" {
t.Fatalf("metadata = %#v", archive.Metadata)
}
if string(archive.Files["SKILL.md"]) == "" || archive.ContentHash == "" {
t.Fatal("archive did not preserve the entry file or compute a hash")
}
}
func TestBuiltinSkillPackageBoundsMetadata(t *testing.T) {
skill := builtinSkillDefinition{
SkillID: "test-builtin-skill",
SkillName: "测试技能",
Description: strings.Repeat("描述内容。", 140),
Instruction: "# 测试技能\n\n保留完整正文。\n",
}
archive, err := archiveFromMarkdown([]byte(skill.Instruction), skill.SkillName, skill.Description)
if err != nil {
t.Fatal(err)
}
if got := len([]rune(archive.Metadata.Description)); got > 500 {
t.Fatalf("builtin skill metadata description length = %d, want <= 500", got)
}
if string(archive.Files["SKILL.md"]) != skill.Instruction {
t.Fatal("builtin skill package must preserve the complete instruction")
}
}
func TestEnsureSkillPackagesBoundsLegacyFallbackMetadata(t *testing.T) {
for _, source := range []int{3, skillSourceUser} {
db, err := gorm.Open(sqlite.Open("file:"+kernel.NewID()+"?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
sqlDB, err := db.DB()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = sqlDB.Close() })
if err := db.AutoMigrate(&model.Skill{}, &model.SkillVersion{}, &model.SkillFile{}); err != nil {
t.Fatal(err)
}
svc := New(repository.New(db), t.TempDir(), nil)
skill := model.Skill{ID: kernel.NewID(), Name: strings.Repeat("名", 81), Description: strings.Repeat("文", 503), Instruction: "## 旧技能\n", Status: skillStatusEnabled, Source: source}
if err := db.Create(&skill).Error; err != nil {
t.Fatal(err)
}
for range 2 {
if err := svc.EnsureSkillPackages(); err != nil {
t.Fatal(err)
}
}
assertSkillVersionCount(t, db, skill.ID, 1)
var saved model.Skill
if err := db.First(&saved, "id = ?", skill.ID).Error; err != nil {
t.Fatal(err)
}
if saved.Name != skill.Name || saved.Description != skill.Description || saved.Instruction != skill.Instruction {
t.Fatal("migration changed original skill fields")
}
version, err := svc.repo.SkillVersion(saved.CurrentVersionID)
if err != nil {
t.Fatal(err)
}
body, err := svc.readSkillArchiveEntry(version, "SKILL.md")
if err != nil || string(body) != skill.Instruction {
t.Fatalf("original instruction not preserved: %v", err)
}
}
if _, err := archiveFromMarkdown([]byte("## 旧技能\n"), strings.Repeat("名", 81), strings.Repeat("文", 503)); err == nil {
t.Fatal("non-migration archive input must still reject oversized fallback metadata")
}
}
func TestArchiveFromMarkdownTruncatesInferredDescriptionWithinLimit(t *testing.T) {
longDescription := bytes.Repeat([]byte("描述内容。"), 140)
data := append([]byte("# 风格库四级匹配序\n\n"), longDescription...)
archive, err := archiveFromMarkdown(data, "", "")
if err != nil {
t.Fatal(err)
}
if got := len([]rune(archive.Metadata.Description)); got > 500 {
t.Fatalf("inferred description length = %d, want <= 500", got)
}
if archive.Metadata.Description == "" {
t.Fatal("inferred description is empty")
}
if !bytes.Equal(archive.Files["SKILL.md"], data) {
t.Fatal("metadata truncation changed the original instruction")
}
}
func TestSkillMetadataLengthBoundaries(t *testing.T) {
for _, length := range []int{499, 500, 501, 553} {
value := strings.Repeat("文", length)
markdown := []byte("# 技能\n\n" + value)
archive, err := archiveFromMarkdown(markdown, "", "")
if err != nil {
t.Fatalf("description length %d: %v", length, err)
}
want := value
if length > 500 {
want = strings.Repeat("文", 497) + "..."
}
if archive.Metadata.Description != want {
t.Fatalf("description length %d: unexpected truncation", length)
}
}
metadata := parseSkillPackageMetadata([]byte("---\nname: " + strings.Repeat("名", 81) + "\ndescription: " + strings.Repeat("文", 501) + "\nmetadata:\n version: " + strings.Repeat("版", 65) + "\n---\n"))
if len([]rune(metadata.Name)) != 80 || len([]rune(metadata.Description)) != 500 || len([]rune(metadata.Version)) != 64 {
t.Fatalf("metadata exceeds bounds: %#v", metadata)
}
// Supplied metadata still goes through strict validation; no widening of
// the archive contract is needed to fix inferred display metadata.
if _, err := finalizeSkillArchive(map[string][]byte{"SKILL.md": []byte("# 技能")}, skillPackageMetadata{Name: "技能", Description: strings.Repeat("文", 501)}); err == nil {
t.Fatal("expected overlong archive metadata to be rejected")
}
}
func TestArchiveFromZipNormalizesWrapperAndNestedFiles(t *testing.T) {
data := skillZip(t, map[string]string{
"director-main/SKILL.md": "---\nname: AI 导演\ndescription: 导演工作流\nmetadata:\n version: 2.1\n---\n",
"director-main/references/camera.md": "# Camera",
"director-main/scripts/check.js": "export default true",
})
archive, err := archiveFromZip(data, "")
if err != nil {
t.Fatal(err)
}
if archive.Metadata.Version != "2.1" || len(archive.Files) != 3 {
t.Fatalf("archive = %#v", archive)
}
if string(archive.Files["references/camera.md"]) != "# Camera" {
t.Fatalf("nested file missing: %#v", archive.Files)
}
}
func TestArchiveFromZipRejectsTraversalAndMultipleSkills(t *testing.T) {
for name, files := range map[string]map[string]string{
"traversal": {"../SKILL.md": "# Bad"},
"multiple": {
"one/SKILL.md": "# One\n\nFirst",
"two/SKILL.md": "# Two\n\nSecond",
},
} {
t.Run(name, func(t *testing.T) {
if _, err := archiveFromZip(skillZip(t, files), ""); err == nil {
t.Fatal("expected package validation error")
}
})
}
}
func TestParseGitHubSkillURL(t *testing.T) {
spec, err := parseGitHubSkillURL("https://github.com/ddcat-ai/open-ai-canvas/tree/main/skills/canvas-context", "", "")
if err != nil {
t.Fatal(err)
}
if spec.Owner != "ddcat-ai" || spec.Repo != "open-ai-canvas" || spec.Ref != "main" || spec.Subdir != "skills/canvas-context" {
t.Fatalf("spec = %#v", spec)
}
if _, err := parseGitHubSkillURL("https://github.com/ddcat-ai/open-ai-canvas/blob/main/SKILL.md", "", ""); err == nil {
t.Fatal("expected blob URL to be rejected")
}
}
func TestEnsureSkillPackagesMigratesAndRefreshesBuiltinSkills(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+kernel.NewID()+"?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.Skill{}, &model.SkillVersion{}, &model.SkillFile{}); err != nil {
t.Fatal(err)
}
svc := New(repository.New(db), t.TempDir(), nil)
builtin := model.Skill{ID: kernel.NewID(), Name: "内置导演", Description: "内置工作流", Instruction: "# 内置导演\n\n第一版", Status: skillStatusEnabled, Source: 3}
userSkill := model.Skill{ID: kernel.NewID(), Name: "用户技能", Description: "用户工作流", Instruction: "# 用户技能\n\n第一版", Status: skillStatusEnabled, Source: skillSourceUser}
if err := db.Create(&builtin).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&userSkill).Error; err != nil {
t.Fatal(err)
}
if err := svc.EnsureSkillPackages(); err != nil {
t.Fatal(err)
}
assertSkillVersionCount(t, db, builtin.ID, 1)
assertSkillVersionCount(t, db, userSkill.ID, 1)
if err := svc.EnsureSkillPackages(); err != nil {
t.Fatal(err)
}
assertSkillVersionCount(t, db, builtin.ID, 1)
assertSkillVersionCount(t, db, userSkill.ID, 1)
if err := db.Model(&model.Skill{}).Where("id = ?", builtin.ID).Update("instruction", "# 内置导演\n\n第二版").Error; err != nil {
t.Fatal(err)
}
if err := db.Model(&model.Skill{}).Where("id = ?", userSkill.ID).Update("instruction", "# 用户技能\n\n不应在启动时重建").Error; err != nil {
t.Fatal(err)
}
if err := svc.EnsureSkillPackages(); err != nil {
t.Fatal(err)
}
assertSkillVersionCount(t, db, builtin.ID, 1)
assertSkillVersionCount(t, db, userSkill.ID, 1)
var refreshed model.Skill
if err := db.First(&refreshed, "id = ?", builtin.ID).Error; err != nil {
t.Fatal(err)
}
version, err := svc.repo.SkillVersion(refreshed.CurrentVersionID)
if err != nil {
t.Fatal(err)
}
body, err := svc.readSkillArchiveEntry(version, "SKILL.md")
if err != nil {
t.Fatal(err)
}
if string(body) != "# 内置导演\n\n第一版" {
t.Fatalf("legacy package was unexpectedly rewritten: %q", body)
}
}
func assertSkillVersionCount(t *testing.T, db *gorm.DB, skillID string, want int64) {
t.Helper()
var got int64
if err := db.Model(&model.SkillVersion{}).Where("skill_id = ?", skillID).Count(&got).Error; err != nil {
t.Fatal(err)
}
if got != want {
t.Fatalf("skill %s version count = %d, want %d", skillID, got, want)
}
}
func skillZip(t *testing.T, files map[string]string) []byte {
t.Helper()
var buffer bytes.Buffer
writer := zip.NewWriter(&buffer)
for filePath, content := range files {
entry, err := writer.Create(filePath)
if err != nil {
t.Fatal(err)
}
if _, err := entry.Write([]byte(content)); err != nil {
t.Fatal(err)
}
}
if err := writer.Close(); err != nil {
t.Fatal(err)
}
return buffer.Bytes()
}