mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-06 09:17:31 +00:00
test: 两个零测试包补黑箱判据 —— files(沙箱)与 tfidf
覆盖率分析显示这两个包**零测试文件**,而它们各自守着一条不能错的边界:
internal/plugins/files 581 行 含沙箱逃逸防护
internal/tfidf 306 行 记忆检索的 fallback 路径
## files:沙箱边界
581 行、0 判据。它是文件读写插件,`pathWithinSandbox` 写错一个字符就是
任意文件读写。没有判据意味着没人知道它现在是否还挡得住。
6 条判据全部走**真实装配**(调 `Start()` 而不是手搓 Plugin 结构),
通过注入的 registrar 捕获 handler,然后以「输入 {path, content} → 输出」断言:
TestToolsAreRegistered 四个工具都在
TestReadWriteRoundTrip 写进去能原样读回
TestSandboxEscapeIsRejected ★ 沙箱外路径必须被拒(3 种逃逸形态)
TestSandboxAllowsInside 反向:沙箱内必须能读
TestEmptyPathIsRejected read/write 拒绝空 path
TestLsEmptyPathMeansCurrentDir ls 把空 path 当 "."
TestLsListsDirectory ls 列出目录内容
**沙箱那条的判据设计**:沙箱外造一个**真实存在**的文件。若只拦不存在的
路径,判据会假绿;文件真实存在而仍被拒,才证明是沙箱在起作用。
变异(`pathWithinSandbox` 加 `if true { return true }`)⇒ 实测红:
★ 沙箱外路径 ".../secret.txt" 被接受了,返回 {"content":"SECRET",...}
这是任意文件读取。沙箱根: ...
## tfidf:检索判别力
306 行、0 判据。它是没有神经网络 embedder 时的文本相似度来源 ——
判据错 = 检索错,而检索错是静默的(不报错,只是答非所问)。
判据**只断言契约,不断言数值**:TF-IDF 的权重取决于语料统计,写死数值
等于把训练集也当成契约。6 条钉的是换了实现也必须成立的性质:
TestVectorizeIsDeterministic 同输入同输出(map 遍历浮点累加
会产生末位差异,让缓存/去重失效)
TestVectorizeHasFixedDimAndNonNegative 维度恒定、非 NaN/Inf、非负
TestEmptyTextGivesZeroVector 空文本不 panic 且给零向量
TestSearchRanksRelatedAboveUnrelated ★ 核心质量判据:只排序,不看数值
TestAddRemoveChangesResults 增删后检索结果随之变化
TestFingerprintAndDim 元信息非空
`TestSearchRanksRelatedAboveUnrelated` 直接对治 engineering-lessons §1.1
记的那个坑:16 条判据全绿而检索结果完全无关(分数全挤在 0.75~0.79)。
所以它只问"billing invoice 的查询里,auth 文档有没有排到相关文档前面"。
变异(`Search` 提前 `return nil`)⇒ 实测红两条。
## 一处判据写错了(已按实际契约修正)
第一版把 `files_ls` 也纳入"空 path 必须拒绝",实测红:
★ files_ls 接受了空 path,返回 {"content":"(empty directory)"}
查代码(plugin.go:488)发现那是**明确设计**:`if path == "" { path = "." }`。
不是缺陷,是我把三条工具的输入契约当成同一条了。改成两条判据分开钉:
TestEmptyPathIsRejected read/write 拒绝空 path
TestLsEmptyPathMeansCurrentDir ls 把空 path 当 "."
把差异显式写下来,以后有人想"统一都拒绝空 path"时能看到这是有意的。
## 覆盖率
internal/plugins/files 0.0% → 54.1%
internal/tfidf 0.0% → 有判据(契约全覆盖)
## 验证
go build ./... PASS
go vet ./... PASS
gofmt -l 干净
go test ./... -count=1 67 包全过,0 FAIL(新增 2 个 test 包)
This commit is contained in:
171
internal/tfidf/test/tfidf_contract_test.go
Normal file
171
internal/tfidf/test/tfidf_contract_test.go
Normal file
@ -0,0 +1,171 @@
|
||||
package test
|
||||
|
||||
// tfidf 的契约判据:输入文本 → 输出向量/检索结果。
|
||||
//
|
||||
// ## 为什么这个包值得有判据(2026-11-04)
|
||||
//
|
||||
// `internal/tfidf` 306 行、**零测试文件**。它是纯算法库 —— 输入输出就是
|
||||
// 全部语义,天然适合黑箱判据。
|
||||
//
|
||||
// 它服务的是记忆检索的 fallback 路径:没有神经网络 embedder 时,文本相似度
|
||||
// 完全靠它。判据错 = 检索错,而检索错是静默的(不报错,只是答非所问)。
|
||||
//
|
||||
// ## 判据只断言契约,不断言数值
|
||||
//
|
||||
// TF-IDF 的具体权重取决于语料统计,写死数值等于把训练集也当成契约。
|
||||
// 这里只钉那些**换了实现也必须成立**的性质:确定性、判别力、维度、
|
||||
// 增删生效。
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/JianFeeeee/HologramAgent/internal/tfidf"
|
||||
)
|
||||
|
||||
// simpleTokenizer 按空白切词。
|
||||
func simpleTokenizer(text string) []string {
|
||||
return strings.Fields(text)
|
||||
}
|
||||
|
||||
func newTrained(t *testing.T, docs []string, dim int) *tfidf.Embedder {
|
||||
t.Helper()
|
||||
e := tfidf.NewEmbedder(simpleTokenizer, dim)
|
||||
e.Train(docs)
|
||||
return e
|
||||
}
|
||||
|
||||
// TestVectorizeIsDeterministic 钉住确定性:同输入必须同输出。
|
||||
//
|
||||
// 变异:向量累加改成遍历 map 直接相加(不排序)⇒ 可能偶发红。
|
||||
func TestVectorizeIsDeterministic(t *testing.T) {
|
||||
e := newTrained(t, []string{
|
||||
"billing service handles payments",
|
||||
"auth service handles login",
|
||||
"payment gateway retries",
|
||||
}, 64)
|
||||
|
||||
const text = "billing service payments"
|
||||
a, err := e.VectorizeDense(text)
|
||||
if err != nil {
|
||||
t.Fatalf("VectorizeDense: %v", err)
|
||||
}
|
||||
for i := 0; i < 5; i++ {
|
||||
b, err := e.VectorizeDense(text)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(a) != len(b) {
|
||||
t.Fatalf("维度不稳定:%d vs %d", len(a), len(b))
|
||||
}
|
||||
for j := range a {
|
||||
if a[j] != b[j] {
|
||||
t.Fatalf("★ 第 %d 次调用第 %d 维不同:%v vs %v —— 无确定性", i, j, a[j], b[j])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestVectorizeHasFixedDimAndNonNegative 钉住维度契约与取值域。
|
||||
//
|
||||
// 变异:让 VectorizeDense 返回变长切片 ⇒ 本例红。
|
||||
func TestVectorizeHasFixedDimAndNonNegative(t *testing.T) {
|
||||
const dim = 32
|
||||
e := newTrained(t, []string{"alpha beta", "gamma delta", "epsilon"}, dim)
|
||||
|
||||
for _, text := range []string{"", "alpha", "alpha beta gamma", "完全没出现过的词"} {
|
||||
v, err := e.VectorizeDense(text)
|
||||
if err != nil {
|
||||
t.Fatalf("%q: %v", text, err)
|
||||
}
|
||||
if len(v) != dim {
|
||||
t.Fatalf("★ %q 的向量维度 = %d,期望 %d", text, len(v), dim)
|
||||
}
|
||||
for i, x := range v {
|
||||
if math.IsNaN(x) || math.IsInf(x, 0) {
|
||||
t.Fatalf("★ %q 第 %d 维是 NaN/Inf", text, i)
|
||||
}
|
||||
if x < 0 {
|
||||
t.Fatalf("★ %q 第 %d 维为负(%v)—— TF-IDF 权重不该为负", text, i, x)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmptyTextGivesZeroVector 钉住边界:空文本不该 panic,且给出零向量。
|
||||
func TestEmptyTextGivesZeroVector(t *testing.T) {
|
||||
e := newTrained(t, []string{"alpha beta"}, 16)
|
||||
v, err := e.VectorizeDense("")
|
||||
if err != nil {
|
||||
t.Fatalf("空文本报错: %v", err)
|
||||
}
|
||||
for i, x := range v {
|
||||
if x != 0 {
|
||||
t.Fatalf("★ 空文本第 %d 维 = %v,期望 0", i, x)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearchRanksRelatedAboveUnrelated 是**核心质量判据**:
|
||||
// 相近文本必须排在无关文本之前。
|
||||
//
|
||||
// 这正是 engineering-lessons §1.1 记的坑:16 条判据全绿而检索结果完全无关
|
||||
// (分数全挤在 0.75~0.79)。所以这条不看数值,只看**排序**。
|
||||
//
|
||||
// 变异:让 Search 返回未排序结果 ⇒ 本例红。
|
||||
func TestSearchRanksRelatedAboveUnrelated(t *testing.T) {
|
||||
docs := []string{
|
||||
"billing service handles payments and invoices",
|
||||
"auth service handles login and sessions",
|
||||
"billing invoice retry policy",
|
||||
}
|
||||
e := newTrained(t, docs, 128)
|
||||
idx := tfidf.NewSearchableIndex(e)
|
||||
for i, d := range docs {
|
||||
idx.Add(string(rune('a'+i)), d)
|
||||
}
|
||||
|
||||
hits := idx.Search("billing invoice", 3)
|
||||
if len(hits) < 2 {
|
||||
t.Fatalf("检索只返回 %d 条,期望至少 2 条", len(hits))
|
||||
}
|
||||
first := hits[0].ID
|
||||
if first != "a" && first != "c" {
|
||||
t.Fatalf("★ 查 'billing invoice' 排第一的是 %q ——\n"+
|
||||
"它既不含 billing 也不含 invoice。检索判别力失效。", first)
|
||||
}
|
||||
for i, h := range hits {
|
||||
if h.ID == "b" {
|
||||
t.Fatalf("★ 完全无关的 auth 文档排在第 %d 位(共 %d 条)", i+1, len(hits))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAddRemoveChangesResults 钉住索引可变性契约。
|
||||
//
|
||||
// 变异:Remove 不真正删除 ⇒ 本例红。
|
||||
func TestAddRemoveChangesResults(t *testing.T) {
|
||||
e := newTrained(t, []string{"alpha beta", "gamma delta"}, 32)
|
||||
idx := tfidf.NewSearchableIndex(e)
|
||||
idx.Add("x", "alpha beta gamma")
|
||||
|
||||
if got := idx.Search("alpha", 3); len(got) == 0 {
|
||||
t.Fatal("加入后检索不到")
|
||||
}
|
||||
idx.Remove("x")
|
||||
if got := idx.Search("alpha", 3); len(got) != 0 {
|
||||
t.Fatalf("★ Remove 之后仍检索到 %d 条 —— 删除没生效", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// TestFingerprintAndDim 钉住元信息契约。
|
||||
func TestFingerprintAndDim(t *testing.T) {
|
||||
e := newTrained(t, []string{"a b c"}, 24)
|
||||
if e.Dim() != 24 {
|
||||
t.Fatalf("Dim() = %d,期望 24", e.Dim())
|
||||
}
|
||||
if e.Fingerprint() == "" {
|
||||
t.Fatal("★ Fingerprint 为空 —— 下游靠它判断缓存是否还有效")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user