mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-12 12:17:36 +00:00
## 方向
「面向模块输入接受模块输出」:只调模块**本来就有**的导出接口,
输入 → 断言可观察输出。不从旧白盒里抽、不读源码、不引用文档、
不构造内部结构、不为测试导出任何新符号。
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 诊断
134 lines
4.0 KiB
Go
134 lines
4.0 KiB
Go
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("★ 磁盘上是明文 —— 敏感项没有加密")
|
||
}
|
||
}
|