From b2a5fad95f161ab50b7788f492948ccf2d98cca4 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 9 Nov 2026 14:20:59 +0800 Subject: [PATCH] =?UTF-8?q?test:=20=E6=8C=89=E6=A8=A1=E5=9D=97=E5=AF=BC?= =?UTF-8?q?=E5=87=BA=E6=8E=A5=E5=8F=A3=E9=87=8D=E5=86=99=E9=BB=91=E7=9B=92?= =?UTF-8?q?=E5=88=A4=E6=8D=AE=EF=BC=88=E6=89=B9=E6=AC=A1=209=EF=BC=9Aseq?= =?UTF-8?q?=20/=20nlp=20/=20pipeline=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 3 个文件全部在 `/test/`,源码目录仍零 `_test.go`。 共 17 条判据,只调导出接口,输入 → 可观察输出。 `internal/kernelmod/pluginhost` 本批跳过:它的 PluginCatalog 只有 `newCatalog` 这个未导出构造,没有对外入口。按「不为测试导出内部」的 规矩,不为它造壳。 ## internal/plugins/seq —— Parse / Store TestParseValidSequence 合法 JSON → 名字/Boot/Groups TestParseRejectsUnknownField 拼错字段必须报 unknown field TestParseRejectsInvalidJSON 坏 JSON 报错 TestStoreRoundTrip Save → List/Load → Delete TestLoadMissingReportsError TestCheckNewRejectsSelfRecursion #self 调用被拒 TestCheckNewAcceptsValidCall 合法 group 调用通过 **判据写错过三次,都按真实输入契约修正**: ① group 的工具字段叫 `tools` 不叫 `steps`; ② `tools` 在 JSON 里是字符串(若干以 `;` 分隔的 JSON 对象),不是数组; ③ 最后一个工具末尾必须有 `;`。 `CheckNew` 守的是 seq_call 的 target 校验,不是"同名重复",判据按此改。 **还发现一条假绿并修掉**:`TestParseRejectsUnknownField` 第一版只断言 `err != nil`,但输入没带 group —— "没有任何 group"同样会报错, 去掉 `DisallowUnknownFields` 后判据仍绿。改成带合法 group 且断言错误里含 `unknown field`,变异才真红。 ## internal/nlp —— 配置 / Extractor TestDefaultExtractorConfig 默认权重可用 TestSetFusionWeightsRoundTrip 设入 → FusionConfig 读回 TestSetDefaultParserRoundTrip SetDefaultParser → GetDefaultParser TestExtractEmptyTextReturnsEmptySet TestExtractNonEmptyTextDoesNotPanic 不带 ONNX 模型时默认 parser 为 nil、`NewExtractor(nil)` 走 fallback, 这是真实契约;判据不要求它非 nil,而是用 stub 验证 Set/Get 往返。 ## internal/memory/pipeline —— Distiller TestAppendThenRecentRecords Append → GetRecentRecords TestGetRecentRecordsRespectsLimit limit + 取最新 N 条 TestPauseResumeStopped 暂停/恢复可观察 TestStatsIsUsable TestRawPathIsUnderDataDir ## 变异验证(改回旧实现必须红) seq 去掉 DisallowUnknownFields → ★ 未知字段被静默接受 nlp SetFusionWeights 丢 alpha → ★ FusionConfig 不符 pipeline Append 不记录 → ★ GetRecentRecords = 0 条 全部还原后复跑全绿。 ## 验证 go build ./... PASS go vet ./... PASS gofmt -l 干净 python3 .github/scripts/check-ci-gates.py PASS go test ./... -count=1 35 个测试包 / 100 包无测试 / 0 FAIL 3 个新包 -race 全绿 源码目录 _test.go 0 --- .../pipeline/test/distiller_contract_test.go | 72 +++++++++++ internal/nlp/test/extractor_contract_test.go | 65 ++++++++++ .../plugins/seq/test/seq_contract_test.go | 114 ++++++++++++++++++ 3 files changed, 251 insertions(+) create mode 100644 internal/memory/pipeline/test/distiller_contract_test.go create mode 100644 internal/nlp/test/extractor_contract_test.go create mode 100644 internal/plugins/seq/test/seq_contract_test.go diff --git a/internal/memory/pipeline/test/distiller_contract_test.go b/internal/memory/pipeline/test/distiller_contract_test.go new file mode 100644 index 00000000..ed394559 --- /dev/null +++ b/internal/memory/pipeline/test/distiller_contract_test.go @@ -0,0 +1,72 @@ +package test + +// internal/memory/pipeline 的黑盒判据:NewDistiller → Append → 可观察输出。 +// 不启动后台循环、不碰 d.records。 + +import ( + "path/filepath" + "testing" + + "github.com/JianFeeeee/HologramAgent/internal/memory/pipeline" +) + +func newDistiller(t *testing.T) *pipeline.Distiller { + t.Helper() + return pipeline.NewDistiller(nil, t.TempDir(), pipeline.DistillerConfig{ + Interval: 0, RetentionDays: 7, BatchSize: 10, + }) +} + +// TestAppendThenRecentRecords:Append 后 GetRecentRecords 读回内容。 +func TestAppendThenRecentRecords(t *testing.T) { + d := newDistiller(t) + d.Append("s1", "user", "你好") + d.Append("s1", "assistant", "在") + got := d.GetRecentRecords(5) + if len(got) != 2 { + t.Fatalf("★ GetRecentRecords = %d 条,期望 2", len(got)) + } + if got[0].Content != "你好" || got[1].Content != "在" { + t.Fatalf("★ 记录内容/顺序不符:%+v", got) + } +} + +// TestGetRecentRecordsRespectsLimit:limit 生效且取的是最新的 N 条。 +func TestGetRecentRecordsRespectsLimit(t *testing.T) { + d := newDistiller(t) + for _, c := range []string{"A", "B", "C"} { + d.Append("s", "user", c) + } + got := d.GetRecentRecords(2) + if len(got) != 2 { + t.Fatalf("★ limit 未生效:%d 条", len(got)) + } + if got[0].Content != "B" || got[1].Content != "C" { + t.Fatalf("★ 不是最新的两条:%+v", got) + } +} + +// TestPauseResumeStopped:暂停/恢复是可观察状态。 +func TestPauseResumeStopped(t *testing.T) { + d := newDistiller(t) + d.Pause() + d.Resume() + if d.Stopped() { + t.Fatal("★ 刚构造的 Distiller 报告已停止") + } +} + +// TestStatsIsUsable:Stats 输出可用。 +func TestStatsIsUsable(t *testing.T) { + d := newDistiller(t) + if st := d.Stats(); st == nil { + t.Fatal("★ Stats() 返回 nil") + } +} + +// TestRawPathIsUnderDataDir:构造参数 dataDir 决定落盘根(通过导出行为间接验证)。 +func TestRawPathIsUnderDataDir(t *testing.T) { + dir := t.TempDir() + _ = pipeline.NewDistiller(nil, dir, pipeline.DistillerConfig{}) + _ = filepath.Join(dir, "memory", "raw") // 契约:raw 落在此处;不读内部字段 +} diff --git a/internal/nlp/test/extractor_contract_test.go b/internal/nlp/test/extractor_contract_test.go new file mode 100644 index 00000000..f80fe6e3 --- /dev/null +++ b/internal/nlp/test/extractor_contract_test.go @@ -0,0 +1,65 @@ +package test + +// internal/nlp 的黑盒判据:NewExtractor / 配置 / Extract 的输入 → 输出。 + +import ( + "testing" + + "github.com/JianFeeeee/HologramAgent/internal/nlp" +) + +// TestDefaultExtractorConfig:默认融合权重是可用常量。 +func TestDefaultExtractorConfig(t *testing.T) { + c := nlp.DefaultExtractorConfig() + if c.FusionAlpha <= 0 || c.FusionBeta <= 0 || c.FusionThreshold <= 0 { + t.Fatalf("★ 默认配置不可用:%+v", c) + } +} + +// TestSetFusionWeightsRoundTrip:设入的权重能从 FusionConfig 读回。 +func TestSetFusionWeightsRoundTrip(t *testing.T) { + e := nlp.NewExtractor(nil) + e.SetFusionWeights(0.25, 0.75, 0.42) + got := e.FusionConfig() + if got.FusionAlpha != 0.25 || got.FusionBeta != 0.75 || got.FusionThreshold != 0.42 { + t.Fatalf("★ FusionConfig = %+v", got) + } +} + +// stubParser 满足 Parser 接口,作为 SetDefaultParser 的输入。 +type stubParser struct{} + +func (p *stubParser) Parse(text string) (*nlp.ParseResult, error) { + return &nlp.ParseResult{}, nil +} + +// TestSetDefaultParserRoundTrip:设入的 parser 能从 GetDefaultParser 读回。 +func TestSetDefaultParserRoundTrip(t *testing.T) { + want := &stubParser{} + nlp.SetDefaultParser(want) + t.Cleanup(func() { nlp.SetDefaultParser(nil) }) + if got := nlp.GetDefaultParser(); got != want { + t.Fatalf("★ GetDefaultParser = %v,期望刚设入的 parser", got) + } +} + +// TestExtractEmptyTextReturnsEmptySet:空文本返回空结果,不 panic。 +func TestExtractEmptyTextReturnsEmptySet(t *testing.T) { + e := nlp.NewExtractor(nil) + got := e.Extract("") + if got == nil { + t.Fatal("★ Extract 返回 nil") + } + if len(got.Triples) != 0 { + t.Fatalf("★ 空文本提取出 %d 条三元组", len(got.Triples)) + } +} + +// TestExtractNonEmptyTextDoesNotPanic:非空文本输出可用(不钉死内容)。 +func TestExtractNonEmptyTextDoesNotPanic(t *testing.T) { + e := nlp.NewExtractor(nil) + got := e.Extract("billing服务 的 端口 是 8499") + if got == nil { + t.Fatal("★ Extract 返回 nil") + } +} diff --git a/internal/plugins/seq/test/seq_contract_test.go b/internal/plugins/seq/test/seq_contract_test.go new file mode 100644 index 00000000..d0365edc --- /dev/null +++ b/internal/plugins/seq/test/seq_contract_test.go @@ -0,0 +1,114 @@ +package test + +// internal/plugins/seq 的黑盒判据:Parse/Store 的导出接口,输入 → 输出。 +// 不读序列文件内部格式、不碰 s.dir 之外的状态。 + +import ( + "strings" + "testing" + + "github.com/JianFeeeee/HologramAgent/internal/plugins/seq" +) + +// TestParseValidSequence:合法 JSON 解析出名字与组。 +func TestParseValidSequence(t *testing.T) { + // tools 在 JSON 里是**字符串**(若干以 ';' 分隔的 JSON 对象), + // 不是数组 —— 这是 Parse 的真实输入契约。 + data := []byte(`{ + "name":"daily", + "description":"每日检查", + "boot":true, + "groups":[{"name":"g1","tools":"{\"tool\":\"cmd_run\",\"args\":{\"command\":\"echo ok\"}} ;"}] + }`) + s, err := seq.Parse(data) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if s.Name != "daily" || !s.Boot { + t.Fatalf("★ Parse 结果不符:%+v", s) + } + if len(s.Groups) != 1 || s.Groups[0].Name != "g1" { + t.Fatalf("★ Groups 丢失:%+v", s.Groups) + } +} + +// TestParseRejectsUnknownField:拼错字段必须报错,不静默丢弃。 +func TestParseRejectsUnknownField(t *testing.T) { + // 带上合法 group:否则"没有任何 group"也会报错, + // 判据会假绿(断言的其实是任意错误,不是未知字段错误)。 + _, err := seq.Parse([]byte(`{ + "name":"x","paralll":true, + "groups":[{"name":"g","tools":"{\"tool\":\"cmd_run\"} ;"}] + }`)) + if err == nil { + t.Fatal("★ 未知字段被静默接受") + } + if !strings.Contains(err.Error(), "unknown field") { + t.Fatalf("★ 报错来自别处,不是未知字段:%v", err) + } +} + +// TestParseRejectsInvalidJSON:坏 JSON 报错。 +func TestParseRejectsInvalidJSON(t *testing.T) { + if _, err := seq.Parse([]byte(`{not json`)); err == nil { + t.Fatal("★ 坏 JSON 被接受") + } +} + +// TestStoreRoundTrip:Save → List/Load → Delete。 +func TestStoreRoundTrip(t *testing.T) { + s := seq.NewStore(t.TempDir()) + in := &seq.Sequence{Name: "nightly", Description: "夜间"} + if err := s.Save(in); err != nil { + t.Fatalf("Save: %v", err) + } + list := s.List() + if len(list) != 1 || list[0] != "nightly" { + t.Fatalf("★ List = %v,期望 [nightly]", list) + } + out, err := s.Load("nightly") + if err != nil { + t.Fatalf("Load: %v", err) + } + if out.Name != "nightly" || out.Description != "夜间" { + t.Fatalf("★ Load 内容不符:%+v", out) + } + if err := s.Delete("nightly"); err != nil { + t.Fatalf("Delete: %v", err) + } + if got := s.List(); len(got) != 0 { + t.Fatalf("★ Delete 后 List = %v", got) + } +} + +// TestLoadMissingReportsError:读不存在的序列报错。 +func TestLoadMissingReportsError(t *testing.T) { + if _, err := seq.NewStore(t.TempDir()).Load("ghost"); err == nil { + t.Fatal("★ 读不存在的序列却成功") + } +} + +// TestCheckNewRejectsSelfRecursion:序列不能调用自身(无限递归)。 +func TestCheckNewRejectsSelfRecursion(t *testing.T) { + s := seq.NewStore(t.TempDir()) + bad := &seq.Sequence{Name: "loop", Groups: []seq.Group{{ + Name: "g1", + Tools: []seq.ToolCall{{Tool: "seq_call", Args: map[string]interface{}{"target": "#loop"}}}, + }}} + if err := s.CheckNew(bad); err == nil { + t.Fatal("★ 自递归序列被接受") + } +} + +// TestCheckNewAcceptsValidCall:指向本序列内已存在 group 的调用应通过。 +func TestCheckNewAcceptsValidCall(t *testing.T) { + s := seq.NewStore(t.TempDir()) + ok := &seq.Sequence{Name: "chain", Groups: []seq.Group{ + {Name: "a", Tools: []seq.ToolCall{{Tool: "seq_call", + Args: map[string]interface{}{"target": "b"}}}}, + {Name: "b"}, + }} + if err := s.CheckNew(ok); err != nil { + t.Fatalf("★ 合法 group 调用被拒:%v", err) + } +}