Files
HomeAgent/internal/config/test/registry_contract_test.go
JianFeeeee 3384cee62a 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 诊断
2026-11-09 11:57:48 +08:00

134 lines
4.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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