test: 把 helper/getter 判据改写为模块职责级黑盒契约

四个模块从「导出符号调用」改为「模块职责 → 可观察结果」。

## internal/memory

    TestCommitThenRecallAfterReopen
      Commit → Close → 重开同一库 → Recall 核对主/谓/宾
      (原来只检查 GraphData 返回 map 里有 nodes 键)
    变异:Recall 恒空 → 断言红

## internal/memory/pipeline

    TestAppendThenDistillWritesQueryableFact
      Append → Start → 注入 splitter → 图库 Recall 命中
    TestRawRecordsPersistAcrossRestart
      Append → Stop → 新实例 Start → 回读原始记录
    TestSplitterFailureDoesNotFabricateFacts
      拆分失败必须回退,不得编出事实
    TestPauseResumeAndStopAreObservable
    变异:跳过蒸馏/跳过落盘 → 均断言红

## internal/nlp

    真实 Parser → 三元组 + 句子溯源 + 分值
    融合阈值改变取舍(高阈值空、低阈值还原)
    SetDefaultParser 影响 NewExtractor(nil)
    变异:丢弃 dep 抽取 / 取消阈值截断 → 均断言红

## internal/config

    新增 TestSecretSurvivesRestartAndStaysEncrypted
      敏感项 Set → 磁盘无明文 → Close → 重开 → GetSecret 还原明文
    变异:GetSecret 不解密 / Set 不加密落盘 → 均断言红
    删除了一条不成立的断言(普通 Get 本应返回明文),按真实契约收紧。

## 验证

    go build ./...                            PASS
    go vet ./...                              PASS
    gofmt -l                                  干净
    python3 .github/scripts/check-ci-gates.py PASS
    go test ./... -count=1                    51 测试包 / 100 包无测试 / 0 FAIL
    4 个改动包 -race                          全绿
    源码目录 _test.go                         0
This commit is contained in:
JianFeeeee
2026-11-14 10:03:52 +08:00
parent 41926e770d
commit a7e8f55085
4 changed files with 269 additions and 133 deletions

View File

