refactor(webui): 再迁 3 个判据到 test/ —— 去重辅助函数

## 迁移

    dashboard_js_scope_test.go  → webui/test/
    embed_read_test.go          → webui/test/
    mascot_asset_test.go        → webui/test/

这三个原先都直接读未导出的 `dashboardFS`(`go:embed` 的变量)。
上一批已经加了导出入口 `FrontendAsset(name)`,所以只需换调用即可,
不涉及行为改动。

## 迁移暴露的三处重复(一并去重)

迁过去之后编译报了三组重复定义:

    stripJSComments      helpers_test.go 与 dashboard_js_scope_test.go 各一份
    dashboardJSSource    helpers_test.go 与 dashboard_js_scope_test.go 各一份
    mustReadEmbedded     embed_read_test.go 里定义,kernel_buttons_render_test.go 在用

前两个是**迁移造成的镜像**:原本包内的辅助函数在迁到 test/ 时被复制了一份。
逐字比较确认两份 `stripJSComments` 实现完全相同,删掉包内那份、保留
`test/helpers_test.go` 里的共享版本。

第三个不同:`mustReadEmbedded` 只有包内的 `kernel_buttons_render_test.go` 在用。
把它改成直接调导出的 `FrontendAsset`,不再需要这个辅助函数 ——
包内测试从此也不再依赖"哪个变量装着 embed 资源"这种实现细节。

## 顺带清掉一批未使用导入

批量迁移+脚本改包名之后,多个文件留着已经不用的 `webui` 导入
(有的只在注释里提到 `webui.FrontendAsset`)。用 `go vet` 的输出逐个清理,
最后 `go vet ./internal/plugins/webui/...` 干净。

## 判据未失效(变异验证)

把 dashboard.js 里 ack 超时 `}, 600000);` 改成 `}, 30000);`:

    --- FAIL: TestChatAckTimeoutIsGenerous
        chat_ack_timeout_test.go:59: ★ 受理超时只有 30000 ms —— 太小。

恢复后全绿。

## 进度

    webui/        180 条(原 186,迁走 6 条)
    webui/test/    22 条
    合计          202 条,与迁移前一致(+ 之前新增的契约判据)

`webui/` 剩下 20 个测试文件,全部依赖 `handleXxx` / `newTestHandler` /
`testSDK` 等未导出 handler 与装配辅助,需要先有导出入口才能迁。
其中 `proxy_test.go`(45 处)和 `handler_test.go`(90 处)是最大两块,
要迁必须先给 webui 导出"起一个带路由的 handler"的测试入口。

## 验证

    go build ./...                          PASS
    go vet ./internal/plugins/webui/...     PASS
    gofmt -l                                干净
    go test ./... -count=1                  65 包全过,0 FAIL
    webui 连跑 3 次                          0 失败
This commit is contained in:
JianFeeeee
2026-11-04 20:23:34 +08:00
parent 06f3161e1d
commit b8ec1e84d2
5 changed files with 18 additions and 78 deletions

View File

