test: 按模块导出接口重写黑盒判据(批次 1:config/knowledge/distill/agent-api)

## 方向

「面向模块输入接受模块输出」:只调模块**本来就有**的导出接口,
输入 → 断言可观察输出。不从旧白盒里抽、不读源码、不引用文档、
不构造内部结构、不为测试导出任何新符号。

4 个文件全部放 `<module>/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 诊断
This commit is contained in:
JianFeeeee
2026-11-09 11:57:48 +08:00
parent 110db1bfee
commit 3384cee62a
4 changed files with 463 additions and 0 deletions

View File

@ -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", "<nil>", 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)
}
}

View File

@ -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("★ 磁盘上是明文 —— 敏感项没有加密")
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}
}