From bb29096f605afe7b7889ea97aa2b91f2f474c9ec Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Wed, 4 Nov 2026 22:06:35 +0800 Subject: [PATCH] =?UTF-8?q?refactor(webui):=20=E5=9B=BE=E8=B0=B1=E5=88=A4?= =?UTF-8?q?=E6=8D=AE=E8=BF=81=E5=88=B0=20test/=20=E2=80=94=E2=80=94=20?= =?UTF-8?q?=E5=AF=BC=E5=87=BA=20GraphDataForVisual=20=E4=B8=80=E5=B1=82?= =?UTF-8?q?=E8=BD=AC=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- internal/plugins/webui/handler_memory.go | 12 +++-- .../webui/{ => test}/graph_source_test.go | 14 ++++-- .../{ => test}/handler_graph_pulse_test.go | 49 +++++++++++++------ 3 files changed, 55 insertions(+), 20 deletions(-) rename internal/plugins/webui/{ => test}/graph_source_test.go (82%) rename internal/plugins/webui/{ => test}/handler_graph_pulse_test.go (85%) diff --git a/internal/plugins/webui/handler_memory.go b/internal/plugins/webui/handler_memory.go index c8a1f144..76b05026 100644 --- a/internal/plugins/webui/handler_memory.go +++ b/internal/plugins/webui/handler_memory.go @@ -143,12 +143,18 @@ type graphBlockView struct { UpdatedAt time.Time `json:"updated_at"` } -// graphDataForVisual 把 GraphData 的原始 map 裁成可视化视图(去掉稠密向量)。 +// GraphDataForVisual 把 GraphData 的原始 map 裁成可视化视图(去掉稠密向量)。 +// +// ★ 导出(2026-11-04):这是本包唯一「输入 raw map → 输出 visual map」的 +// 纯转换,也是判据 `TestGraphDataExposesBlocksForStarmap` 想测的东西。 +// 走 HTTP 端点测不了它 —— 端点的输入来自 h.memory.GraphData(),而 h.memory +// 是私有字段,包外无法注入合成数据。导出这层转换比导出整个 Handler 的 +// 装配更小、也更贴合它真实的对外语义。 // // 用 map 断言而不是泛型/反射:GraphData 返回 map[string]interface{}, // 里面的具体类型是包内私有的 graphEntity/[]*memory.MemoryBlock, // 断言不中就原样透传(宁可多发也不让接口挂掉)。 -func graphDataForVisual(data map[string]interface{}) map[string]interface{} { +func GraphDataForVisual(data map[string]interface{}) map[string]interface{} { out := make(map[string]interface{}, len(data)) for k, v := range data { out[k] = v @@ -196,7 +202,7 @@ func (h *Handler) handleMemoryGraph(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } - writeJSON(w, http.StatusOK, map[string]interface{}{"success": true, "data": graphDataForVisual(data)}) + writeJSON(w, http.StatusOK, map[string]interface{}{"success": true, "data": GraphDataForVisual(data)}) } // handleMemoryGraphPulse 是给星图「跟随 agent 动」用的**轻量**活动端点。 diff --git a/internal/plugins/webui/graph_source_test.go b/internal/plugins/webui/test/graph_source_test.go similarity index 82% rename from internal/plugins/webui/graph_source_test.go rename to internal/plugins/webui/test/graph_source_test.go index d4b2dd16..e6584563 100644 --- a/internal/plugins/webui/graph_source_test.go +++ b/internal/plugins/webui/test/graph_source_test.go @@ -1,10 +1,11 @@ -package webui +package test import ( "encoding/json" "testing" "github.com/JianFeeeee/HologramAgent/internal/memory" + "github.com/JianFeeeee/HologramAgent/internal/plugins/webui" ) // 星图数据源必须来自块体系(memory_blocks),而不是停写的旧表 entities。 @@ -26,14 +27,21 @@ func TestGraphDataExposesBlocksForStarmap(t *testing.T) { {ID: 2, SourceKind: "block", SourceID: "blk_src_a", TargetKind: "block", TargetID: "blk_ent_c", Type: "contains"}, }, } - out := graphDataForVisual(data) + out := webui.GraphDataForVisual(data) // 前端读的是 memory_blocks 字段 blocks, ok := out["memory_blocks"] if !ok { t.Fatal("GraphData 必须下发 memory_blocks(前端星图的真实数据源)") } raw, _ := json.Marshal(blocks) - var got []graphBlockView + // 只用 JSON 结构断言下发内容,不引用包内私有类型。 + var got []struct { + ID string `json:"id"` + Modality string `json:"modality"` + Text string `json:"text"` + HasData bool `json:"has_data"` + Size int64 `json:"size"` + } if err := json.Unmarshal(raw, &got); err != nil { t.Fatal(err) } diff --git a/internal/plugins/webui/handler_graph_pulse_test.go b/internal/plugins/webui/test/handler_graph_pulse_test.go similarity index 85% rename from internal/plugins/webui/handler_graph_pulse_test.go rename to internal/plugins/webui/test/handler_graph_pulse_test.go index 755ea2b4..57405d15 100644 --- a/internal/plugins/webui/handler_graph_pulse_test.go +++ b/internal/plugins/webui/test/handler_graph_pulse_test.go @@ -1,4 +1,4 @@ -package webui +package test import ( "bytes" @@ -8,7 +8,10 @@ import ( "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" ) @@ -90,8 +93,26 @@ func graphFixture() map[string]interface{} { } } -func newGraphHandler(m *fakeGraphMemory) *Handler { - return &Handler{memory: m} +// 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 钉住「不下发稠密向量」。 @@ -99,9 +120,9 @@ func newGraphHandler(m *fakeGraphMemory) *Handler { // 生产实测:8 块记忆的 vector 占 79,314 B / 408,146 B = 19%,而星图 // (本接口唯一消费者)从不读 vector。这部分纯属白付带宽 + 堆内存。 func TestHandleMemoryGraph_StripsVector(t *testing.T) { - h := newGraphHandler(&fakeGraphMemory{data: graphFixture()}) + h := newGraphHandler(t, &fakeGraphMemory{data: graphFixture()}) rr := httptest.NewRecorder() - h.handleMemoryGraph(rr, httptest.NewRequest(http.MethodGet, "/api/v1/memory/graph", nil)) + 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) } @@ -120,10 +141,10 @@ func TestHandleMemoryGraph_StripsVector(t *testing.T) { // // 这是星图「新记忆生长」的数据源;它必须比全量图谱小两个数量级。 func TestHandleMemoryGraphPulse_OnlyRecent(t *testing.T) { - h := newGraphHandler(&fakeGraphMemory{data: graphFixture()}) + h := newGraphHandler(t, &fakeGraphMemory{data: graphFixture()}) rr := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/v1/memory/graph/pulse", nil) - h.handleMemoryGraphPulse(rr, req) + serveGraph(t, h, rr, req) if rr.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rr.Code) } @@ -153,10 +174,10 @@ func TestHandleMemoryGraphPulse_OnlyRecent(t *testing.T) { // TestHandleMemoryGraphPulse_SinceWidens 钉住 since 参数能放大窗口。 func TestHandleMemoryGraphPulse_SinceWidens(t *testing.T) { - h := newGraphHandler(&fakeGraphMemory{data: graphFixture()}) + h := newGraphHandler(t, &fakeGraphMemory{data: graphFixture()}) rr := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/v1/memory/graph/pulse?since=86400", nil) - h.handleMemoryGraphPulse(rr, req) + serveGraph(t, h, rr, req) var resp struct { Data struct { Nodes []struct { @@ -174,9 +195,9 @@ func TestHandleMemoryGraphPulse_SinceWidens(t *testing.T) { // TestHandleMemoryGraphPulse_NoVector 钉住 pulse 响应同样不带 vector。 func TestHandleMemoryGraphPulse_NoVector(t *testing.T) { - h := newGraphHandler(&fakeGraphMemory{data: graphFixture()}) + h := newGraphHandler(t, &fakeGraphMemory{data: graphFixture()}) rr := httptest.NewRecorder() - h.handleMemoryGraphPulse(rr, httptest.NewRequest(http.MethodGet, "/api/v1/memory/graph/pulse", nil)) + 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()) } @@ -192,7 +213,7 @@ func TestGraphDataForVisual_PassthroughWhenTypeMismatch(t *testing.T) { "nodes": []map[string]interface{}{{"id": 1}}, "memory_blocks": []*memory.MemoryBlock{{ID: "x"}}, } - out := graphDataForVisual(in) + out := webui.GraphDataForVisual(in) if out["memory_blocks"] == nil { t.Fatalf("memory_blocks dropped on type mismatch") } @@ -237,9 +258,9 @@ func TestGraphPulse_IgnoresFrozenLegacyNodes(t *testing.T) { }, } - h := newGraphHandler(&fakeGraphMemory{data: fixture}) + h := newGraphHandler(t, &fakeGraphMemory{data: fixture}) rr := httptest.NewRecorder() - h.handleMemoryGraphPulse(rr, httptest.NewRequest(http.MethodGet, "/api/v1/memory/graph/pulse", nil)) + serveGraph(t, h, rr, httptest.NewRequest(http.MethodGet, "/api/v1/memory/graph/pulse", nil)) var resp struct { Success bool `json:"success"`