diff --git a/internal/agent/core/test/agent_contract_test.go b/internal/agent/core/test/agent_contract_test.go new file mode 100644 index 00000000..6476349a --- /dev/null +++ b/internal/agent/core/test/agent_contract_test.go @@ -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") + } +} diff --git a/internal/memory/test/memory_contract_test.go b/internal/memory/test/memory_contract_test.go new file mode 100644 index 00000000..77f95abc --- /dev/null +++ b/internal/memory/test/memory_contract_test.go @@ -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 能改主记忆") + } +}