@ -0,0 +1,168 @@
package test
import (
"bytes"
"encoding/binary"
"errors"
"github.com/JianFeeeee/HologramAgent/internal/plugins/webui"
"testing"
)
// 这个小测试钉住一件很具体、也很容易被无声破坏的事:
// **看板娘素材必须自带真 alpha,而不是「白底图」或「棋盘格假透明」。**
//
// 为什么值得一个判据:
//
// 历史上 mascot.webp 是不透明 WebP(白底 + 蓝紫渐变外框)。放在深色面板的
// 30px 圆形头像里,就是一个白方块;site/README.md 里记着因为「没法抠图」
// 最终干脆不放立绘。后来换素材时又出现过「棋盘格 JPEG」—— 那种图**看起来**
// 是透明的,实际像素是灰白格子,直接塞进仓库依然会露出格子。
//
// 这类退化在肉眼 review 里几乎抓不住(缩到 30px 就是一团浅色),但用户每天
// 都在看。所以用容器格式本身来判定:判据看的是 VP8X 的 Alpha 标志位与
// ALPH chunk 是否**一致存在**,而不是「渲染出来像不像透明」。
func TestMascotAssetHasRealAlpha(t *testing.T) {
data, err := webui.FrontendAsset("mascot.webp")
if err != nil {
t.Fatalf("读 mascot.webp: %v", err)
}
info, err := parseWebPInfo(data)
if err != nil {
t.Fatalf("mascot.webp 不是合法 WebP: %v", err)
}
if !info.hasAlphaFlag {
t.Errorf("mascot.webp 的 VP8X Alpha 标志位未置位(flags=0x%02x)——"+
"说明这张图没有真 alpha 通道,深色面板下会露出白底/棋盘格", info.vp8xFlags)
}
if !info.hasALPHChunk {
t.Errorf("mascot.webp 缺 ALPH chunk —— alpha 标志位与实际数据不一致")
}
// 尺寸:30px 圆形头像在 2x 屏上需要 60px;留够余量但别把大图塞进二进制。
if info.width != info.height {
t.Errorf("mascot.webp 应为正方形(圆形头像裁剪依赖它),实际 %dx%d", info.width, info.height)
}
if info.width < 128 {
t.Errorf("mascot.webp 只有 %dpx,2x/3x 屏上会糊(要求 >= 128)", info.width)
}
if len(data) > 128<<10 {
t.Errorf("mascot.webp 体积 %.1fKB 超过 128KB —— 它进二进制、每次请求都发,"+
"头像用途不需要这个量级", float64(len(data))/1024)
}
}
// 反向验证:把真实素材「退化」成无 alpha 的容器,确认判据会报红。
//
// 这条来自实测教训:形状类判据如果只喂真实数据,它可能对任何输入都绿。
// 这里不依赖外部工具,直接构造一个带 VP8X 但不置 Alpha 位的最小容器。
func TestMascotAssetHasRealAlpha_RejectsOpaqueContainer(t *testing.T) {
var chunks bytes.Buffer
payload := make([]byte, 10) // flags=0x00:Alpha 位为 0
payload[4], payload[5] = 0x7f, 0x00 // 宽 = 0x7f+1 = 128
payload[7], payload[8] = 0x7f, 0x00 // 高 = 0x7f+1 = 128
writeChunk(&chunks, "VP8X", payload)
info, err := parseWebPInfo(buildRIFF(chunks.Bytes()))
if err != nil {
t.Fatalf("构造的容器应能被解析: %v", err)
}
if info.hasAlphaFlag {
t.Fatal("容器本来就声明不透明,解析器却报了 Alpha —— 判据会对退化素材失效")
}
if info.hasALPHChunk {
t.Fatal("构造的容器里没有 ALPH chunk,解析器却说有")
}
}
type webpInfo struct {
width, height int
vp8xFlags byte
hasAlphaFlag bool
hasALPHChunk bool
}
var errNotWebP = errors.New("不是 RIFF/WEBP 容器")
// parseWebPInfo 只解析容器头,不解码像素 —— 判据要看的是「格式声明」,
// 不是「看起来透明」。纯标准库即可,不给依赖加负担。
func parseWebPInfo(data []byte) (*webpInfo, error) {
if len(data) < 12 || string(data[0:4]) != "RIFF" || string(data[8:12]) != "WEBP" {
return nil, errNotWebP
}
info := &webpInfo{}
// 遍历 RIFF chunk:4 字节 ID + 4 字节 LE 长度 + 负载(按 2 字节对齐)。
for off := 12; off+8 <= len(data); {
id := string(data[off : off+4])
size := int(binary.LittleEndian.Uint32(data[off+4 : off+8]))
payload := off + 8
if payload+size > len(data) {
break
}
switch id {
case "VP8X":
if size >= 10 {
info.vp8xFlags = data[payload]
info.hasAlphaFlag = info.vp8xFlags&0x10 != 0 // bit 4 = Alpha
// 画布尺寸存的是「实际值 - 1」,各 3 字节 LE。
info.width = int(readUint24LE(data[payload+4:])) + 1
info.height = int(readUint24LE(data[payload+7:])) + 1
}
case "ALPH":
info.hasALPHChunk = true
case "VP8 ":
// 无损/有损的「简单格式」(无 VP8X):宽高在帧头里。
// 这种文件必然没有 alpha —— 正是判据要拒的那种。
if size >= 10 && data[payload+3] == 0x9d && data[payload+4] == 0x01 && data[payload+5] == 0x2a {
info.width = int(binary.LittleEndian.Uint16(data[payload+6:]) & 0x3FFF)
info.height = int(binary.LittleEndian.Uint16(data[payload+8:]) & 0x3FFF)
}
case "VP8L":
if size >= 5 {
// 无损格式:signature 0x2f + 14bit 宽 + 14bit 高 + alpha_is_used 位
b := data[payload+1 : payload+5]
info.width = int(binary.LittleEndian.Uint16(b)&0x3FFF) + 1
info.height = int(binary.LittleEndian.Uint16(b[2:])&0x3FFF) + 1
if data[payload+4]&0x10 != 0 {
info.hasAlphaFlag = true
}
}
}
adv := payload + size
if size%2 == 1 {
adv++ // RIFF 要求 chunk 负载补齐到偶数
}
off = adv
}
if info.width == 0 || info.height == 0 {
return nil, errNotWebP
}
return info, nil
}
func readUint24LE(b []byte) int {
return int(b[0]) | int(b[1])<<8 | int(b[2])<<16
}
func writeChunk(buf *bytes.Buffer, id string, payload []byte) {
buf.WriteString(id)
var sz [4]byte
binary.LittleEndian.PutUint32(sz[:], uint32(len(payload)))
buf.Write(sz[:])
buf.Write(payload)
if len(payload)%2 == 1 {
buf.WriteByte(0)
}
}
func buildRIFF(chunks []byte) []byte {
var out bytes.Buffer
out.WriteString("RIFF")
var sz [4]byte
binary.LittleEndian.PutUint32(sz[:], uint32(4+len(chunks)))
out.Write(sz[:])
out.WriteString("WEBP")
out.Write(chunks)
return out.Bytes()
}