visionA/local-tool/visiona-local/log_buffer_test.go
jim800121chen 8cd5751ce3 feat(local-tool): M8 重構 — Wails 控制台 + 瀏覽器 Web UI(R5 決策)
依 R5 五輪決策把 visionA-local 從「Wails 內嵌 Next.js」重構為「Wails
本機伺服器控制台 + 瀏覽器 Web UI」模式(類比 Docker Desktop / Ollama)。

程式碼變動
  - M8-1 砍 yt-dlp 全套(後端 resolver / URL handler / 前端 URL tab /
    Makefile vendor / installer / bootstrap / CI workflow,-555 行)
  - M8-2 砍 Mock 模式全套(driver/mock、mock_camera、Settings runtimeMode、
    VISIONA_MOCK 環境變數,-528 行)
  - M8-3 ffmpeg 從 GPL 切換到 LGPL 混合方案:Windows/Linux 用 BtbN 現成
    LGPL binary,macOS 自 build minimal decoder-only 進 git
    (vendor/ffmpeg/macos/ffmpeg 5.7MB + ffprobe 5.6MB,比 GPL 版省 85% 空間)
  - M8-4 Wails Server Controller:state machine、log ring buffer 2000 行、
    preferences.json atomic write、boot-id、Gin SkipPaths、shutdown 7+1 秒、
    notify_*.go 三平台 OS 通知、watchServer 改 Error state 不 os.Exit
  - M8-4b 啟動階段管線 R5-E:6 階段進度 event、20s soft / 60s hard timeout、
    stage 5/6 skip 規則、sentinel file、RestartStartupSequence 5 步驟
  - M8-5 Wails 控制台 vanilla HTML/JS/CSS(9 檔 ~2012 行)取代 M7-B splash:
    state 視覺、log panel、startup progress panel、Stage 6 manual CTA
    pulse、shutdown modal、Settings、Dark Mode、i18n 中英雙語
  - M8-6 上傳影片副檔名擴充(mp4/avi/mov/mpeg/mpg)
  - M8-7 Web UI Server Offline Overlay(role=alertdialog + focus trap +
    wsEverConnected 容錯 + Page Visibility)
  - M8-8 CORS middleware(127.0.0.1/localhost only + suffix attack 防護)+
    ws/origin.go 獨立 WebSocket CheckOrigin 避 package cycle
  - MAJ-4 server:shutdown-imminent WebSocket broadcast 機制
    (/ws/system endpoint + notifyShutdownImminent helper)
  - M8-9 Boot-ID + 瀏覽器 tab 自動重連(sessionStorage loop guard)

品質
  - ~105+ 新 unit test + race detector (-count=2) 全綠
  - 10 個 milestone 全部通過 Reviewer 審查
  - 三方 v2 + v2.1 文件(PRD / Design Spec / TDD)+ 交叉互審紀錄
    收錄在 .autoflow/

交付前待處理(M8-10)
  - 重跑 make payload-macos 把舊 GPL 77MB binary 換成新 LGPL
  - 三平台 end-to-end build 驗證

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 17:57:54 +08:00

152 lines
3.5 KiB
Go

