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:
JianFeeeee
2026-11-04 20:41:43 +08:00
parent b8ec1e84d2
commit c01f4d6350
2 changed files with 407 additions and 0 deletions

View 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 为空 —— 下游靠它判断缓存是否还有效")
}
}