package skills import ( "errors" "os" "path/filepath" "strings" "testing" "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" ) // fullSkillYAML 覆盖 design/09 §6.2 的全部新增字段,用来断言「字段真的接上了」。 const fullSkillYAML = ` name: full description: 全字段技能 prompt: base version: 3 when-to-use: 当需要全字段校验时 invocation: model-only triggers: [汇总, 简报] paths: ["src/**/*.go"] allowed-tools: [search, note] disallowed-tools: [write] context: fork agent: researcher model: fast effort: high metadata: owner: yeiji steps: - name: one prompt: p ` func TestLoader_Load_新字段_正常(t *testing.T) { t.Parallel() sk, err := NewLoader(testLogger()).Load(strings.NewReader(fullSkillYAML)) if err != nil { t.Fatalf("Load: %v", err) } if sk.Version() != 3 { t.Fatalf("Version = %d, want 3", sk.Version()) } if sk.WhenToUse() != "当需要全字段校验时" { t.Fatalf("WhenToUse = %q", sk.WhenToUse()) } if sk.Invocation() != sdk.InvocationModelOnly { t.Fatalf("Invocation = %q, want model-only", sk.Invocation()) } if got := strings.Join(sk.Triggers(), ","); got != "汇总,简报" { t.Fatalf("Triggers = %q", got) } if got := strings.Join(sk.Paths(), ","); got != "src/**/*.go" { t.Fatalf("Paths = %q", got) } if got := strings.Join(sk.AllowedTools(), ","); got != "search,note" { t.Fatalf("AllowedTools = %q", got) } if got := strings.Join(sk.DisallowedTools(), ","); got != "write" { t.Fatalf("DisallowedTools = %q", got) } if sk.ContextMode() != sdk.ContextFork || sk.Agent() != "researcher" { t.Fatalf("Context/Agent = %q/%q, want fork/researcher", sk.ContextMode(), sk.Agent()) } if sk.Model() != "fast" || sk.Effort() != "high" { t.Fatalf("Model/Effort = %q/%q, want fast/high", sk.Model(), sk.Effort()) } if got := sk.Metadata()["owner"]; got != "yeiji" { t.Fatalf("Metadata[owner] = %q, want yeiji", got) } } // TestLoader_Load_缺省值 断言未声明的字段落到文档规定的缺省(version=1 / both / inline)。 func TestLoader_Load_缺省值(t *testing.T) { t.Parallel() sk, err := NewLoader(testLogger()).Load(strings.NewReader("name: bare\nprompt: p\n")) if err != nil { t.Fatalf("Load: %v", err) } if sk.Version() != sdk.DefaultSkillVersion || sk.Invocation() != sdk.InvocationBoth || sk.ContextMode() != sdk.ContextInline { t.Fatalf("缺省值 = %d/%q/%q, want 1/both/inline", sk.Version(), sk.Invocation(), sk.ContextMode()) } if sk.AllowedTools() != nil || sk.Triggers() != nil || sk.Metadata() != nil { t.Fatalf("未声明的集合字段应为空:%v/%v/%v", sk.AllowedTools(), sk.Triggers(), sk.Metadata()) } } // TestLoader_Load_非法枚举_异常 断言枚举写错是**显式失败**,不被静默当成缺省。 func TestLoader_Load_非法枚举_异常(t *testing.T) { t.Parallel() cases := map[string]string{ "invocation": "name: x\ninvocation: everyone\n", "context": "name: x\ncontext: session\n", "paths": "name: x\npaths: [\"[bad\"]\n", } for name, src := range cases { src := src t.Run(name, func(t *testing.T) { t.Parallel() if _, err := NewLoader(testLogger()).Load(strings.NewReader(src)); !errors.Is(err, sdk.ErrInvalidSkillField) { t.Fatalf("Load(%s) err = %v, want sdk.ErrInvalidSkillField", name, err) } }) } } // TestLoader_Load_结构校验_异常 覆盖 design/09 §6.2 要求的四类「加载期就拒」。 func TestLoader_Load_结构校验_异常(t *testing.T) { t.Parallel() cases := map[string]struct { yaml string want error }{ "依赖不存在": { yaml: "name: x\nsteps:\n - name: a\n prompt: p\n depends: [ghost]\n", want: ErrStepDependsMissing, }, "依赖成环": { yaml: "name: x\nsteps:\n - name: a\n prompt: p\n depends: [b]\n - name: b\n prompt: p\n depends: [a]\n", want: ErrStepCycle, }, "自依赖": { yaml: "name: x\nsteps:\n - name: a\n prompt: p\n depends: [a]\n", want: ErrStepCycle, }, "工具自相矛盾": { yaml: "name: x\nallowed-tools: [a, b]\ndisallowed-tools: [b]\n", want: ErrToolConflict, }, "护栏重复": { yaml: "name: x\nguardrails:\n - id: g\n message: m\n - id: g\n message: n\n", want: ErrGuardrailDuplicate, }, "步骤重名": { yaml: "name: x\nsteps:\n - name: a\n prompt: p\n - name: a\n prompt: p\n", want: ErrStepDuplicate, }, "步骤匿名": { yaml: "name: x\nsteps:\n - prompt: p\n", want: ErrInvalidSkill, }, } for name, tc := range cases { tc := tc t.Run(name, func(t *testing.T) { t.Parallel() _, err := NewLoader(testLogger()).Load(strings.NewReader(tc.yaml)) if !errors.Is(err, tc.want) { t.Fatalf("%s: err = %v, want %v", name, err, tc.want) } }) } } // TestLoader_LoadDir_目录式与单文件兼容 覆盖 design/09 §6.1 的载体规则: // 目录式 /skill.yaml、单文件 .yaml、同 id 时目录胜出、无定义文件的子目录跳过。 func TestLoader_LoadDir_目录式与单文件兼容(t *testing.T) { t.Parallel() dir := t.TempDir() writeNested(t, dir+"/brief/skill.yaml", "name: brief\nprompt: p\n") writeNested(t, dir+"/brief/notes.md", "支持文件,不该被当成技能") writeFile(t, dir+"/legacy.yaml", "name: legacy\nprompt: p\n") writeFile(t, dir+"/both.yaml", "name: from-file\nprompt: p\n") writeNested(t, dir+"/both/skill.yaml", "name: from-dir\nprompt: p\n") writeNested(t, dir+"/asset/support.txt", "无 skill.yaml 的子目录") writeFile(t, dir+"/.hidden.yaml", "name: hidden\nprompt: p\n") loaded, err := NewLoader(testLogger()).LoadDirDetailed(dir) if err != nil { t.Fatalf("LoadDirDetailed: %v", err) } if len(loaded) != 3 { t.Fatalf("加载了 %d 份,want 3(both/brief/legacy)", len(loaded)) } byID := make(map[string]Loaded, len(loaded)) for _, item := range loaded { byID[item.ID] = item } if got := byID["both"].Skill.Name(); got != "from-dir" { t.Fatalf("同 id 目录与文件并存时取到 %q,want from-dir(目录胜出)", got) } if !strings.HasSuffix(filepath.ToSlash(byID["both"].File), "both/skill.yaml") { t.Fatalf("both 的来源 = %q, want 目录式定义文件", byID["both"].File) } if !strings.HasSuffix(filepath.ToSlash(byID["brief"].Dir), "brief") { t.Fatalf("brief 的 Dir = %q, want 指向技能目录(支持文件按它解析)", byID["brief"].Dir) } if !strings.HasSuffix(filepath.ToSlash(byID["legacy"].File), "legacy.yaml") { t.Fatalf("legacy 的来源 = %q, want 单文件", byID["legacy"].File) } } // TestLoader_LoadDir_坏定义即整体失败 断言目录加载是 fail fast:一份坏定义不会 // 被跳过(那会让「配置写错」看起来像「没这个技能」)。 func TestLoader_LoadDir_坏定义即整体失败(t *testing.T) { t.Parallel() dir := t.TempDir() writeFile(t, dir+"/ok.yaml", "name: ok\nprompt: p\n") writeFile(t, dir+"/bad.yaml", "name: bad\nsteps:\n - name: a\n prompt: p\n depends: [ghost]\n") if _, err := NewLoader(testLogger()).LoadDir(dir); !errors.Is(err, ErrStepDependsMissing) { t.Fatalf("LoadDir err = %v, want ErrStepDependsMissing", err) } } // TestTopoOrder_顺序与环 断言拓扑序确定(就绪步骤按声明序出队),且成环时如实 // 列出没排进去的步骤。 func TestTopoOrder_顺序与环(t *testing.T) { t.Parallel() order, err := TopoOrder([]sdk.Step{ {Name: "a", Prompt: "p"}, {Name: "b", Prompt: "p", Depends: []string{"a"}}, {Name: "c", Prompt: "p", Depends: []string{"b", "a"}}, {Name: "z", Prompt: "p"}, {Name: "y", Prompt: "p", Depends: []string{"z"}}, }) if err != nil { t.Fatalf("TopoOrder: %v", err) } if got := strings.Join(order, ","); got != "a,z,b,y,c" { t.Fatalf("TopoOrder = %q, want a,z,b,y,c", got) } _, err = TopoOrder([]sdk.Step{ {Name: "a", Prompt: "p", Depends: []string{"b"}}, {Name: "b", Prompt: "p", Depends: []string{"a"}}, {Name: "free", Prompt: "p"}, }) if !errors.Is(err, ErrStepCycle) { t.Fatalf("TopoOrder(cycle) err = %v, want ErrStepCycle", err) } if !strings.Contains(err.Error(), "a") || !strings.Contains(err.Error(), "b") { t.Fatalf("环错误没列出相关步骤:%v", err) } } // writeNested 写文件并自动建父目录(目录式技能要的是 /skill.yaml)。 func writeNested(t *testing.T, path, content string) { t.Helper() if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatalf("mkdir %s: %v", filepath.Dir(path), err) } writeFile(t, path, content) }