Files
HomeAgent/internal/plugins/webui/starmap_perf_test.go
JianFeeeee 8336509bfa perf(webui): 修星图形变活跃集的泄漏 + 加回归判据
上一提交(3e201dd4)把 starmapTickMorph 从「每帧全量遍历」改成
starmapMorphSet 活跃集,但我漏了一处:buildChatStarmapGraph 重建图时
只重置了 starmapNodeMeshes / starmapEdgeLines,**没清空 starmapMorphSet**。

后果:旧 mesh 已从场景摘掉,却留在活跃集里,于是
- 集合随每次重建单调增长、永不归零 ⇒「空集时 O(1) 返回」的快速路径
  从此失效,正是这次优化想省掉的那笔开销又回来了
- 每帧遍历一批已经不在场景里的废弃 mesh

同时补 starmap_perf_test.go。为何用源码断言而非运行时测量:
dashboard.js 是嵌入的浏览器脚本(依赖 three.js / DOM),Node 跑不了;
而这几条都是**结构性**缺陷(循环扫全表、循环不停、每帧重建),
源码里能可靠判定,回归时立刻红。

判据可杀变异(逐个实测过,不是装饰):
- 节流常量改名 → FAIL「缺少节流」
- morph 早退条件删掉 → FAIL「必须提前返回」
- clear() 改成 if (false) 或整行删除 → FAIL「必须无条件清空」

第三处特意断言在**重建函数体内**且是 `(?m)^\s*if \(starmapMorphSet\) ...`
(先写成全仓搜 `starmapMorphSet.clear()`,变异测试显示 `if (false)` 能骗过
它 —— 弱判据当场修强)。
2026-10-08 12:40:43 +08:00

84 lines
3.4 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 (
"os"
"regexp"
"strings"
"testing"
)
// 星图渲染必须避免「每帧全量」——这些判据钉住 2026-10-08 修「非常卡」的三处。
//
// 为何用源码断言而不是运行时测量:dashboard.js 是嵌入的浏览器脚本,
// 用 Node 跑不了(依赖 three.js / DOM)。而这三条都是**结构性**缺陷
// (循环扫全表、循环不停、每帧重建),源码里能可靠判定,且回归时立刻红。
func TestStarmapNoPerFrameFullScan(t *testing.T) {
src := readDashboardJS(t)
// ① starmapTickMorph 不得再全量遍历 starmapNodeMeshes。
// 原先 3190 节点 × 60fps = 每秒 19 万次 userData 读取与分支,
// 而绝大多数帧里「正在形变」的节点是 0 个。
morph := extractFunc(t, src, "starmapTickMorph")
if strings.Contains(morph, "starmapNodeMeshes") {
t.Error("starmapTickMorph 又全量遍历 starmapNodeMeshes 了:应只遍历 starmapMorphSet 活跃集," +
"空集时 O(1) 返回(否则每帧 3190 次无谓遍历)")
}
if !strings.Contains(morph, "starmapMorphSet") {
t.Error("starmapTickMorph 必须走 starmapMorphSet 活跃集合")
}
if !regexp.MustCompile(`starmapMorphSet\.size\s*===\s*0`).MatchString(morph) {
t.Error("starmapTickMorph 必须在活跃集为空时提前返回(这是常见路径,应当零开销)")
}
// ② 渲染循环必须能被暂停(否则切走页签/后台仍 60fps 空转)。
anim := extractFunc(t, src, "starmapAnimate")
if !strings.Contains(anim, "starmapAnimateRunning") {
t.Error("starmapAnimate 缺少 starmapAnimateRunning 开关:切走页签后循环不会停")
}
if !strings.Contains(anim, "starmapActiveContainer") {
t.Error("starmapAnimate 必须按「星图容器是否可见」判断暂停," +
"不能用 document.hidden(星图可能在未激活页签里被搬走)")
}
// ③ 重建图时必须清空形变活跃集,否则集合单调增长、
// 「空集 O(1)」快速路径永不生效,每帧还遍历已废弃 mesh。
// 断言必须在**重建函数体内**且真的在调用 clear(不能只看全仓有这串,
// 否则改成 `if (false) ...clear()` 也能骗过——变异测试实测过)。
rebuild := extractFunc(t, src, "buildChatStarmapGraph")
if !regexp.MustCompile(`(?m)^\s*if \(starmapMorphSet\) starmapMorphSet\.clear\(\);`).MatchString(rebuild) {
t.Error("buildChatStarmapGraph 必须无条件清空 starmapMorphSet(`if (starmapMorphSet) starmapMorphSet.clear();`):" +
"旧 mesh 已从场景摘掉,却会留在活跃集里被永远遍历")
}
// ④ 标签层必须有节流(相机拖动时 camKey 每帧都变 → 否则每帧重建全量 plan)。
labels := extractFunc(t, src, "starmapDrawLabels")
if !strings.Contains(labels, "SM_LABEL_MIN_INTERVAL") {
t.Error("starmapDrawLabels 缺少节流:OrbitControls 有阻尼,拖动时每帧重建 3190 项 plan")
}
}
// extractFunc 取一个顶层函数的源码体(到下一个行首 `}` 为止)。
func extractFunc(t *testing.T, src, name string) string {
t.Helper()
marker := "function " + name + "("
i := strings.Index(src, marker)
if i < 0 {
t.Fatalf("找不到函数 %s", name)
}
rest := src[i:]
end := strings.Index(rest, "\n}\n")
if end < 0 {
return rest
}
return rest[:end]
}
func readDashboardJS(t *testing.T) string {
t.Helper()
b, err := os.ReadFile("dashboard.js")
if err != nil {
t.Fatal(err)
}
return string(b)
}