Files
HomeAgent/internal/plugins/webui/handler_graph_pulse_test.go
pi 7e4f1058ea chore: 移出本地工具配置 .pi-lens.json
它是 pi-lens 的**仓库级 ignore 配置**,随每个人的工具版本与偏好变化,
对 CI 和其他贡献者毫无意义,还会成为 merge 噪音。
⇒ 移出仓库并加进 .gitignore,各人在本机放自己的一份。

★ 但它注释里的**根因分析要留在仓库**(配置外置、结论内留)——
runbook §6.1/6.2 已写得更全(runner 不按目录语言选+
failed-first 让误报永不自愈),故补 §6.3 说明「为什么仓库里没这个文件」,
原§6.3/6.4 顺延为 §6.4/6.5。

这个上游缺陷不知道哪年修:修之前每个新 clone 的人都会再踩一遍,
所以结论必须内留而配置必须外置。
2026-10-05 13:28:01 +08:00

237 lines
9.0 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 webui
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
// fakeGraphMemory 是一个最小 MemoryAPI 桩,只为把 GraphData 喂给 handler。
type fakeGraphMemory struct {
data map[string]interface{}
}
func (f *fakeGraphMemory) Recall([]string, int) ([]pubsdk.Entity, []pubsdk.Relation, error) {
return nil, nil, nil
}
func (f *fakeGraphMemory) Commit([]pubsdk.Triple) error { return nil }
func (f *fakeGraphMemory) Introspect() (map[string]interface{}, error) {
return nil, nil
}
func (f *fakeGraphMemory) MergeEntities(string, string) (int, error) { return 0, nil }
func (f *fakeGraphMemory) Purge(map[string]string, string) (int, error) {
return 0, nil
}
func (f *fakeGraphMemory) GraphData() (map[string]interface{}, error) { return f.data, nil }
// graphFixture 造一份与生产实例同构的图谱快照:
// 节点带 updated_at(pulse 端点按它过滤),memory_blocks 带稠密 vector。
func graphFixture() map[string]interface{} {
now := time.Now()
old := now.Add(-72 * time.Hour)
return map[string]interface{}{
"nodes": []map[string]interface{}{
{"id": 1, "name": "小宅", "type": "Concept", "mention_count": 228,
"created_at": old, "updated_at": now.Add(-8 * time.Hour)},
{"id": 2, "name": "刚刚学到的东西", "type": "Concept", "mention_count": 1,
"created_at": now, "updated_at": now},
},
"edges": []map[string]interface{}{},
// ★ memory_blocks 才是 pulse 的过滤源(2026-10-05 修)。
// 刻意放两个:一个 8h 前(默认 900s 窗口外)、一个刚更新(窗口内)。
// 旧节点(nodes/小宅)的 updated_at 也刻意设成 8h 前 ——
// 修复前 pulse 读的是它,而它永远落在窗口外 ⇒ 恒返回空。
"memory_blocks": []memory.MemoryBlock{
{ID: "b_old", Modality: memory.BlockModality("text"), Text: "老块",
PayloadDigest: "d0", Vector: []float64{0.1, 0.2, 0.3},
CreatedAt: old, UpdatedAt: now.Add(-8 * time.Hour)},
{ID: "b_new", Modality: memory.BlockModality("text"), Text: "刚学到的东西",
PayloadDigest: "d1", Vector: []float64{0.1, 0.2, 0.3},
CreatedAt: now, UpdatedAt: now},
},
}
}
func newGraphHandler(m *fakeGraphMemory) *Handler {
return &Handler{memory: m}
}
// TestHandleMemoryGraph_StripsVector 钉住「不下发稠密向量」。
//
// 生产实测:8 块记忆的 vector 占 79,314 B / 408,146 B = 19%,而星图
// (本接口唯一消费者)从不读 vector。这部分纯属白付带宽 + 堆内存。
func TestHandleMemoryGraph_StripsVector(t *testing.T) {
h := newGraphHandler(&fakeGraphMemory{data: graphFixture()})
rr := httptest.NewRecorder()
h.handleMemoryGraph(rr, httptest.NewRequest(http.MethodGet, "/api/v1/memory/graph", nil))
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
}
if bytes.Contains(rr.Body.Bytes(), []byte(`"vector"`)) {
t.Fatalf("response still contains vector field:\n%s", rr.Body.String())
}
// 其余字段必须还在(不能顺手把整个 memory_blocks 删掉)。
for _, want := range []string{`"payload_digest":"d1"`, `"id":"b_new"`, `"nodes"`, `"edges"`} {
if !bytes.Contains(rr.Body.Bytes(), []byte(want)) {
t.Fatalf("response missing %s:\n%s", want, rr.Body.String())
}
}
}
// TestHandleMemoryGraphPulse_OnlyRecent 钉住 pulse 端点只回窗口内变动的节点。
//
// 这是星图「新记忆生长」的数据源;它必须比全量图谱小两个数量级。
func TestHandleMemoryGraphPulse_OnlyRecent(t *testing.T) {
h := newGraphHandler(&fakeGraphMemory{data: graphFixture()})
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/memory/graph/pulse", nil)
h.handleMemoryGraphPulse(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
}
var resp struct {
Success bool `json:"success"`
Data struct {
Nodes []struct {
ID string `json:"id"`
Name string `json:"name"`
} `json:"nodes"`
} `json:"data"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if !resp.Success {
t.Fatalf("success = false")
}
// 默认窗口 900s:8h 前的「小宅」不该在里面,刚更新的应该在。
if len(resp.Data.Nodes) != 1 {
t.Fatalf("expected 1 recent node, got %d: %+v", len(resp.Data.Nodes), resp.Data.Nodes)
}
if resp.Data.Nodes[0].ID != "b_new" {
t.Fatalf("expected block id=b_new, got %q", resp.Data.Nodes[0].ID)
}
}
// TestHandleMemoryGraphPulse_SinceWidens 钉住 since 参数能放大窗口。
func TestHandleMemoryGraphPulse_SinceWidens(t *testing.T) {
h := newGraphHandler(&fakeGraphMemory{data: graphFixture()})
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/memory/graph/pulse?since=86400", nil)
h.handleMemoryGraphPulse(rr, req)
var resp struct {
Data struct {
Nodes []struct {
ID string `json:"id"`
} `json:"nodes"`
} `json:"data"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if len(resp.Data.Nodes) != 2 {
t.Fatalf("since=86400 should include both nodes, got %d", len(resp.Data.Nodes))
}
}
// TestHandleMemoryGraphPulse_NoVector 钉住 pulse 响应同样不带 vector。
func TestHandleMemoryGraphPulse_NoVector(t *testing.T) {
h := newGraphHandler(&fakeGraphMemory{data: graphFixture()})
rr := httptest.NewRecorder()
h.handleMemoryGraphPulse(rr, httptest.NewRequest(http.MethodGet, "/api/v1/memory/graph/pulse", nil))
if bytes.Contains(rr.Body.Bytes(), []byte(`"vector"`)) {
t.Fatalf("pulse response contains vector:\n%s", rr.Body.String())
}
}
// TestGraphDataForVisual_PassthroughWhenTypeMismatch 钉住断言失败时不吞数据。
//
// graphDataForVisual 用类型断言识别 memory_blocks。若 GraphData 换了
// 返回类型(比如改成 []*MemoryBlock),断言不中时必须原样透传而不是
// 把字段弄丢 —— 宁可多发 vector,也不能让整个图谱接口变空。
func TestGraphDataForVisual_PassthroughWhenTypeMismatch(t *testing.T) {
in := map[string]interface{}{
"nodes": []map[string]interface{}{{"id": 1}},
"memory_blocks": []*memory.MemoryBlock{{ID: "x"}},
}
out := graphDataForVisual(in)
if out["memory_blocks"] == nil {
t.Fatalf("memory_blocks dropped on type mismatch")
}
if _, ok := out["nodes"]; !ok {
t.Fatalf("nodes dropped")
}
}
// TestGraphPulse_IgnoresFrozenLegacyNodes 钉住 pulse 的**过滤源**是块表而非旧表。
//
// ★ 这条是 2026-10-05 清理时补的,针对一个真实的生产静默失效:
//
// /memory/graph/pulse 恒返回 {"nodes":[]},
// 而 /memory/graph 同一时刻有 1294 个节点。
//
// 根因:pulse 原实现按 `out["nodes"]` 的 updated_at 过滤,而 nodes 来自
// **旧表 entities**(graph.go:1445)——旧表停双写后不再增长,
// 生产最后更新停在 2026-10-03,而 pulse 默认窗口只有 900s
// ⇒ 1294 个节点**永远**落在窗口外 ⇒ 恒空。
// 症状形态:HTTP 200 + success:true,无任何报错。
//
// ★ 为什么上面两条判据抓不住:它们的 fixture 里 nodes 与 memory_blocks
//
// 「恰好都有一条落在窗口内」,所以读哪个源都能过。
// 这条刻意让**旧表节点全部落在窗口外、块有一个在窗口内**,
// 于是「读错源」与「读对源」的结果**必然不同**。
func TestGraphPulse_IgnoresFrozenLegacyNodes(t *testing.T) {
now := time.Now()
frozen := now.Add(-72 * time.Hour) // 旧表:三天前,之后再没更新过
fixture := map[string]interface{}{
// 旧表节点:全部在窗口外(模拟停双写后的冻结状态)
"nodes": []map[string]interface{}{
{"id": 1, "name": "旧实体", "type": "Concept", "mention_count": 228,
"created_at": frozen, "updated_at": frozen},
},
"edges": []map[string]interface{}{},
// 块表:有一条刚更新(活图谱的真实形态)
"memory_blocks": []memory.MemoryBlock{
{ID: "b_live", Modality: memory.BlockModality("text"), Text: "刚写入的块",
PayloadDigest: "dx", CreatedAt: now, UpdatedAt: now},
},
}
h := newGraphHandler(&fakeGraphMemory{data: fixture})
rr := httptest.NewRecorder()
h.handleMemoryGraphPulse(rr, httptest.NewRequest(http.MethodGet, "/api/v1/memory/graph/pulse", nil))
var resp struct {
Success bool `json:"success"`
Data struct {
Nodes []struct {
ID string `json:"id"`
Name string `json:"name"`
} `json:"nodes"`
} `json:"data"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v\n%s", err, rr.Body.String())
}
if !resp.Success {
t.Fatal("success = false")
}
if len(resp.Data.Nodes) != 1 {
t.Fatalf("旧表节点已冻结三天、块表有一条刚更新 ⇒ 应回 1 个(来自块表),"+
"实际 %d 个:%+v\n★ 若为 0,说明 pulse 又退回读旧表了 —— "+
"生产上这表现为「星图不跟随 agent 动」且无任何报错",
len(resp.Data.Nodes), resp.Data.Nodes)
}
if resp.Data.Nodes[0].ID != "b_live" {
t.Fatalf("应回块表的 b_live,实际 %q", resp.Data.Nodes[0].ID)
}
}