依 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>
152 lines
3.5 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|