Files
HomeAgent/internal/memory/test/memory_contract_test.go
JianFeeeee 443110506c test: 按模块导出接口重写黑盒判据(批次 3:memory / agent-core)
延续批次 1/2 的形态:只调模块**本来就有**的导出接口,输入 → 可观察输出。
2 个文件全部在 `<module>/test/`,源码目录仍零 `_test.go`。
共 17 条判据。

## internal/memory(10 条)

纯函数(输入文本 → 输出):

    TestCleanText                     空白/「,」前缀(实现剥两次)
    TestChannelScene                  "qq" → "chan:qq",空 → 空
    TestFilterNoiseTriples            噪音主语/宾语被过滤,且不改输入切片
    TestSentenceBlockIDIsStable       同文本同 ID
    TestNewSentenceBlockFields        Trim 后的 Text 与 ID
    TestBuildCentroid                 中心向量均值 = 输入算术均值

GraphDB 真实构造 → 写入 → 可观察输出:

    TestCommitThenGraphData           Commit → GraphData 给出 nodes 结构
    TestReadOnlyHandleRejectsWrite    OpenGraphDBReadOnly 在 SQLite 层拒写

`TestReadOnlyHandleRejectsWrite` 守的是「子 agent 改不了主记忆」的
**结构性**保证(PRAGMA query_only=1),不是靠调用方自觉。

**判据写错过两次,按真实契约修正**:
① `CleanText` 连剥两次「,」,第一版按"只剥一次"写 ⇒ 红。
② 只读句柄的第一版三元组用了单字符实体名,被 `validEntityName`
   提前拒掉、根本走不到 SQL 层 ⇒ 假绿。换成合法实体名后才真正验到
   query_only。这个坑值得记:**判据的输入必须能走到被测路径**。

## internal/agent/core(7 条)

    TestAgentID                       New(AgentConfig{ID}) → ID()
    TestOutputAllowlist               空白名单=完整授权;非空=白名单
    TestDuplicateInputWindow          同源同内容第二次判重;异源/异内容否
    TestRealtimeFlag                  普通 agent=false;nil receiver 安全
    TestEmptyCollectionsOnFreshAgent  Residents/RTChildren/DumpScheduler 为空
    TestResolveOutputTargetWithoutRegistry  无注册表 → ok=false,不 panic
    TestGetKernelStatusNeverNil       状态查询返回可用对象

只用 `New(AgentConfig)` 装配,不启动事件循环(已核实 `New` 里没有
`go a.xxx` / `Start()`),不构造 TaskFrame、不调 stepLLM/runStage、
不碰 a.sched、不读源码。

## 变异验证(改回旧实现必须红)

    memory  CleanText 不剥前缀        → ★ CleanText(",,你好")=",你好"
    memory  只读句柄不设 query_only  → ★ 只读句柄接受了写入
    core    白名单恒放行             → ★ 白名单外的通道被放行
    core    去重恒 false             → ★ 同源同内容第二次未判重

全部还原后复跑全绿。

## 验证

    go build ./...                            PASS
    go vet ./...                              PASS
    gofmt -l                                  干净
    python3 .github/scripts/check-ci-gates.py PASS
    go test ./... -count=1                    14 个测试包 / 100 包无测试 / 0 FAIL
    2 个新包 -race                            全绿
    源码目录 _test.go                         0
2026-11-09 12:37:52 +08:00

161 lines
4.8 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
}
// TestCommitThenGraphData:Commit 一条三元组后,GraphData 必须能反映它。
func TestCommitThenGraphData(t *testing.T) {
g := newDB(t)
tri := memory.Triple{
Subject: "billing服务", Relation: "端口", Object: "8499",
SentenceText: "billing服务端口是8499",
}
if _, _, err := g.Commit([]memory.Triple{tri}, "contract-session", 1); err != nil {
t.Fatalf("Commit: %v", err)
}
data, err := g.GraphData()
if err != nil {
t.Fatalf("GraphData: %v", err)
}
if data == nil {
t.Fatal("★ GraphData 返回 nil")
}
// 可观察输出:GraphData 必须给出 nodes/edges 结构(而非 panic / 空 map)
if _, ok := data["nodes"]; !ok {
t.Fatalf("★ GraphData 缺 nodes 字段:%v", data)
}
}
// 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 能改主记忆")
}
}