mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-12 12:17:36 +00:00
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
This commit is contained in:
102
internal/agent/core/test/agent_contract_test.go
Normal file
102
internal/agent/core/test/agent_contract_test.go
Normal file
@ -0,0 +1,102 @@
|
||||
package test
|
||||
|
||||
// internal/agent/core 的黑盒判据:只用 New(AgentConfig) + 公开方法,
|
||||
// 输入 → 可观察输出。不构造 TaskFrame、不调 stepLLM/runStage、
|
||||
// 不碰 a.sched、不读源码。
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/JianFeeeee/HologramAgent/internal/agent/core"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/agent/io"
|
||||
"github.com/JianFeeeee/HologramAgent/pkg/types"
|
||||
)
|
||||
|
||||
func newAgent(cfg core.AgentConfig) *core.Agent {
|
||||
return core.New(cfg)
|
||||
}
|
||||
|
||||
// TestAgentID:构造时给的 ID 必须能从 ID() 读回。
|
||||
func TestAgentID(t *testing.T) {
|
||||
a := newAgent(core.AgentConfig{ID: types.AgentID("contract-agent")})
|
||||
if got := a.ID(); got != types.AgentID("contract-agent") {
|
||||
t.Fatalf("★ ID() = %q,期望 contract-agent", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOutputAllowlist:AllowedOutputs 为空 = 完整授权;非空 = 白名单。
|
||||
func TestOutputAllowlist(t *testing.T) {
|
||||
all := newAgent(core.AgentConfig{})
|
||||
if !all.IsOutputAllowed("qq") || !all.IsOutputAllowed("webui") {
|
||||
t.Fatal("★ 空白名单应完整授权")
|
||||
}
|
||||
limited := newAgent(core.AgentConfig{
|
||||
AllowedOutputs: []string{"qq"},
|
||||
})
|
||||
if !limited.IsOutputAllowed("qq") {
|
||||
t.Fatal("★ 白名单内的通道被拒")
|
||||
}
|
||||
if limited.IsOutputAllowed("webui") {
|
||||
t.Fatal("★ 白名单外的通道被放行")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDuplicateInputWindow:同源同内容短窗口内第二次判重;异源/异内容不判重。
|
||||
func TestDuplicateInputWindow(t *testing.T) {
|
||||
a := newAgent(core.AgentConfig{})
|
||||
if a.IsDuplicateInput("webui", "再发一次") {
|
||||
t.Fatal("★ 第一次输入被判成重复")
|
||||
}
|
||||
if !a.IsDuplicateInput("webui", "再发一次") {
|
||||
t.Fatal("★ 同源同内容第二次未判重")
|
||||
}
|
||||
if a.IsDuplicateInput("qq", "再发一次") {
|
||||
t.Fatal("★ 不同来源被误判重复")
|
||||
}
|
||||
if a.IsDuplicateInput("webui", "另一句") {
|
||||
t.Fatal("★ 不同内容被误判重复")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRealtimeFlag:IsRealtime 是构造期的可观察输出;nil receiver 安全。
|
||||
func TestRealtimeFlag(t *testing.T) {
|
||||
if newAgent(core.AgentConfig{}).IsRealtime() {
|
||||
t.Fatal("★ 普通 agent 报告自己是 realtime")
|
||||
}
|
||||
var nilAgent *core.Agent
|
||||
if nilAgent.IsRealtime() {
|
||||
t.Fatal("★ nil receiver 报告 realtime=true")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmptyCollectionsOnFreshAgent:新 agent 的驻留子 / rt 子 / 队列视图为空。
|
||||
func TestEmptyCollectionsOnFreshAgent(t *testing.T) {
|
||||
a := newAgent(core.AgentConfig{IO: io.NewIOManager()})
|
||||
if got := a.Residents(); len(got) != 0 {
|
||||
t.Fatalf("★ Residents() = %v,期望空", got)
|
||||
}
|
||||
if got := a.RTChildren(); len(got) != 0 {
|
||||
t.Fatalf("★ RTChildren() = %v,期望空", got)
|
||||
}
|
||||
snap := a.DumpScheduler()
|
||||
if snap.Running != nil || len(snap.Queue) != 0 {
|
||||
t.Fatalf("★ 新 agent 的调度快照非空:running=%v queue=%d",
|
||||
snap.Running, len(snap.Queue))
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveOutputTargetWithoutRegistry:未装配通道注册表时 ok=false,不 panic。
|
||||
func TestResolveOutputTargetWithoutRegistry(t *testing.T) {
|
||||
a := newAgent(core.AgentConfig{})
|
||||
if _, ok := a.ResolveOutputTarget("anything"); ok {
|
||||
t.Fatal("★ 无注册表却报告解析成功")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetKernelStatusNeverNil:状态查询必须返回可用对象。
|
||||
func TestGetKernelStatusNeverNil(t *testing.T) {
|
||||
a := newAgent(core.AgentConfig{IO: io.NewIOManager()})
|
||||
if st := a.GetKernelStatus(); st == nil {
|
||||
t.Fatal("★ GetKernelStatus() 返回 nil")
|
||||
}
|
||||
}
|
||||
160
internal/memory/test/memory_contract_test.go
Normal file
160
internal/memory/test/memory_contract_test.go
Normal file
@ -0,0 +1,160 @@
|
||||
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 能改主记忆")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user