From cd4290807ca2a49d4bdd46e4c208244f44ffadea Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 9 Nov 2026 16:35:21 +0800 Subject: [PATCH] =?UTF-8?q?test:=20=E6=8C=89=E6=A8=A1=E5=9D=97=E5=AF=BC?= =?UTF-8?q?=E5=87=BA=E6=8E=A5=E5=8F=A3=E9=87=8D=E5=86=99=E9=BB=91=E7=9B=92?= =?UTF-8?q?=E5=88=A4=E6=8D=AE=EF=BC=88=E6=89=B9=E6=AC=A1=2012=EF=BC=9Aplug?= =?UTF-8?q?in=20/=20qwen3vl=20/=20chineseclip=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 3 个文件全部在 `/test/`,源码目录仍零 `_test.go`。 新增公开输入→输出判据: ## internal/plugin —— SKILL 内容与加载 TestValidateSKILLContent 合法内容通过;空内容/无描述/非法工具名分别被拒 TestLoadSKILLDirectoryExposesMetadataAndTools 临时 SKILL.md → Name/Version/Description/Tools/IOConfig TestLoadSKILLMissingFileReturnsError ## providers/qwen3vl —— BPE tokenizer 公开契约 TestTokenizerSpecialTokenRoundTrip 临时 tokenizer.json → VocabSize/SpecialID/Encode/DecodeOne TestTokenizerMissingFileReturnsError ## providers/chineseclip —— WordPiece tokenizer 公开契约 TestTokenizerEncodeUsesSpecialTokensAndPadding 临时 vocab.txt → 固定长度 ids 与 attention mask TestTokenizerRejectsInvalidVocabulary 缺特殊 token / 非正 maxLength → 报错 所有测试都用 `package test` 导入模块,通过导出 API 操作;不访问私有符号, 不读取源码/文档作为业务断言。 ## 变异验证 plugin ValidateSKILLContent 放过无描述内容 → no_description 断言红 qwen3vl Encode 丢弃特殊 token → Encode=[] 断言红 chineseclip Encode 不补 padding → 固定长度判据红 ## 验证 go build ./... PASS go vet ./... PASS gofmt -l 干净 python3 .github/scripts/check-ci-gates.py PASS go test ./... -count=1 45 个测试包 / 100 包无测试 / 0 FAIL 3 个新包 -race 全绿 源码目录 _test.go 0 --- internal/plugin/test/skill_contract_test.go | 62 +++++++++++++++++++ .../test/tokenizer_contract_test.go | 56 +++++++++++++++++ .../qwen3vl/test/tokenizer_contract_test.go | 62 +++++++++++++++++++ 3 files changed, 180 insertions(+) create mode 100644 internal/plugin/test/skill_contract_test.go create mode 100644 providers/chineseclip/test/tokenizer_contract_test.go create mode 100644 providers/qwen3vl/test/tokenizer_contract_test.go 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 却成功") + } +}