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 诊断
139 lines
4.9 KiB
Go
139 lines
4.9 KiB
Go
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)
|
||
}
|
||
}
|