依 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>
113 lines
2.8 KiB
Go
113 lines
2.8 KiB
Go
package main
|
||
|
||
// preferences_test.go — M8-4 Preferences 持久化單元測試
|
||
|
||
import (
|
||
"encoding/json"
|
||
"os"
|
||
"path/filepath"
|
||
"runtime"
|
||
"testing"
|
||
)
|
||
|
||
func TestDefaultPreferences_PlatformSpecific(t *testing.T) {
|
||
p := DefaultPreferences()
|
||
wantOpen := runtime.GOOS != "linux"
|
||
if p.AutoOpenBrowser != wantOpen {
|
||
t.Fatalf("DefaultPreferences.AutoOpenBrowser=%v on %s, want %v", p.AutoOpenBrowser, runtime.GOOS, wantOpen)
|
||
}
|
||
if p.Locale != "" {
|
||
t.Fatalf("default Locale=%q, want empty", p.Locale)
|
||
}
|
||
if p.LogRingSize != 0 {
|
||
t.Fatalf("default LogRingSize=%d, want 0", p.LogRingSize)
|
||
}
|
||
}
|
||
|
||
func TestLoadPreferences_MissingFile(t *testing.T) {
|
||
dir := t.TempDir()
|
||
p := LoadPreferences(dir)
|
||
want := DefaultPreferences()
|
||
if p != want {
|
||
t.Fatalf("LoadPreferences on empty dir=%+v, want default %+v", p, want)
|
||
}
|
||
}
|
||
|
||
func TestLoadPreferences_CorruptJSON(t *testing.T) {
|
||
dir := t.TempDir()
|
||
path := preferencesPath(dir)
|
||
if err := os.WriteFile(path, []byte("{not json"), 0o644); err != nil {
|
||
t.Fatalf("prep: %v", err)
|
||
}
|
||
p := LoadPreferences(dir)
|
||
want := DefaultPreferences()
|
||
if p != want {
|
||
t.Fatalf("LoadPreferences on corrupt json=%+v, want default %+v", p, want)
|
||
}
|
||
}
|
||
|
||
func TestSaveAndLoadPreferences_Roundtrip(t *testing.T) {
|
||
dir := t.TempDir()
|
||
|
||
orig := Preferences{
|
||
AutoOpenBrowser: false,
|
||
Locale: "zh-TW",
|
||
LogRingSize: 1000,
|
||
}
|
||
if err := SavePreferences(dir, orig); err != nil {
|
||
t.Fatalf("Save: %v", err)
|
||
}
|
||
|
||
// 確認 .tmp 不存在(rename 已完成)
|
||
if _, err := os.Stat(preferencesPath(dir) + ".tmp"); !os.IsNotExist(err) {
|
||
t.Fatalf("tmp file should not exist after rename, got err=%v", err)
|
||
}
|
||
|
||
// 檢查檔案內容是合法 JSON
|
||
raw, err := os.ReadFile(preferencesPath(dir))
|
||
if err != nil {
|
||
t.Fatalf("read file: %v", err)
|
||
}
|
||
var check Preferences
|
||
if err := json.Unmarshal(raw, &check); err != nil {
|
||
t.Fatalf("unmarshal: %v", err)
|
||
}
|
||
if check != orig {
|
||
t.Fatalf("on-disk=%+v, want %+v", check, orig)
|
||
}
|
||
|
||
// Load 回來應該一致
|
||
loaded := LoadPreferences(dir)
|
||
if loaded != orig {
|
||
t.Fatalf("loaded=%+v, want %+v", loaded, orig)
|
||
}
|
||
}
|
||
|
||
func TestSavePreferences_AtomicRename(t *testing.T) {
|
||
dir := t.TempDir()
|
||
// 預先寫一個舊 preferences.json
|
||
oldData := `{"autoOpenBrowser":true,"locale":"en-US"}`
|
||
if err := os.WriteFile(preferencesPath(dir), []byte(oldData), 0o644); err != nil {
|
||
t.Fatalf("prep: %v", err)
|
||
}
|
||
|
||
// 寫新的
|
||
newPref := Preferences{AutoOpenBrowser: false, Locale: "zh-TW"}
|
||
if err := SavePreferences(dir, newPref); err != nil {
|
||
t.Fatalf("Save: %v", err)
|
||
}
|
||
|
||
loaded := LoadPreferences(dir)
|
||
if loaded != newPref {
|
||
t.Fatalf("loaded=%+v, want %+v", loaded, newPref)
|
||
}
|
||
|
||
// tmp cleanup 後 data dir 只該有 preferences.json
|
||
entries, _ := os.ReadDir(dir)
|
||
for _, e := range entries {
|
||
if filepath.Ext(e.Name()) == ".tmp" {
|
||
t.Fatalf("leftover tmp file: %s", e.Name())
|
||
}
|
||
}
|
||
}
|