Files
HomeAgent/internal/memory/test/memory_contract_test.go
JianFeeeee a7e8f55085 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
2026-11-14 10:04:44 +08:00

174 lines
5.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package test
// internal/memory 的黑盒判据:只用导出函数/方法,输入 → 可观察输出。
// 不读源码、不碰 GraphDB 内部字段、不用 ForTest 钩子。
import (
"path/filepath"
"testing"
"time"
"github.com/JianFeeeee/HologramAgent/internal/memory"
)
// ─────────── 纯函数:输入文本 → 输出 ───────────
func TestCleanText(t *testing.T) {
cases := []struct{ in, want string }{
{" 你好 ", "你好"},
{",你好", "你好"},
{",,你好", "你好"}, // 连续剥两次前缀
{"", ""},
{" ", ""},
}
for _, c := range cases {
if got := memory.CleanText(c.in); got != c.want {
t.Errorf("★ CleanText(%q)=%q,期望 %q", c.in, got, c.want)
}
}
}
func TestChannelScene(t *testing.T) {
cases := []struct{ in, want string }{
{"qq", "chan:qq"},
{"webui", "chan:webui"},
{"", ""},
}
for _, c := range cases {
if got := memory.ChannelScene(c.in); got != c.want {
t.Errorf("★ ChannelScene(%q)=%q,期望 %q", c.in, got, c.want)
}
}
}
// TestFilterNoiseTriples:噪音实体在主语或宾语位置都要被过滤掉。
func TestFilterNoiseTriples(t *testing.T) {
good := memory.Triple{Subject: "billing服务", Relation: "端口", Object: "8499"}
badSub := memory.Triple{Subject: "", Relation: "是", Object: "X"}
badObj := memory.Triple{Subject: "X", Relation: "是", Object: "这个"}
in := []memory.Triple{good, badSub, badObj}
out := memory.FilterNoiseTriples(in)
if len(out) != 1 || out[0].Subject != "billing服务" {
t.Fatalf("★ FilterNoiseTriples = %+v,期望只留 billing 那条", out)
}
if len(in) != 3 {
t.Fatal("★ 过滤修改了输入切片")
}
}
// TestSentenceBlockIDIsStable:同文本得同一 ID。
func TestSentenceBlockIDIsStable(t *testing.T) {
const s = "用户偏好在晚上处理构建任务"
a := memory.SentenceBlockID(s)
b := memory.SentenceBlockID(s)
if a == "" || a != b {
t.Fatalf("★ SentenceBlockID 不稳定:%q vs %q", a, b)
}
if c := memory.SentenceBlockID(s + " "); c == "" {
t.Fatal("★ 带空白的句子没得到 ID")
}
}
func TestNewSentenceBlockFields(t *testing.T) {
now := time.Now()
b := memory.NewSentenceBlock(" 原句内容 ", now, now)
if b.Text != "原句内容" {
t.Fatalf("★ Text = %q,期望已 Trim", b.Text)
}
if b.ID != memory.SentenceBlockID(" 原句内容 ") {
t.Fatalf("★ ID = %q,与 SentenceBlockID 不一致", b.ID)
}
}
// TestBuildCentroid:中心向量的均值必须等于输入的算术均值。
func TestBuildCentroid(t *testing.T) {
vecs := [][]float64{{1, 2, 3}, {3, 4, 5}}
centroid, _ := memory.BuildCentroid(vecs)
if centroid == nil {
t.Fatal("★ BuildCentroid 返回 nil")
}
mean := centroid.Mean()
if len(mean) != 3 {
t.Fatalf("★ Mean 维度 = %d,期望 3", len(mean))
}
for i, want := range []float64{2, 3, 4} {
if mean[i] != want {
t.Fatalf("★ Mean[%d]=%v,期望 %v", i, mean[i], want)
}
}
}
// ─────────── GraphDB:真实构造 → 写入 → 可观察输出 ───────────
func newDB(t *testing.T) *memory.GraphDB {
t.Helper()
g, err := memory.NewGraphDB(filepath.Join(t.TempDir(), "graph.db"))
if err != nil {
t.Fatalf("NewGraphDB: %v", err)
}
t.Cleanup(func() { _ = g.Close() })
return g
}
// 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)
}
if err := g.Close(); err != nil {
t.Fatal(err)
}
reopened, err := memory.NewGraphDB(path)
if err != nil {
t.Fatal(err)
}
defer reopened.Close()
result, err := reopened.Recall([]string{"billing服务"}, nil, 2, "")
if err != nil {
t.Fatal(err)
}
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 层拒写。
//
// 这是「子 agent 改不了主记忆」的结构性保证,不是靠调用方自觉。
func TestReadOnlyHandleRejectsWrite(t *testing.T) {
path := filepath.Join(t.TempDir(), "graph.db")
main, err := memory.NewGraphDB(path)
if err != nil {
t.Fatal(err)
}
defer main.Close()
ro, err := memory.OpenGraphDBReadOnly(path)
if err != nil {
t.Fatalf("OpenGraphDBReadOnly: %v", err)
}
defer ro.Close()
// 实体名要合法(否则会被 validEntityName 提前拒掉,走不到 SQL 层)
_, _, err = ro.Commit([]memory.Triple{
{Subject: "billing服务", Relation: "端口", Object: "8499",
SentenceText: "billing服务端口是8499"},
}, "contract", 1)
if err == nil {
t.Fatal("★ 只读句柄接受了写入 —— 子 agent 能改主记忆")
}
}