@ -4,6 +4,8 @@ package test
// 输入 → 输出。不读源码、不碰 registry 内部结构。
import (
"os"
"path/filepath"
"testing"
"github.com/JianFeeeee/HologramAgent/internal/config"
@ -106,28 +108,60 @@ func TestDefRoundTripPreservesSecret(t *testing.T) {
}
}
// TestSecretValueIsNotPlaintextOnDisk:SetSecret → GetSecret 往返,且 DB 里无明文。
func TestSecretValueIsNotPlaintextOnDisk(t *testing.T) {
reg := newRegistry(t)
reg.SetCodec(config.NewTestCodec(t))
p := reg.PluginConfig("alpha")
// TestSecretSurvivesRestartAndStaysEncrypted 覆盖配置模块的完整职责:
// 敏感项写入 → 磁盘上无明文 → 关闭并重新打开同一数据库后仍可正确解密读回。
// 只用公开 API 观察,不查询内部表结构。
func TestSecretSurvivesRestartAndStaysEncrypted(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.db")
first := config.NewConfigRegistry(path)
first.SetCodec(config.NewTestCodec(t))
p := first.PluginConfig("alpha")
p.RegisterDef(config.ConfigDef{Key: "api_key", Secret: true})
if err := p.Set("api_key", "PLAINTEXT-SECRET-123"); err != nil {
t.Fatal(err)
const secret = "PLAINTEXT-SECRET-123"
if err := p.Set("api_key", secret); err != nil {
first.Close()
t.Fatalf("Set: %v", err)
}
got, err := p.GetSecret("api_key")
if got, err := p.GetSecret("api_key"); err != nil || got != secret {
first.Close()
t.Fatalf("GetSecret before restart = %q / %v", got, err)
}
if err := first.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
// 落盘文件里不得出现明文。
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("GetSecret: %v", err)
}
if got != "PLAINTEXT-SECRET-123" {
t.Fatalf("★ 解密后 = %q,期望原文", got)
}
var raw string
if err := reg.DB().QueryRow(
"SELECT value FROM config_alpha WHERE key='api_key'").Scan(&raw); err != nil {
t.Fatal(err)
}
if raw == "PLAINTEXT-SECRET-123" {
t.Fatal("★ 磁盘上是明文 —— 敏感项没有加密")
if string(raw) == secret || containsBytes(raw, []byte(secret)) {
t.Fatalf("secret written in plaintext on disk")
}
second := config.NewConfigRegistry(path)
defer second.Close()
second.SetCodec(config.NewTestCodec(t))
q := second.PluginConfig("alpha")
if got, err := q.GetSecret("api_key"); err != nil || got != secret {
t.Fatalf("GetSecret after restart = %q / %v", got, err)
}
}
func containsBytes(haystack, needle []byte) bool {
for i := 0; i+len(needle) <= len(haystack); i++ {
match := true
for j := range needle {
if haystack[i+j] != needle[j] {
match = false
break
}
}
if match {
return true
}
}
return false
}

View File

@ -1,72 +1,155 @@
package test
// internal/memory/pipeline 的黑盒判据:NewDistiller → Append → 可观察输出。
// 不启动后台循环、不碰 d.records。
// internal/memory/pipeline 的模块级黑盒判据:Append 原始记录 → 蒸馏批次
// 经公开 SetSplitter/Start/Stop → 图记忆可查询到的三元组,以及原始记录
// 落盘/重启回读与暂停语义。不碰 d.records 等私有字段。
import (
"context"
"path/filepath"
"sync/atomic"
"testing"
"time"
"github.com/JianFeeeee/HologramAgent/internal/memory"
"github.com/JianFeeeee/HologramAgent/internal/memory/pipeline"
)
func newDistiller(t *testing.T) *pipeline.Distiller {
type spySplitter struct{ calls atomic.Int32 }
func (s *spySplitter) Split(_ context.Context, record string) ([]memory.Triple, error) {
s.calls.Add(1)
return []memory.Triple{{
Subject: "billing服务",
Relation: "端口",
Object: "8499",
SentenceText: record,
}}, nil
}
type failSplitter struct{}
func (failSplitter) Split(context.Context, string) ([]memory.Triple, error) {
return nil, context.DeadlineExceeded
}
func newHarness(t *testing.T) (*pipeline.Distiller, *memory.GraphDB, *spySplitter) {
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 落在此处;不读内部字段
g, err := memory.NewGraphDB(filepath.Join(dir, "graph.db"))
if err != nil {
t.Fatalf("NewGraphDB: %v", err)
}
t.Cleanup(func() { _ = g.Close() })
spy := &spySplitter{}
d := pipeline.NewDistiller(g, dir, pipeline.DistillerConfig{
Interval: 20 * time.Millisecond, RetentionDays: 7, BatchSize: 10,
})
d.SetSplitter(spy)
return d, g, spy
}
func assertFactRecalled(t *testing.T, g *memory.GraphDB) {
t.Helper()
got, err := g.Recall([]string{"billing服务"}, nil, 2, "")
if err != nil {
t.Fatalf("Recall: %v", err)
}
for _, r := range got.Relations {
if r.SourceName == "billing服务" && r.RelationType == "端口" && r.TargetName == "8499" {
return
}
}
t.Fatalf("distilled fact not queryable: %+v", got)
}
func TestAppendThenDistillWritesQueryableFact(t *testing.T) {
d, g, spy := newHarness(t)
d.Append("s1", "user", "billing服务端口是8499")
d.Start()
t.Cleanup(d.Stop)
deadline := time.Now().Add(10 * time.Second)
for time.Now().Before(deadline) {
if spy.calls.Load() > 0 {
assertFactRecalled(t, g)
return
}
time.Sleep(20 * time.Millisecond)
}
t.Fatal("distiller never invoked the injected splitter")
}
func TestRawRecordsPersistAcrossRestart(t *testing.T) {
dir := t.TempDir()
cfg := pipeline.DistillerConfig{Interval: time.Hour, RetentionDays: 7, BatchSize: 10}
first := pipeline.NewDistiller(nil, dir, cfg)
first.Append("s1", "user", "第一条原始记录")
first.Start()
first.Stop()
second := pipeline.NewDistiller(nil, dir, cfg)
second.Start()
defer second.Stop()
// Start 载入磁盘上的原始记录后,未蒸馏记录必须可读到。
got := second.GetRecentRecords(10)
second.Stop()
found := false
for _, r := range got {
if r.Content == "第一条原始记录" && r.Role == "user" && r.SessionID == "s1" {
found = true
}
}
if !found {
t.Fatalf("raw record not restored after restart: %+v", got)
}
}
func TestSplitterFailureDoesNotFabricateFacts(t *testing.T) {
dir := t.TempDir()
g, err := memory.NewGraphDB(filepath.Join(dir, "graph.db"))
if err != nil {
t.Fatal(err)
}
defer g.Close()
d := pipeline.NewDistiller(g, dir, pipeline.DistillerConfig{
Interval: 20 * time.Millisecond, RetentionDays: 7, BatchSize: 10,
})
d.SetSplitter(failSplitter{})
d.Append("s1", "user", "会失败的记录")
d.Start()
time.Sleep(200 * time.Millisecond)
d.Stop()
// 拆分器失败时必须回退而不是伪造事实:图记忆里不能出现这条记录编出来的关系。
got, err := g.Recall([]string{"会失败的记录"}, nil, 2, "")
if err != nil {
t.Fatalf("Recall: %v", err)
}
for _, r := range got.Relations {
t.Fatalf("failed batch fabricated a fact: %+v", r)
}
}
func TestPauseResumeAndStopAreObservable(t *testing.T) {
d, _, _ := newHarness(t)
d.Append("s1", "user", "record")
d.Start()
if err := d.Pause(); err != nil {
t.Fatalf("Pause: %v", err)
}
if err := d.Pause(); err != nil {
t.Fatalf("Pause must be idempotent: %v", err)
}
if err := d.Resume(); err != nil {
t.Fatalf("Resume: %v", err)
}
if d.Stopped() {
t.Fatal("reported stopped before Stop")
}
d.Stop()
if !d.Stopped() {
t.Fatal("Stop did not report stopped immediately")
}
}

View File

@ -110,27 +110,40 @@ func newDB(t *testing.T) *memory.GraphDB {
return g
}
// TestCommitThenGraphData:Commit 一条三元组后,GraphData 必须能反映它。
func TestCommitThenGraphData(t *testing.T) {
g := newDB(t)
// TestCommitThenRecallAfterReopen verifies that a committed fact is durable and
// queryable through the memory module, rather than merely producing graph keys.
func TestCommitThenRecallAfterReopen(t *testing.T) {
path := filepath.Join(t.TempDir(), "graph.db")
g, err := memory.NewGraphDB(path)
if err != nil {
t.Fatal(err)
}
tri := memory.Triple{
Subject: "billing服务", Relation: "端口", Object: "8499",
SentenceText: "billing服务端口是8499",
}
if _, _, err := g.Commit([]memory.Triple{tri}, "contract-session", 1); err != nil {
_ = g.Close()
t.Fatalf("Commit: %v", err)
}
data, err := g.GraphData()
if err := g.Close(); err != nil {
t.Fatal(err)
}
reopened, err := memory.NewGraphDB(path)
if err != nil {
t.Fatalf("GraphData: %v", err)
t.Fatal(err)
}
if data == nil {
t.Fatal("★ GraphData 返回 nil")
defer reopened.Close()
result, err := reopened.Recall([]string{"billing服务"}, nil, 2, "")
if err != nil {
t.Fatal(err)
}
// 可观察输出:GraphData 必须给出 nodes/edges 结构(而非 panic / 空 map)
if _, ok := data["nodes"]; !ok {
t.Fatalf("★ GraphData 缺 nodes 字段:%v", data)
for _, relation := range result.Relations {
if relation.SourceName == tri.Subject && relation.RelationType == tri.Relation && relation.TargetName == tri.Object {
return
}
}
t.Fatalf("committed fact missing after reopen: %+v", result)
}
// TestReadOnlyHandleRejectsWrite:OpenGraphDBReadOnly 必须在 SQLite 层拒写。

View File

@ -1,6 +1,6 @@
package test
// internal/nlp 的黑盒判据:NewExtractor / 配置 / Extract 的输入 → 输出。
// internal/nlp 的模块级黑盒判据:文本输入 → 公开 Parser/Extractor → 三元组输出。
import (
"testing"
@ -8,58 +8,64 @@ import (
"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)
type contractParser struct{}
func (contractParser) Parse(string) (*nlp.ParseResult, error) {
// billing --SBV--> 监听 <--VOB-- 8499
return &nlp.ParseResult{
Tokens: []string{"billing", "监听", "8499"},
POS: []string{"n", "v", "n"},
Heads: []int{2, 0, 2},
DepRels: []string{"SBV", "ROOT", "VOB"},
}, nil
}
func TestExtractRunsParserAndReturnsFact(t *testing.T) {
e := nlp.NewExtractor(contractParser{})
got := e.Extract("billing监听8499")
if got == nil || got.Err != nil {
t.Fatalf("Extract failed: %+v", got)
}
if got.Src != "dep_parser" || len(got.Triples) != 1 {
t.Fatalf("module output = %+v", got)
}
tr := got.Triples[0]
if tr.Subject != "billing" || tr.Relation != "监听" || tr.Object != "8499" {
t.Fatalf("unexpected extracted fact: %+v", tr)
}
if tr.SentenceRef != "billing监听8499" || tr.Score <= 0 {
t.Fatalf("missing observable provenance/score: %+v", tr)
}
}
// TestSetFusionWeightsRoundTrip:设入的权重能从 FusionConfig 读回。
func TestSetFusionWeightsRoundTrip(t *testing.T) {
func TestFusionConfigurationChangesExtractionDecision(t *testing.T) {
e := nlp.NewExtractor(contractParser{})
e.SetFusionWeights(1, 0, 0.95)
if got := e.Extract("billing监听8499"); got == nil || len(got.Triples) != 0 {
t.Fatalf("high threshold should reject the parser candidate: %+v", got)
}
e.SetFusionWeights(1, 0, 0.1)
got := e.Extract("billing监听8499")
if got == nil || len(got.Triples) != 1 {
t.Fatalf("lower threshold should restore the fact: %+v", got)
}
}
func TestExtractorEmptyAndFallbackInput(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)
if got := e.Extract(""); got == nil || len(got.Triples) != 0 {
t.Fatalf("empty input should produce no facts: %+v", got)
}
if got := e.Extract("billing服务的端口是8499"); got == nil {
t.Fatal("fallback extraction returned nil")
}
}
// 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)
func TestDefaultParserInjectionChangesNewExtractorBehavior(t *testing.T) {
nlp.SetDefaultParser(contractParser{})
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")
got := nlp.NewExtractor(nil).Extract("billing监听8499")
if got == nil || got.Src != "dep_parser" || len(got.Triples) != 1 {
t.Fatalf("default parser was not used by NewExtractor(nil): %+v", got)
}
}