package engine import ( "os" "path/filepath" "testing" ) // ---- parseFrontmatter ---- func TestParseFrontmatter_NoFrontmatter(t *testing.T) { content := "# Just a document\nNo frontmatter here." fm, body := parseFrontmatter(content) if fm != "" { t.Errorf("expected empty frontmatter, got %q", fm) } if body != content { t.Errorf("expected body to be original content, got %q", body) } } func TestParseFrontmatter_Basic(t *testing.T) { content := "---\nname: my-skill\ndescription: A test skill\n---\n\nBody content here." fm, body := parseFrontmatter(content) if fm == "" { t.Fatal("expected non-empty frontmatter") } if body != "\nBody content here." { t.Errorf("unexpected body: %q", body) } } func TestParseFrontmatter_EmptyBody(t *testing.T) { content := "---\nname: skill\n---\n" fm, body := parseFrontmatter(content) if fm == "" { t.Fatal("expected non-empty frontmatter") } if body != "" { t.Errorf("expected empty body, got %q", body) } } func TestParseFrontmatter_OnlyDelimiters(t *testing.T) { // Malformed: only opening --- content := "---\nname: skill\n" fm, _ := parseFrontmatter(content) // 找不到结束 ---,应该返回空 frontmatter if fm != "" { t.Errorf("expected empty frontmatter for malformed input, got %q", fm) } } // ---- parseFrontmatterFields ---- func TestParseFrontmatterFields_AllFields(t *testing.T) { fm := `name: test-skill description: A comprehensive test skill when_to_use: When you need to test argument-hint: version: 2.0.0 model: sonnet context: fork agent: general-purpose user-invocable: true allowed-tools: Bash, Read, Write paths: src/*.ts, src/*.tsx` skill := &Skill{} parseFrontmatterFields(fm, skill) if skill.Name != "test-skill" { t.Errorf("Name: got %q, want %q", skill.Name, "test-skill") } if skill.Description != "A comprehensive test skill" { t.Errorf("Description: got %q", skill.Description) } if skill.WhenToUse != "When you need to test" { t.Errorf("WhenToUse: got %q", skill.WhenToUse) } if skill.ArgumentHint != "" { t.Errorf("ArgumentHint: got %q", skill.ArgumentHint) } if skill.Version != "2.0.0" { t.Errorf("Version: got %q", skill.Version) } if skill.Model != "sonnet" { t.Errorf("Model: got %q", skill.Model) } if skill.Context != ExecutionContextFork { t.Errorf("Context: got %q, want fork", skill.Context) } if skill.AgentType != "general-purpose" { t.Errorf("AgentType: got %q", skill.AgentType) } if !skill.UserInvocable { t.Error("UserInvocable: expected true") } if len(skill.AllowedTools) != 3 { t.Errorf("AllowedTools: got %d items, want 3", len(skill.AllowedTools)) } if len(skill.Paths) != 2 { t.Errorf("Paths: got %d items, want 2", len(skill.Paths)) } } func TestParseFrontmatterFields_ListFormat(t *testing.T) { fm := `name: list-skill allowed-tools: - Bash - Read - Write paths: - src/*.go - pkg/**/*.go` skill := &Skill{} parseFrontmatterFields(fm, skill) if len(skill.AllowedTools) != 3 { t.Errorf("AllowedTools: got %d items, want 3 (got: %v)", len(skill.AllowedTools), skill.AllowedTools) } if len(skill.Paths) != 2 { t.Errorf("Paths: got %d items, want 2", len(skill.Paths)) } } func TestParseFrontmatterFields_ModelInherit(t *testing.T) { fm := "model: inherit" skill := &Skill{} parseFrontmatterFields(fm, skill) if skill.Model != "" { t.Errorf("model: inherit should result in empty model, got %q", skill.Model) } } func TestParseFrontmatterFields_QuotedValues(t *testing.T) { fm := `name: "quoted-name" description: "A description with: colons" paths: "src/*.{ts,tsx}"` skill := &Skill{} parseFrontmatterFields(fm, skill) if skill.Name != "quoted-name" { t.Errorf("Name: got %q", skill.Name) } if skill.Description != "A description with: colons" { t.Errorf("Description: got %q", skill.Description) } if len(skill.Paths) != 1 || skill.Paths[0] != "src/*.{ts,tsx}" { t.Errorf("Paths: got %v", skill.Paths) } } func TestParseFrontmatterFields_ContextInline(t *testing.T) { skill := &Skill{} parseFrontmatterFields("context: inline", skill) if skill.Context != ExecutionContextInline { t.Errorf("expected inline, got %q", skill.Context) } } func TestParseFrontmatterFields_UnknownContext(t *testing.T) { skill := &Skill{} parseFrontmatterFields("context: unknown", skill) // 未知 context 应该 fallback 到 inline if skill.Context != ExecutionContextInline { t.Errorf("expected inline fallback, got %q", skill.Context) } } // ---- unquoteYAML ---- func TestUnquoteYAML(t *testing.T) { cases := []struct { input string want string }{ {`"hello"`, "hello"}, {`'world'`, "world"}, {`no-quotes`, "no-quotes"}, {`""`, ""}, {`"`, `"`}, // 单个引号,不拆(长度<2) {`"a"b"`, `a"b`}, // 两端 " 匹配,中间内容保留原样 } for _, c := range cases { got := unquoteYAML(c.input) if got != c.want { t.Errorf("unquoteYAML(%q) = %q, want %q", c.input, got, c.want) } } } // ---- splitCSV ---- func TestSplitCSV(t *testing.T) { cases := []struct { input string want []string }{ {"Bash, Read, Write", []string{"Bash", "Read", "Write"}}, {"[Bash, Read]", []string{"Bash", "Read"}}, {"single", []string{"single"}}, {" space , around ", []string{"space", "around"}}, {`"quoted", plain`, []string{"quoted", "plain"}}, } for _, c := range cases { got := splitCSV(c.input) if len(got) != len(c.want) { t.Errorf("splitCSV(%q) = %v, want %v", c.input, got, c.want) continue } for i := range got { if got[i] != c.want[i] { t.Errorf("splitCSV(%q)[%d] = %q, want %q", c.input, i, got[i], c.want[i]) } } } } // ---- Skill.ExpandPrompt ---- func TestSkillExpandPrompt_Basic(t *testing.T) { s := &Skill{ Content: "Process $ARGUMENTS using this guide. Session: ${FLYTO_SESSION_ID}", SkillDir: "/home/user/.flyto/skills/commit", } expanded := s.ExpandPrompt("feat: add login", "sess-123") want := "Process feat: add login using this guide. Session: sess-123" if expanded != want { t.Errorf("ExpandPrompt: got %q, want %q", expanded, want) } } func TestSkillExpandPrompt_SkillDir(t *testing.T) { s := &Skill{ Content: "Base dir: ${FLYTO_SKILL_DIR}", SkillDir: "/skills/commit", } expanded := s.ExpandPrompt("", "") if expanded != "Base dir: /skills/commit" { t.Errorf("ExpandPrompt: got %q", expanded) } } func TestSkillExpandPrompt_ClaudeCompat(t *testing.T) { // 兼容早期方案变量名 s := &Skill{ Content: "Dir: ${CLAUDE_SKILL_DIR}, Session: ${CLAUDE_SESSION_ID}", SkillDir: "/mydir", } expanded := s.ExpandPrompt("", "sess42") if expanded != "Dir: /mydir, Session: sess42" { t.Errorf("ExpandPrompt: got %q", expanded) } } func TestSkillExpandPrompt_NoArgs(t *testing.T) { s := &Skill{Content: "No arguments here."} expanded := s.ExpandPrompt("", "") if expanded != "No arguments here." { t.Errorf("ExpandPrompt: got %q", expanded) } } // ---- LoadSkillFile ---- func TestLoadSkillFile_Basic(t *testing.T) { dir := t.TempDir() content := "---\nname: my-skill\ndescription: Hello\ncontext: fork\n---\n\nDo the task: $ARGUMENTS" path := filepath.Join(dir, "SKILL.md") if err := os.WriteFile(path, []byte(content), 0644); err != nil { t.Fatal(err) } s, err := LoadSkillFile(path, "user") if err != nil { t.Fatalf("LoadSkillFile: %v", err) } if s.Name != "my-skill" { t.Errorf("Name: got %q", s.Name) } if s.Description != "Hello" { t.Errorf("Description: got %q", s.Description) } if s.Context != ExecutionContextFork { t.Errorf("Context: got %q", s.Context) } if s.Source != "user" { t.Errorf("Source: got %q", s.Source) } if s.SkillDir != dir { t.Errorf("SkillDir: got %q, want %q", s.SkillDir, dir) } if s.Content != "Do the task: $ARGUMENTS" { t.Errorf("Content: got %q", s.Content) } } func TestLoadSkillFile_NotFound(t *testing.T) { _, err := LoadSkillFile("/nonexistent/SKILL.md", "user") if err == nil { t.Error("expected error for nonexistent file") } } // ---- ScanSkillsDir ---- func TestScanSkillsDir_Subdirectory(t *testing.T) { dir := t.TempDir() // 创建子目录格式:dir/commit/SKILL.md commitDir := filepath.Join(dir, "commit") if err := os.Mkdir(commitDir, 0755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(commitDir, "SKILL.md"), []byte("---\ndescription: Commit skill\n---\nCommit everything"), 0644); err != nil { t.Fatal(err) } skills, err := ScanSkillsDir(dir, "user") if err != nil { t.Fatalf("ScanSkillsDir: %v", err) } if len(skills) != 1 { t.Fatalf("expected 1 skill, got %d", len(skills)) } if skills[0].Name != "commit" { t.Errorf("Name: got %q, want 'commit'", skills[0].Name) } } func TestScanSkillsDir_FlatFile(t *testing.T) { dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, "review.md"), []byte("---\ndescription: Review skill\n---\nReview code"), 0644); err != nil { t.Fatal(err) } skills, err := ScanSkillsDir(dir, "project") if err != nil { t.Fatalf("ScanSkillsDir: %v", err) } if len(skills) != 1 { t.Fatalf("expected 1 skill, got %d", len(skills)) } if skills[0].Name != "review" { t.Errorf("Name: got %q", skills[0].Name) } } func TestScanSkillsDir_SubdirPriorityOverFlat(t *testing.T) { dir := t.TempDir() // 同时有 commit.md 和 commit/SKILL.md,子目录优先 if err := os.WriteFile(filepath.Join(dir, "commit.md"), []byte("---\ndescription: Flat commit\n---\nFlat"), 0644); err != nil { t.Fatal(err) } subdir := filepath.Join(dir, "commit") if err := os.Mkdir(subdir, 0755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(subdir, "SKILL.md"), []byte("---\ndescription: Dir commit\n---\nDir"), 0644); err != nil { t.Fatal(err) } skills, err := ScanSkillsDir(dir, "user") if err != nil { t.Fatalf("ScanSkillsDir: %v", err) } if len(skills) != 1 { t.Fatalf("expected 1 skill (deduped), got %d", len(skills)) } if skills[0].Description != "Dir commit" { t.Errorf("expected subdirectory format to win, got description %q", skills[0].Description) } } func TestScanSkillsDir_NotExists(t *testing.T) { skills, err := ScanSkillsDir("/nonexistent/dir", "user") if err != nil { t.Errorf("expected no error for nonexistent dir, got %v", err) } if len(skills) != 0 { t.Errorf("expected empty list, got %d", len(skills)) } } func TestScanSkillsDir_Multiple(t *testing.T) { dir := t.TempDir() for _, name := range []string{"alpha", "beta", "gamma"} { subdir := filepath.Join(dir, name) _ = os.Mkdir(subdir, 0755) _ = os.WriteFile(filepath.Join(subdir, "SKILL.md"), []byte("---\ndescription: "+name+"\n---\n"+name+" body"), 0644) } skills, err := ScanSkillsDir(dir, "user") if err != nil { t.Fatalf("ScanSkillsDir: %v", err) } if len(skills) != 3 { t.Errorf("expected 3 skills, got %d", len(skills)) } } // ---- FormatSkillsPrompt ---- func TestFormatSkillsPrompt_Empty(t *testing.T) { result := FormatSkillsPrompt(nil, 0) if result != "" { t.Errorf("expected empty string for nil skills, got %q", result) } } func TestFormatSkillsPrompt_WithSkills(t *testing.T) { skills := []*Skill{ {Name: "commit", Description: "Create a commit"}, {Name: "review", Description: "Review code"}, } result := FormatSkillsPrompt(skills, 0) if result == "" { t.Error("expected non-empty result") } if len(result) < 20 { t.Errorf("result too short: %q", result) } } func TestFormatSkillsPrompt_Truncation(t *testing.T) { // 生成超大描述,测试截断 skills := make([]*Skill, 20) for i := range skills { desc := "" for j := 0; j < 1000; j++ { desc += "x" } skills[i] = &Skill{Name: "skill", Description: desc} } result := FormatSkillsPrompt(skills, 500) if len(result) > 600 { // 允许一点余量(标题等) t.Errorf("result exceeds max chars budget: %d bytes", len(result)) } }