package main
// log_buffer_test.go — M8-4 ring buffer + level parse + rate limit 的單元測試
import (
"fmt"
"testing"
"time"
)
func TestLogBuffer_AppendAndSnapshot(t *testing.T) {
b := NewLogBuffer()
if got := b.Size(); got != 0 {
t.Fatalf("initial size=%d, want 0", got)
}
// 先塞 3 行
for i := 0; i < 3; i++ {
b.Append(LogLine{
Ts: time.Now().UnixMilli(),
Stream: "stdout",
Line: fmt.Sprintf("line-%d", i),
})
}
if got := b.Size(); got != 3 {
t.Fatalf("size after 3 appends=%d, want 3", got)
}
// Snapshot(0) 應回全部
all := b.Snapshot(0)
if len(all) != 3 {
t.Fatalf("snapshot(0) len=%d, want 3", len(all))
}
for i := 0; i < 3; i++ {
if all[i].Line != fmt.Sprintf("line-%d", i) {
t.Fatalf("snapshot[%d].Line=%q", i, all[i].Line)
}
}
// Snapshot(2) 應回最後 2 行
last2 := b.Snapshot(2)
if len(last2) != 2 {
t.Fatalf("snapshot(2) len=%d", len(last2))
}
if last2[0].Line != "line-1" || last2[1].Line != "line-2" {
t.Fatalf("snapshot(2) 內容錯誤: %+v", last2)
}
}
func TestLogBuffer_WrapAround(t *testing.T) {
b := NewLogBuffer()
// 塞超過 cap 100 行
total := logBufferCap + 100
for i := 0; i < total; i++ {
b.Append(LogLine{Line: fmt.Sprintf("line-%d", i)})
}
if got := b.Size(); got != logBufferCap {
t.Fatalf("size=%d, want %d", got, logBufferCap)
}
if got := b.Dropped(); got != 100 {
t.Fatalf("dropped=%d, want 100", got)
}
// 取最後 3 行,應該是 line-{total-3} ... line-{total-1}
last3 := b.Snapshot(3)
if len(last3) != 3 {
t.Fatalf("snapshot len=%d, want 3", len(last3))
}
expected := []string{
fmt.Sprintf("line-%d", total-3),
fmt.Sprintf("line-%d", total-2),
fmt.Sprintf("line-%d", total-1),
}
for i, e := range expected {
if last3[i].Line != e {
t.Fatalf("last3[%d]=%q, want %q", i, last3[i].Line, e)
}
}
// 取全部應該是 line-100 ... line-2099
all := b.Snapshot(0)
if len(all) != logBufferCap {
t.Fatalf("all len=%d", len(all))
}
if all[0].Line != fmt.Sprintf("line-%d", total-logBufferCap) {
t.Fatalf("all[0]=%q", all[0].Line)
}
if all[len(all)-1].Line != fmt.Sprintf("line-%d", total-1) {
t.Fatalf("all[last]=%q", all[len(all)-1].Line)
}
}
func TestLogBuffer_Reset(t *testing.T) {
b := NewLogBuffer()
for i := 0; i < 10; i++ {
b.Append(LogLine{Line: fmt.Sprintf("x-%d", i)})
}
b.Reset()
if got := b.Size(); got != 0 {
t.Fatalf("size after reset=%d, want 0", got)
}
snap := b.Snapshot(0)
if len(snap) != 0 {
t.Fatalf("snapshot after reset len=%d, want 0", len(snap))
}
}
func TestLogBuffer_RateLimit(t *testing.T) {
b := NewLogBuffer()
// 一秒內前 logRateLimitBurst 次 ShouldEmit 都應為 true
allowed := 0
denied := 0
for i := 0; i < logRateLimitBurst+50; i++ {
if b.ShouldEmit() {
allowed++
} else {
denied++
}
}
if allowed != logRateLimitBurst {
t.Fatalf("allowed=%d, want %d", allowed, logRateLimitBurst)
}
if denied != 50 {
t.Fatalf("denied=%d, want 50", denied)
}
}
func TestParseLogLevel(t *testing.T) {
cases := []struct {
line string
level string
}{
{"2026/04/14 14:23:01 [INFO] server started", "info"},
{"[WARN] something odd", "warn"},
{"[ERROR] python died", "error"},
{"[DEBUG] trace", "debug"},
{"INFO: message", "info"},
{"[GIN] 200 | 2.1ms | GET /api/system/info", "info"},
{"[GIN] 404 | 0.8ms | GET /missing", "warn"},
{"[GIN] 500 | 12ms | POST /api/boom", "error"},
{"random text line", ""},
{"", ""},
}
for _, c := range cases {
got := parseLogLevel(c.line)
if got != c.level {
t.Errorf("parseLogLevel(%q)=%q, want %q", c.line, got, c.level)
}
}
}