Files
HomeAgent/internal/plugins/webui/test/handler_graph_pulse_test.go
JianFeeeee bb29096f60 refactor(webui): 图谱判据迁到 test/ —— 导出 GraphDataForVisual 一层转换
graph_source_test.go        → test/
    handler_graph_pulse_test.go → test/     (7 条判据)
    webui/       174 → 167
    webui/test/   28 →  35
    合计 202 不变

## 这一条必须动生产代码,理由要写清

判据想测的是「GraphData 的 raw map 被裁成可视化视图时,稠密向量必须被剥掉」。
两条路都不通:

    走 HTTP 端点 /api/v1/memory/graph
      → 端点输入来自 h.memory.GraphData(),h.memory 是私有字段,
        包外无法注入合成数据。测不了。

    包内测试直接调 graphDataForVisual
      → 这正是要摆脱的白盒写法。

所以导出这一层转换 `graphDataForVisual → GraphDataForVisual`。它不是
为测试造出来的形状:函数的输入输出本来就是本包唯一的「raw map →
visual map」语义,端点在 handler_memory.go:199 调它。导出的是真实边界,
不是内部细节。(判据 §5.3「不要为满足测试导出内部」指的是后者。)

顺带把 `graphBlockView`(私有 JSON 结构)在测试侧改成匿名结构体 ——
它只用于反序列化断言,没有必要引用包内类型。

## 改走真实路由暴露的两个事实

### ① `newGraphHandler` 直接设 `&Handler{memory: m}`

改成 `NewHandler(sdk.New("webui", sdk.SDKConfig{Memory: m, ...}))` ——
`Memory` 确实能经 SDKConfig 注入(plugin.go:457 `base.SetMemoryAPI(cfg.Memory)`)。

### ② 503 暴露了鉴权要求

改走路由后立刻:

    status = 503, want 200

`requireAPI` 需要 api_key,而最小装配没配。补 `seedWebUIConfig` 后 200。
之前直接调 handler 的写法**从来不知道有这个要求** —— 路由层挡掉的东西,
绕过路由就看不见。

## 判据有效性(变异验证)

在 `GraphDataForVisual` 里把裁剪短路掉(`if blocks != nil { return out }`),
实测红,且输出直接暴露泄漏内容:

    --- FAIL: TestHandleMemoryGraph_StripsVector
        response still contains vector field:
        {"data":{"memory_blocks":[{"id":"b_old",...,"vector":[0.1,0.2,0.3],...
         "success":true}

稠密向量确实会被下发到前端。恢复后全绿。

## 验证

    go build ./...                  PASS
    go vet ./...                    PASS
    gofmt -l                        干净
    go test ./... -count=1          67 包全过,0 FAIL
2026-11-04 22:06:35 +08:00

290 lines
11 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
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
internalConfig "github.com/JianFeeeee/HologramAgent/internal/config"
"github.com/JianFeeeee/HologramAgent/internal/events"
"github.com/JianFeeeee/HologramAgent/internal/memory"
"github.com/JianFeeeee/HologramAgent/internal/plugins/webui"
sdk "github.com/JianFeeeee/HologramAgent/internal/sdk"
pubsdk "github.com/JianFeeeee/hologramagentsdk/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 }
// MediaBytes 是 CAS 淘汰后插件取媒体的入口。本桩只服务星图端点,
// 不做媒体断言,返回“无此块”即可(与真实实现在无引用时的行为一致)。
func (f *fakeGraphMemory) MediaBytes(string) ([]byte, string, bool, error) {
return nil, "", false, nil
}
// BlocksChangedSince 是 pulse 端点现在的数据源(不再走 GraphData 全量 + 过滤)。
//
// 桩要**忠实反映过滤语义**:只回窗口内的块。若这里偷懒返回全部,
// 就把「窗口过滤」这条判据架空了——测的是桩,不是产品。
func (f *fakeGraphMemory) BlocksChangedSince(since time.Time, limit int) ([]sdk.MemoryBlockView, error) {
blocks, _ := f.data["memory_blocks"].([]memory.MemoryBlock)
var out []sdk.MemoryBlockView
for _, b := range blocks {
if b.UpdatedAt.Before(since) && b.CreatedAt.Before(since) {
continue
}
out = append(out, sdk.MemoryBlockView{
ID: b.ID, Modality: string(b.Modality), Text: b.Text,
HasData: len(b.Data) > 0, MIME: b.MIME,
Source: b.Source, Tool: b.Tool, Scene: b.Scene,
CreatedAt: b.CreatedAt, UpdatedAt: b.UpdatedAt,
})
if limit > 0 && len(out) >= limit {
break
}
}
return out, 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: "老块",
Vector: []float64{0.1, 0.2, 0.3},
CreatedAt: old, UpdatedAt: now.Add(-8 * time.Hour)},
{ID: "b_new", Modality: memory.BlockModality("image"), Text: "刚学到的东西",
Data: []byte("png-bytes"), MIME: "image/png",
Vector: []float64{0.1, 0.2, 0.3},
CreatedAt: now, UpdatedAt: now},
},
}
}
// newGraphHandler 通过**导出入口**装配 Handler:Memory 经 SDKConfig 注入,
// 不再直接设 h.memory 私有字段。
func newGraphHandler(t *testing.T, m *fakeGraphMemory) *webui.Handler {
t.Helper()
reg := internalConfig.NewConfigRegistry("")
seedWebUIConfig(reg)
return webui.NewHandler(sdk.New("webui", sdk.SDKConfig{
Memory: m,
Settings: sdk.NewSettings("webui", reg),
EventBus: events.NewBus(),
}))
}
// serveGraph 经导出的 RegisterRoutes 分发请求。
func serveGraph(t *testing.T, h *webui.Handler, w http.ResponseWriter, r *http.Request) {
t.Helper()
r.Header.Set("X-API-Key", testAPIKey)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
mux.ServeHTTP(w, r)
}
// TestHandleMemoryGraph_StripsVector 钉住「不下发稠密向量」。
//
// 生产实测:8 块记忆的 vector 占 79,314 B / 408,146 B = 19%,而星图
// (本接口唯一消费者)从不读 vector。这部分纯属白付带宽 + 堆内存。
func TestHandleMemoryGraph_StripsVector(t *testing.T) {
h := newGraphHandler(t, &fakeGraphMemory{data: graphFixture()})
rr := httptest.NewRecorder()
serveGraph(t, h, 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{`"has_data":true`, `"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(t, &fakeGraphMemory{data: graphFixture()})
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/memory/graph/pulse", nil)
serveGraph(t, h, 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(t, &fakeGraphMemory{data: graphFixture()})
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/memory/graph/pulse?since=86400", nil)
serveGraph(t, h, 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(t, &fakeGraphMemory{data: graphFixture()})
rr := httptest.NewRecorder()
serveGraph(t, h, 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 := webui.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: "刚写入的块",
CreatedAt: now, UpdatedAt: now},
},
}
h := newGraphHandler(t, &fakeGraphMemory{data: fixture})
rr := httptest.NewRecorder()
serveGraph(t, h, 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)
}
}