test: 按模块导出接口重写黑盒判据(批次 9:seq / nlp / pipeline)

3 个文件全部在 `<module>/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
This commit is contained in:
JianFeeeee
2026-11-09 14:20:59 +08:00
parent ac525670d1
commit b2a5fad95f
3 changed files with 251 additions and 0 deletions

View File

@ -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 落在此处;不读内部字段
}

View File

@ -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")
}
}

View File

@ -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)
}
}