diff --git a/internal/plugin/test/skill_contract_test.go b/internal/plugin/test/skill_contract_test.go new file mode 100644 index 00000000..cb45b415 --- /dev/null +++ b/internal/plugin/test/skill_contract_test.go @@ -0,0 +1,62 @@ +package test + +// internal/plugin 的黑盒判据:公开内容校验与 SKILL 文件加载。 +// 测试只通过 ValidateSKILLContent/LoadSKILL 及 Plugin 接口观察结果。 + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/JianFeeeee/HologramAgent/internal/plugin" +) + +func TestValidateSKILLContent(t *testing.T) { + valid := "# Demo\n\n这是一个用于测试的技能。\n\n## greet\n向用户打招呼。\n- name: 用户名\n" + if err := plugin.ValidateSKILLContent(valid); err != nil { + t.Fatalf("合法 SKILL 被拒:%v", err) + } + for _, tc := range []struct{ name, content, want string }{ + {"empty", "", "empty"}, + {"no description", "# Only heading", "no description"}, + {"bad tool name", "正文\n\n## bad/name\n描述", "invalid tool name"}, + } { + t.Run(tc.name, func(t *testing.T) { + if err := plugin.ValidateSKILLContent(tc.content); err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("错误内容的报错不符:%v", err) + } + }) + } +} + +func TestLoadSKILLDirectoryExposesMetadataAndTools(t *testing.T) { + dir := t.TempDir() + content := "---\nversion: 2.1\nauthor: test\nio_type: text\nio_route: chat\nio_output_caps: text,markdown\n---\n# Demo\n\n用于回归的技能。\n\n## greet\n向用户打招呼。\n- name: 用户名\n" + if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + p, err := plugin.LoadSKILL(dir) + if err != nil { + t.Fatalf("LoadSKILL: %v", err) + } + if p.Name() != filepath.Base(dir) || p.Version() != "2.1" || p.Description() != "用于回归的技能。" { + t.Fatalf("公开元数据不符:name=%q version=%q description=%q", p.Name(), p.Version(), p.Description()) + } + if p.PluginType() != plugin.PluginTypeSKILL || !p.Enabled() || p.RawContent() != content { + t.Fatalf("公开属性不符:type=%q enabled=%v", p.PluginType(), p.Enabled()) + } + if len(p.Tools()) != 1 || p.Tools()[0].Name != "greet" { + t.Fatalf("工具定义不符:%+v", p.Tools()) + } + io := p.IOConfig() + if io == nil || io.Type != "text" || io.InputRoute != "chat" || io.OutputRoute != "chat" || len(io.OutputCaps) != 2 { + t.Fatalf("IO 配置不符:%+v", io) + } +} + +func TestLoadSKILLMissingFileReturnsError(t *testing.T) { + if _, err := plugin.LoadSKILL(filepath.Join(t.TempDir(), "missing")); err == nil { + t.Fatal("不存在的 SKILL 路径却加载成功") + } +} diff --git a/providers/chineseclip/test/tokenizer_contract_test.go b/providers/chineseclip/test/tokenizer_contract_test.go new file mode 100644 index 00000000..304f5448 --- /dev/null +++ b/providers/chineseclip/test/tokenizer_contract_test.go @@ -0,0 +1,56 @@ +package test + +// chineseclip tokenizer 黑盒判据:词表文件 → 固定长度的 ids/mask。 + +import ( + "os" + "path/filepath" + "testing" + + "github.com/JianFeeeee/HologramAgent/providers/chineseclip" +) + +func writeCLIPVocab(t *testing.T) string { + t.Helper() + dir := t.TempDir() + vocab := "[PAD]\n[UNK]\n[CLS]\n[SEP]\n你\n好\n" + if err := os.WriteFile(filepath.Join(dir, "vocab.txt"), []byte(vocab), 0o644); err != nil { + t.Fatal(err) + } + return dir +} + +func TestTokenizerEncodeUsesSpecialTokensAndPadding(t *testing.T) { + tok, err := chineseclip.LoadTokenizer(writeCLIPVocab(t), 6) + if err != nil { + t.Fatalf("LoadTokenizer: %v", err) + } + if tok.MaxLength() != 6 { + t.Fatalf("MaxLength=%d", tok.MaxLength()) + } + ids, mask := tok.Encode("你好") + if len(ids) != 6 || len(mask) != 6 { + t.Fatalf("长度不符 ids=%v mask=%v", ids, mask) + } + // 词表按文件顺序编号:[PAD]=0,[UNK]=1,[CLS]=2,[SEP]=3,你=4,好=5。 + wantIDs := []int64{2, 4, 5, 3, 0, 0} + wantMask := []int64{1, 1, 1, 1, 0, 0} + for i := range ids { + if ids[i] != wantIDs[i] || mask[i] != wantMask[i] { + t.Fatalf("Encode=%v/%v", ids, mask) + } + } +} + +func TestTokenizerRejectsInvalidVocabulary(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "vocab.txt"), []byte("[PAD]\n"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := chineseclip.LoadTokenizer(dir, 4); err == nil { + t.Fatal("缺特殊 token 的词表却成功") + } + if _, err := chineseclip.LoadTokenizer(dir, 0); err == nil { + t.Fatal("非正 maxLength 却成功") + } +} diff --git a/providers/qwen3vl/test/tokenizer_contract_test.go b/providers/qwen3vl/test/tokenizer_contract_test.go new file mode 100644 index 00000000..e6712f77 --- /dev/null +++ b/providers/qwen3vl/test/tokenizer_contract_test.go @@ -0,0 +1,62 @@ +package test + +// qwen3vl tokenizer 黑盒判据:从最小公开 tokenizer.json 加载,验证 +// 特殊 token 的 Encode/Decode/SpecialID 及输入错误。 + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/JianFeeeee/HologramAgent/providers/qwen3vl" +) + +func writeQwenTokenizer(t *testing.T) string { + t.Helper() + dir := t.TempDir() + doc := map[string]interface{}{ + "model": map[string]interface{}{"vocab": map[string]int{"<|im_start|>": 42, "<|endoftext|>": 99}, "merges": []interface{}{}}, + "added_tokens": []map[string]interface{}{ + {"id": 42, "content": "<|im_start|>", "special": true}, + {"id": 99, "content": "<|endoftext|>", "special": true}, + }, + } + b, err := json.Marshal(doc) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "tokenizer.json"), b, 0o644); err != nil { + t.Fatal(err) + } + return dir +} + +func TestTokenizerSpecialTokenRoundTrip(t *testing.T) { + tok, err := qwen3vl.LoadTokenizer(writeQwenTokenizer(t)) + if err != nil { + t.Fatalf("LoadTokenizer: %v", err) + } + if tok.VocabSize() != 2 { + t.Fatalf("VocabSize=%d", tok.VocabSize()) + } + id, ok := tok.SpecialID("<|im_start|>") + if !ok || id != 42 { + t.Fatalf("SpecialID=%d,%v", id, ok) + } + if got := tok.Encode("<|im_start|>"); len(got) != 1 || got[0] != 42 { + t.Fatalf("Encode=%v", got) + } + if got := tok.DecodeOne(42); got != "<|im_start|>" { + t.Fatalf("DecodeOne=%q", got) + } + if got := tok.DecodeOne(12345); got != "" { + t.Fatalf("未知 token 解码为 %q", got) + } +} + +func TestTokenizerMissingFileReturnsError(t *testing.T) { + if _, err := qwen3vl.LoadTokenizer(t.TempDir()); err == nil { + t.Fatal("缺少 tokenizer.json 却成功") + } +}