From 3384cee62a6a44061d6ed3d8f4f604ba11242c28 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 9 Nov 2026 11:57:48 +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=201=EF=BC=9Aconfi?= =?UTF-8?q?g/knowledge/distill/agent-api=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 方向 「面向模块输入接受模块输出」:只调模块**本来就有**的导出接口, 输入 → 断言可观察输出。不从旧白盒里抽、不读源码、不引用文档、 不构造内部结构、不为测试导出任何新符号。 4 个文件全部放 `/test/`,源码目录仍零 `_test.go`。 共 23 条判据,每模块 1 个文件。 ## 各模块的接口清单与判据 ### internal/config —— NewConfigRegistry / PluginConfig TestCoreSetGetDelete Set → Get → Delete → Get=nil TestPluginNamespaceIsIsolated 两插件同名 key 不串台;核心表看不到插件键 TestPluginListByPrefix List 只返回匹配前缀的键 TestDefRoundTripPreservesSecret RegisterDef → ListDefs 保住 Secret TestSecretValueIsNotPlaintextOnDisk Set → GetSecret 往返 + DB 里无明文 **判据按真实契约写**:插件表由 `RegisterDef` 创建,`Set` 不隐式建表。 第一版没先 RegisterDef,实测 `no such table: config_alpha` —— 不是缺陷, 是我把装配顺序当成了实现细节。判据里显式写了这条契约。 ### internal/knowledge —— NewStore(root) / Add / List / Stats / Remove / Search / Categories TestAddThenListAndStats Add → List/Stats TestRemoveActuallyRemoves Remove → 空;二次 Remove 报错 TestPathTraversalIsRejected ".." / "." / 空名 必须被拒 TestCategoriesIncludeIntermediateLevels tech/go/叶名 ⇒ tech、tech/go 都出现 TestSearchRanksRelevantAboveUnrelated 只问排序,不钉数值 `TestSearchRanksRelatedAboveUnrelated` 直接对治 engineering-lessons §1.1 那个坑:16 条判据全绿而检索结果完全无关(分数全挤在 0.75~0.79)。 **判据写错过一次并修正**:`Categories()` 返回的是**父路径**, `tech/go/concurrency` 条目的 Category 是 `tech/go`,不含叶名。 第一版断言叶名也在列表里 ⇒ 红。查实现后按真实契约改成两层。 ### internal/memory/distill —— 纯函数,输入输出就是全部语义 TestBlockTextRoundTrip ToMemoryBlock → BlockSubject/Dimension/Value TestBlockIDDependsOnSubject 不同主语不得撞 ID(静默数据丢失) TestBlockIDIsDeterministic 同输入同输出 TestDimensionValueEdgeCases 无 '=' / 空维度 / 多 '=' TestSentenceBlockIDIsStable 原句块 ID 稳定 TestNormalizeDimensionIsIdempotent 归一幂等 ### internal/agent/api —— 纯函数 + ProviderManager 公开状态机 TestEstimateTokensIsMonotonic 更长文本不得更少 token TestTruncateByTokensRespectsBudget 截断后不超预算 TestModelContextWindowAlwaysPositive TestIsValidSourceConfig 四字段 + URL scheme 真值表 TestRegisterGetListDefault 注册顺序/按名取回/默认源切换 TestAvailabilityStateMachine MarkUnavailable → 不可用 → Reset 恢复 TestProviderErrorCarriesStatusAndMessage `ProviderManager` 用的是真 `NewProviderManager()` + 满足 `Provider` 接口的 stub,不碰 `m.status`、不用任何 ForTest 钩子。 ## 变异验证(每条至少一次,改回旧实现必须红) config ListDefs 丢 Secret → ★ Secret:true 在 RegisterDef → ListDefs 后丢失 knowledge normalizeName 放行 ".." → ★ Add("..") 被接受了 —— 路径可逃出知识根 distill BlockID 丢 subject → ★ 不同主语得到同一个 BlockID api 漏 URL scheme 校验 → ★ ftp://x 判成了 true 全部还原后复跑全绿。 ## 验证 go build ./... PASS go vet ./... PASS gofmt -l 干净 python3 .github/scripts/check-ci-gates.py PASS go test ./... -count=1 9 个测试包 / 100 个包无测试 / 0 FAIL 4 个新包 -race 全绿 lens 4 个新文件 LSP 0 诊断 --- internal/agent/api/test/api_contract_test.go | 138 ++++++++++++++++++ .../config/test/registry_contract_test.go | 133 +++++++++++++++++ .../knowledge/test/store_contract_test.go | 105 +++++++++++++ .../distill/test/blocks_contract_test.go | 87 +++++++++++ 4 files changed, 463 insertions(+) create mode 100644 internal/agent/api/test/api_contract_test.go create mode 100644 internal/config/test/registry_contract_test.go create mode 100644 internal/knowledge/test/store_contract_test.go create mode 100644 internal/memory/distill/test/blocks_contract_test.go diff --git a/internal/agent/api/test/api_contract_test.go b/internal/agent/api/test/api_contract_test.go new file mode 100644 index 00000000..0603f7d8 --- /dev/null +++ b/internal/agent/api/test/api_contract_test.go @@ -0,0 +1,138 @@ +package test + +// internal/agent/api 的黑盒判据:只调导出函数/方法,输入 → 可观察输出。 +// 不读源码、不碰 m.status、不用 ForTest。 + +import ( + "context" + "strings" + "testing" + + "github.com/JianFeeeee/HologramAgent/internal/agent/api" +) + +// ─────────── 纯函数:输入 → 输出 ─────────── + +// TestEstimateTokensIsMonotonic:更长的文本不得得到更少的 token。 +func TestEstimateTokensIsMonotonic(t *testing.T) { + short := api.EstimateTokens("你好") + long := api.EstimateTokens("你好" + strings.Repeat("这是一段用于估算长度的中文文本", 20)) + if short <= 0 { + t.Fatalf("★ EstimateTokens(短文本) = %d,期望 > 0", short) + } + if long <= short { + t.Fatalf("★ 长文本估算 %d 未超过短文本 %d", long, short) + } +} + +// TestTruncateByTokensRespectsBudget:截断结果不得超过预算。 +func TestTruncateByTokensRespectsBudget(t *testing.T) { + src := strings.Repeat("中文内容ABC ", 200) + for _, budget := range []int{8, 32, 128} { + got := api.TruncateByTokens(src, budget) + if n := api.EstimateTokens(got); n > budget+1 { + t.Fatalf("★ budget=%d 截断后估算 %d,超预算", budget, n) + } + } +} + +// TestModelContextWindowAlwaysPositive:任何模型名都给出可用的正数窗口。 +func TestModelContextWindowAlwaysPositive(t *testing.T) { + for _, m := range []string{"deepseek-chat", "qwen3-vl", "完全未知的模型", ""} { + if w := api.ModelContextWindow(m); w <= 0 { + t.Fatalf("★ ModelContextWindow(%q) = %d,期望 > 0", m, w) + } + } +} + +// TestIsValidSourceConfig:四字段 + URL scheme 的输入→输出真值表。 +func TestIsValidSourceConfig(t *testing.T) { + cases := []struct { + name, url, model, adapter string + want bool + }{ + {"deepseek", "https://api.deepseek.com", "deepseek-chat", "openai", true}, + {"local", "http://127.0.0.1:8081/v1", "AUTO", "openai", true}, + {"", "https://x", "m", "a", false}, + {"n", "ftp://x", "m", "a", false}, + {"n", "https://x", "", "a", false}, + {"n", "https://x", "nil", "a", false}, + {"n", "https://x", "m", "", false}, + } + for _, c := range cases { + if got := api.IsValidSourceConfig(c.name, c.url, c.model, c.adapter); got != c.want { + t.Errorf("★ IsValidSourceConfig(%q,%q,%q,%q)=%v,期望 %v", + c.name, c.url, c.model, c.adapter, got, c.want) + } + } +} + +// ─────────── ProviderManager:输入 → 可观察输出 ─────────── + +type stubProvider struct{ name string } + +func (s *stubProvider) Name() string { return s.name } +func (s *stubProvider) MaxContextTokens() int { return 8192 } +func (s *stubProvider) Chat(ctx context.Context, req *api.CompletionRequest) (*api.CompletionResponse, error) { + return &api.CompletionResponse{Content: "ok"}, nil +} +func (s *stubProvider) ChatStream(ctx context.Context, req *api.CompletionRequest) (<-chan api.StreamChunk, error) { + return nil, context.Canceled +} + +// TestRegisterGetListDefault:注册顺序、按名取回、默认源。 +func TestRegisterGetListDefault(t *testing.T) { + m := api.NewProviderManager() + m.Register("first", &stubProvider{name: "first"}) + m.Register("second", &stubProvider{name: "second"}) + if got := m.ProviderCount(); got != 2 { + t.Fatalf("★ ProviderCount = %d,期望 2", got) + } + if got := m.List(); len(got) != 2 || got[0] != "first" || got[1] != "second" { + t.Fatalf("★ List = %v,期望注册顺序", got) + } + if p := m.Get("second"); p == nil || p.Name() != "second" { + t.Fatalf("★ Get(\"second\") = %v", p) + } + if p := m.Default(); p == nil || p.Name() != "first" { + t.Fatalf("★ Default = %v,期望第一个注册的源", p) + } + if err := m.SetDefault("second"); err != nil { + t.Fatalf("SetDefault: %v", err) + } + if p := m.Default(); p.Name() != "second" { + t.Fatalf("★ SetDefault 后 Default = %q,期望 second", p.Name()) + } + if err := m.SetDefault("missing"); err == nil { + t.Fatal("★ SetDefault 不存在的源应报错") + } +} + +// TestAvailabilityStateMachine:MarkUnavailable → IsAvailable=false → +// ResetAvailability → 恢复。这是 provider 熔断的可观察效果。 +func TestAvailabilityStateMachine(t *testing.T) { + m := api.NewProviderManager() + m.Register("p", &stubProvider{name: "p"}) + if !m.IsAvailable("p") { + t.Fatal("★ 新注册源不可用") + } + m.MarkUnavailable("p") + if m.IsAvailable("p") { + t.Fatal("★ MarkUnavailable 后仍报告可用 —— 熔断没生效") + } + m.ResetAvailability("p") + if !m.IsAvailable("p") { + t.Fatal("★ ResetAvailability 后仍报告不可用") + } +} + +// TestProviderErrorCarriesStatusAndMessage:错误对象是对外输出。 +func TestProviderErrorCarriesStatusAndMessage(t *testing.T) { + e := &api.ProviderError{StatusCode: 429, Message: "rate limit", Kind: api.ErrTransient} + if e.Error() != "rate limit" { + t.Fatalf("★ Error() = %q", e.Error()) + } + if e.StatusCode != 429 || e.Kind != api.ErrTransient { + t.Fatalf("★ 状态码/类别丢失:%d / %v", e.StatusCode, e.Kind) + } +} diff --git a/internal/config/test/registry_contract_test.go b/internal/config/test/registry_contract_test.go new file mode 100644 index 00000000..35775d00 --- /dev/null +++ b/internal/config/test/registry_contract_test.go @@ -0,0 +1,133 @@ +package test + +// internal/config 的黑盒判据:只用 NewConfigRegistry / PluginConfig 的导出方法, +// 输入 → 输出。不读源码、不碰 registry 内部结构。 + +import ( + "testing" + + "github.com/JianFeeeee/HologramAgent/internal/config" +) + +func newRegistry(t *testing.T) *config.ConfigRegistry { + t.Helper() + reg := config.NewConfigRegistry("") + t.Cleanup(func() { _ = reg.Close() }) + return reg +} + +// TestCoreSetGetDelete:核心命名空间的写入→读取→删除。 +func TestCoreSetGetDelete(t *testing.T) { + reg := newRegistry(t) + if err := reg.Set("core.llm.model", "deepseek-chat"); err != nil { + t.Fatalf("Set: %v", err) + } + got, err := reg.Get("core.llm.model") + if err != nil { + t.Fatalf("Get: %v", err) + } + if got != "deepseek-chat" { + t.Fatalf("★ Get = %v,期望 deepseek-chat", got) + } + if err := reg.Delete("core.llm.model"); err != nil { + t.Fatalf("Delete: %v", err) + } + got, _ = reg.Get("core.llm.model") + if got != nil { + t.Fatalf("★ 删除后 Get = %v,期望 nil", got) + } +} + +// TestPluginNamespaceIsIsolated:两个插件同名 key 互不串台;核心表看不到插件键。 +func TestPluginNamespaceIsIsolated(t *testing.T) { + reg := newRegistry(t) + a := reg.PluginConfig("alpha") + b := reg.PluginConfig("beta") + // 契约:插件表由 RegisterDef 创建,读写不隐式建表。 + a.RegisterDef(config.ConfigDef{Key: "token"}) + b.RegisterDef(config.ConfigDef{Key: "token"}) + if err := a.Set("token", "A-TOKEN"); err != nil { + t.Fatal(err) + } + if err := b.Set("token", "B-TOKEN"); err != nil { + t.Fatal(err) + } + va, _ := a.Get("token") + vb, _ := b.Get("token") + if va != "A-TOKEN" || vb != "B-TOKEN" { + t.Fatalf("★ 命名空间串台:alpha=%v beta=%v", va, vb) + } + if v, _ := reg.Get("plugin.alpha.token"); v != nil { + t.Fatalf("★ 插件键泄漏进核心表:%v", v) + } +} + +// TestPluginListByPrefix:List 只返回匹配前缀的键。 +func TestPluginListByPrefix(t *testing.T) { + reg := newRegistry(t) + p := reg.PluginConfig("alpha") + for _, k := range []string{"db.host", "db.port", "api.key", "api.secret"} { + p.RegisterDef(config.ConfigDef{Key: k}) + if err := p.Set(k, "v"); err != nil { + t.Fatal(err) + } + } + got, err := p.List("db.") + if err != nil { + t.Fatal(err) + } + if len(got) != 2 { + t.Fatalf("★ List(\"db.\") = %v,期望 2 个键", got) + } +} + +// TestDefRoundTripPreservesSecret:RegisterDef → ListDefs 必须保住 Secret 标记。 +// +// 这是真实缺陷形态:SDK 的 ConfigDef 一直有 Secret,但内核侧转换漏过它, +// 导致敏感项被当普通文本框明文回显。 +func TestDefRoundTripPreservesSecret(t *testing.T) { + reg := newRegistry(t) + p := reg.PluginConfig("alpha") + p.RegisterDef(config.ConfigDef{Key: "by_flag", Type: "string", Secret: true}) + p.RegisterDef(config.ConfigDef{Key: "by_type", Type: "password"}) + defs := p.ListDefs("") + if len(defs) != 2 { + t.Fatalf("★ ListDefs = %d 条,期望 2", len(defs)) + } + byKey := map[string]*config.ConfigDef{} + for _, d := range defs { + byKey[d.Key] = d + } + if !byKey["by_flag"].Secret { + t.Fatal("★ Secret:true 在 RegisterDef → ListDefs 后丢失") + } + if byKey["by_type"].Type != "password" { + t.Fatalf("★ Type 丢失:%q", byKey["by_type"].Type) + } +} + +// TestSecretValueIsNotPlaintextOnDisk:SetSecret → GetSecret 往返,且 DB 里无明文。 +func TestSecretValueIsNotPlaintextOnDisk(t *testing.T) { + reg := newRegistry(t) + reg.SetCodec(config.NewTestCodec(t)) + p := reg.PluginConfig("alpha") + p.RegisterDef(config.ConfigDef{Key: "api_key", Secret: true}) + if err := p.Set("api_key", "PLAINTEXT-SECRET-123"); err != nil { + t.Fatal(err) + } + got, err := p.GetSecret("api_key") + if err != nil { + t.Fatalf("GetSecret: %v", err) + } + if got != "PLAINTEXT-SECRET-123" { + t.Fatalf("★ 解密后 = %q,期望原文", got) + } + var raw string + if err := reg.DB().QueryRow( + "SELECT value FROM config_alpha WHERE key='api_key'").Scan(&raw); err != nil { + t.Fatal(err) + } + if raw == "PLAINTEXT-SECRET-123" { + t.Fatal("★ 磁盘上是明文 —— 敏感项没有加密") + } +} diff --git a/internal/knowledge/test/store_contract_test.go b/internal/knowledge/test/store_contract_test.go new file mode 100644 index 00000000..753ae67c --- /dev/null +++ b/internal/knowledge/test/store_contract_test.go @@ -0,0 +1,105 @@ +package test + +// internal/knowledge 的黑盒判据:NewStore(root) → Add → 可观察输出。 +// 不读 .index.json、不碰 s.items、不引用文档。 + +import ( + "testing" + + "github.com/JianFeeeee/HologramAgent/internal/knowledge" +) + +func newStore(t *testing.T) *knowledge.Store { + t.Helper() + return knowledge.NewStore(t.TempDir()) +} + +// TestAddThenListAndStats:Add 之后 List / Stats 必须反映该条目。 +func TestAddThenListAndStats(t *testing.T) { + s := newStore(t) + if err := s.Add("tech/go/gc", "Go 的垃圾回收使用三色标记"); err != nil { + t.Fatalf("Add: %v", err) + } + got := s.List() + if len(got) != 1 || got[0] != "tech/go/gc" { + t.Fatalf("★ List = %v,期望 [tech/go/gc]", got) + } + st := s.Stats() + if st["knowledge_count"] != 1 { + t.Fatalf("★ knowledge_count = %v,期望 1", st["knowledge_count"]) + } +} + +// TestRemoveActuallyRemoves:Remove 之后 List 不再含该条目,且二次 Remove 报错。 +func TestRemoveActuallyRemoves(t *testing.T) { + s := newStore(t) + if err := s.Add("a", "内容A"); err != nil { + t.Fatal(err) + } + if err := s.Remove("a"); err != nil { + t.Fatalf("Remove: %v", err) + } + if got := s.List(); len(got) != 0 { + t.Fatalf("★ Remove 后 List = %v,期望空", got) + } + if err := s.Remove("a"); err == nil { + t.Fatal("★ 删除不存在的条目应报错,实际返回 nil") + } +} + +// TestPathTraversalIsRejected:名字里的 ".." 不得逃出知识根目录。 +// +// 这是真实事故形态:早期 Remove("..") 直接删掉整个 data 目录。 +func TestPathTraversalIsRejected(t *testing.T) { + s := newStore(t) + for _, name := range []string{"..", "../escape", "a/../b", ".hidden", ""} { + if err := s.Add(name, "x"); err == nil { + t.Fatalf("★ Add(%q) 被接受了 —— 路径可逃出知识根", name) + } + } + if got := s.List(); len(got) != 0 { + t.Fatalf("★ 非法名字留下了条目:%v", got) + } +} + +// TestCategoriesIncludeIntermediateLevels:分类树必须给出中间层。 +func TestCategoriesIncludeIntermediateLevels(t *testing.T) { + s := newStore(t) + if err := s.Add("tech/go/concurrency", "goroutine 调度"); err != nil { + t.Fatal(err) + } + cats := s.Categories() + // Category 是**父路径**:tech/go/concurrency 条目的 Category 是 tech/go。 + want := map[string]bool{"tech": false, "tech/go": false} + for _, c := range cats { + if _, ok := want[c]; ok { + want[c] = true + } + } + for c, hit := range want { + if !hit { + t.Fatalf("★ Categories() = %v,缺中间层 %q", cats, c) + } + } +} + +// TestSearchRanksRelevantAboveUnrelated:只问排序,不钉数值。 +// +// 分数全挤在 0.75~0.79 时 16 条判据全绿而检索结果完全无关 —— +// 所以判据必须断言「相关条目排在无关条目之前」。 +func TestSearchRanksRelevantAboveUnrelated(t *testing.T) { + s := newStore(t) + if err := s.Add("billing/invoice", "账单服务的发票重试策略与对账流程"); err != nil { + t.Fatal(err) + } + if err := s.Add("auth/session", "登录会话的过期时间与刷新令牌"); err != nil { + t.Fatal(err) + } + hits := s.Search("发票 重试 账单", 2) + if len(hits) == 0 { + t.Fatal("★ 检索无结果") + } + if hits[0].Name != "billing/invoice" { + t.Fatalf("★ 排第一的是 %q,期望 billing/invoice(检索判别力失效)", hits[0].Name) + } +} diff --git a/internal/memory/distill/test/blocks_contract_test.go b/internal/memory/distill/test/blocks_contract_test.go new file mode 100644 index 00000000..f49baf1b --- /dev/null +++ b/internal/memory/distill/test/blocks_contract_test.go @@ -0,0 +1,87 @@ +package test + +// internal/memory/distill 纯函数的黑盒判据:输入文本 → 输出。 +// 只调导出函数,不构造 Extractor 内部状态。 + +import ( + "testing" + + "github.com/JianFeeeee/HologramAgent/internal/memory/distill" +) + +// TestBlockTextRoundTrip:ToMemoryBlock 产出的文本能被 +// BlockSubject / Dimension / Value 完整还原。 +func TestBlockTextRoundTrip(t *testing.T) { + fb := distill.FieldBlock{Subject: "billing服务", Dimension: "端口", Value: "8499"} + blk := fb.ToMemoryBlock("billing服务端口8499") + if got := distill.BlockSubject(blk.Text); got != "billing服务" { + t.Fatalf("★ BlockSubject = %q,期望 billing服务", got) + } + if got := distill.Dimension(blk.Text); got != "端口" { + t.Fatalf("★ Dimension = %q,期望 端口", got) + } + if got := distill.Value(blk.Text); got != "8499" { + t.Fatalf("★ Value = %q,期望 8499", got) + } +} + +// TestBlockIDDependsOnSubject:同句同维度同值、不同主语必须得到不同 ID。 +// +// 真实缺陷:subject 不参与 ID 派生时,三个服务各自的「端口=8861」 +// 会撞成同一个块 ⇒ 静默数据丢失(后者覆盖前者,无任何报错)。 +func TestBlockIDDependsOnSubject(t *testing.T) { + const sentence, dim, val = "各服务端口相同", "端口", "8861" + a := distill.BlockID(sentence, "admin服务", dim, val) + b := distill.BlockID(sentence, "billing服务", dim, val) + if a == b { + t.Fatal("★ 不同主语得到同一个 BlockID —— 记忆会互相覆盖") + } +} + +// TestBlockIDIsDeterministic:同输入同输出(缓存/去重的前提)。 +func TestBlockIDIsDeterministic(t *testing.T) { + x := distill.BlockID("s", "subj", "dim", "v") + for i := 0; i < 5; i++ { + if y := distill.BlockID("s", "subj", "dim", "v"); y != x { + t.Fatalf("★ 第 %d 次得到 %q,期望 %q", i, y, x) + } + } +} + +// TestDimensionValueEdgeCases:无 '=' / 空维度 / 多 '='。 +func TestDimensionValueEdgeCases(t *testing.T) { + cases := []struct{ in, dim, val string }{ + {"端口=8499", "端口", "8499"}, + {"没有等号", "", "没有等号"}, + {"=空维度", "", "空维度"}, + {"a=b=c", "a", "b=c"}, + } + for _, c := range cases { + if got := distill.Dimension(c.in); got != c.dim { + t.Errorf("★ Dimension(%q)=%q,期望 %q", c.in, got, c.dim) + } + if got := distill.Value(c.in); got != c.val { + t.Errorf("★ Value(%q)=%q,期望 %q", c.in, got, c.val) + } + } +} + +// TestSentenceBlockIDIsStable:原句块 ID 由文本决定,跨调用稳定。 +func TestSentenceBlockIDIsStable(t *testing.T) { + const s = "用户偏好在晚上处理构建任务" + a := distill.SentenceBlockID(s) + b := distill.SentenceBlockID(s) + if a == "" || a != b { + t.Fatalf("★ SentenceBlockID 不稳定:%q vs %q", a, b) + } +} + +// TestNormalizeDimensionIsIdempotent:归一两次结果不变。 +func TestNormalizeDimensionIsIdempotent(t *testing.T) { + for _, in := range []string{"端口", " 端 口 ", "Port", "配置端口"} { + once := distill.NormalizeDimension(in) + if twice := distill.NormalizeDimension(once); twice != once { + t.Fatalf("★ NormalizeDimension(%q) 非幂等:%q → %q", in, once, twice) + } + } +}