visionA/local-tool/visiona-local/firmware_close_guard_test.go
jim800121chen ff9bbc81ed feat(local-tool): M9-4.5 — server SIGTERM + Wails OnBeforeClose firmware-aware shutdown
A 階段尾端 milestone、雙層防護避免使用者在 firmware 升級進行中關閉 app 造成 dongle brick。

Server 端 (3 改):
- main.go: SIGTERM/SIGINT goroutine 加 firmware-aware preamble
- server/internal/firmware/shutdown.go: 新 211 行(AwaitActiveTasksOrTimeout + 3 interfaces + shutdownBroadcastTask minimal struct + toBroadcastTasks helper)
- server/internal/firmware/shutdown_test.go: 新 384 行、8 tests

Wails 端 (3 新 + 2 改):
- visiona-local/main.go: OnBeforeClose 從 inline → app.OnBeforeClose
- visiona-local/app.go: App struct 加 firmwareCloseGuard
- visiona-local/firmware_close_guard.go: 新 244 行(CloseGuard + OnBeforeClose + ConfirmForceClose)
- visiona-local/firmware_close_guard_test.go: 新 280 行、8 tests
- visiona-local/query_firmware_active_tasks.go: 新 111 行(HTTP helper、fail-open、1s timeout)
- visiona-local/query_firmware_active_tasks_test.go: 新 250 行、7 tests

行為:
- Server SIGTERM 有 active task → broadcast `server:shutdown-pending` to "system" room → RequestShutdown + WaitForActiveTasks(220s) → 走原本 shutdownFn
- Wails OnBeforeClose 有 active task → emit Wails event `app:firmware-in-progress` + return true 擋住關閉
- ConfirmForceClose binding 給 frontend 第二層 FORCE 確認用、走 graceful 7+1s shutdown(不是 SIGKILL bypass、雙層防護)

Reviewer 兩輪審查:
- Round 1: 0 Critical / 1 Major / 3 Minor / 4 Suggestion
- 第 2 輪修法(3 sub-agent 平行):
  - Architect: TDD §8.6 改 event 名 `firmware:shutdown-rejected` → `server:shutdown-pending`、標題「拒絕」→「延遲」、補 payload schema 註明 tasks 不含 startTs
  - Design: control-panel.md §6a 改「SIGKILL bypass」→「graceful 7+1s 雙層防護」、補「為何不採 SIGKILL」5 點設計理由、§6a.11 IPC 規格對齊
  - Backend: MaxShutdownWait 180s → 220s(KL720 200s upgrade + 20s buffer)+ broadcast 過濾 startTs(shutdownBroadcastTask minimal struct + toBroadcastTasks helper)

測試:
- server: go test ./... -race 全綠(firmware 2.7s + api/ws/handlers)
- wails: go test ./... -race 全綠(visiona-local 11.2s、21 tests)
- 合計新增 23 unit tests race-clean、0 regression

下一步: M9-5 三平台實機驗證 + 順手修 MJ3(backend smoke test schema phase→stage / firmware:progress→firmware_progress)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 15:07:29 +08:00

281 lines
11 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 main
// firmware_close_guard_test.go — M9-4.5Wails OnBeforeClose firmware-aware 攔截邏輯測試
//
// 覆蓋情境:
// 1. forceCloseAccepted=true → 放行return false、旗標被清掉
// 2. server port=0 → 放行
// 3. queryFirmwareTasks 失敗 → fail-open 放行
// 4. hasActive=false → 放行、不 emit event
// 5. hasActive=true → emit event + return trueprevent、payload schema 正確
// 6. ConfirmForceClose → 設旗標 + 下次 evaluateClose 放行
// 7. nil deps → 防呆放行
// 8. concurrent ConfirmForceClose 與 evaluateClose → race-free
import (
"context"
"errors"
"sync"
"sync/atomic"
"testing"
)
// fakeCloseGuardDeps 是 CloseGuardDeps 的 test double。
type fakeCloseGuardDeps struct {
mu sync.Mutex
port int
queryResp FirmwareActiveTasksResponse
queryErr error
queryCalls int
emitCalls int
lastEmitPayload map[string]interface{}
logLines []string
}
func (f *fakeCloseGuardDeps) ServerPort() int {
f.mu.Lock()
defer f.mu.Unlock()
return f.port
}
func (f *fakeCloseGuardDeps) QueryFirmwareTasks(ctx context.Context, port int) (FirmwareActiveTasksResponse, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.queryCalls++
return f.queryResp, f.queryErr
}
func (f *fakeCloseGuardDeps) EmitFirmwareInProgress(payload map[string]interface{}) {
f.mu.Lock()
defer f.mu.Unlock()
f.emitCalls++
f.lastEmitPayload = payload
}
func (f *fakeCloseGuardDeps) AppLog(format string, args ...interface{}) {
f.mu.Lock()
defer f.mu.Unlock()
f.logLines = append(f.logLines, format)
}
// ──────────────────────────────────────────────────────────────────────
// 1. forceCloseAccepted=true → 放行、旗標清空
// ──────────────────────────────────────────────────────────────────────
func TestEvaluateClose_ForceAccepted(t *testing.T) {
g := NewFirmwareCloseGuard()
g.ConfirmForceClose()
deps := &fakeCloseGuardDeps{
port: 8080,
queryResp: FirmwareActiveTasksResponse{HasActive: true}, // 即使有 active task 也該放行
}
prevent := g.evaluateClose(context.Background(), deps)
if prevent {
t.Errorf("expected prevent=false when force accepted, got true")
}
// queryFirmwareTasks 不該被叫force-close 短路)
if deps.queryCalls != 0 {
t.Errorf("expected queryFirmwareTasks NOT called on force accept, got %d", deps.queryCalls)
}
if deps.emitCalls != 0 {
t.Errorf("expected no emit on force accept, got %d", deps.emitCalls)
}
// 旗標應該已被清掉、下次呼叫如沒 active 才放行
deps.queryResp = FirmwareActiveTasksResponse{HasActive: true, Tasks: []FirmwareActiveTaskSummary{{TaskID: "x"}}}
prevent2 := g.evaluateClose(context.Background(), deps)
if !prevent2 {
t.Errorf("expected prevent=true on 2nd call (flag cleared), got false")
}
}
// ──────────────────────────────────────────────────────────────────────
// 2. server port<=0 → 放行
// ──────────────────────────────────────────────────────────────────────
func TestEvaluateClose_ServerNotRunning(t *testing.T) {
g := NewFirmwareCloseGuard()
deps := &fakeCloseGuardDeps{port: 0}
prevent := g.evaluateClose(context.Background(), deps)
if prevent {
t.Errorf("expected prevent=false when server not running")
}
if deps.queryCalls != 0 {
t.Errorf("expected queryFirmwareTasks NOT called when port=0, got %d", deps.queryCalls)
}
}
// ──────────────────────────────────────────────────────────────────────
// 3. queryFirmwareTasks 失敗 → fail-open 放行
// ──────────────────────────────────────────────────────────────────────
func TestEvaluateClose_QueryError_FailOpen(t *testing.T) {
g := NewFirmwareCloseGuard()
deps := &fakeCloseGuardDeps{
port: 3721,
queryErr: errors.New("connection refused"),
}
prevent := g.evaluateClose(context.Background(), deps)
if prevent {
t.Errorf("expected prevent=false (fail-open) on query error, got true")
}
if deps.queryCalls != 1 {
t.Errorf("expected queryFirmwareTasks called once, got %d", deps.queryCalls)
}
if deps.emitCalls != 0 {
t.Errorf("expected no emit on query error, got %d", deps.emitCalls)
}
}
// ──────────────────────────────────────────────────────────────────────
// 4. hasActive=false → 放行、不 emit
// ──────────────────────────────────────────────────────────────────────
func TestEvaluateClose_NoActiveTask(t *testing.T) {
g := NewFirmwareCloseGuard()
deps := &fakeCloseGuardDeps{
port: 3721,
queryResp: FirmwareActiveTasksResponse{HasActive: false, Tasks: []FirmwareActiveTaskSummary{}},
}
prevent := g.evaluateClose(context.Background(), deps)
if prevent {
t.Errorf("expected prevent=false when no active task")
}
if deps.emitCalls != 0 {
t.Errorf("expected no emit when no active task, got %d", deps.emitCalls)
}
}
// ──────────────────────────────────────────────────────────────────────
// 5. hasActive=true → emit event + return true、payload schema 正確
// ──────────────────────────────────────────────────────────────────────
func TestEvaluateClose_HasActive_PreventAndEmit(t *testing.T) {
g := NewFirmwareCloseGuard()
deps := &fakeCloseGuardDeps{
port: 3721,
queryResp: FirmwareActiveTasksResponse{
HasActive: true,
Tasks: []FirmwareActiveTaskSummary{
{
TaskID: "upgrade-KL520-0",
DeviceID: "kl520-0",
DeviceName: "KL520 #1",
Chip: "KL520",
Direction: "upgrade",
Stage: "flashing",
ElapsedMs: 12000,
EtaSeconds: 45,
},
},
},
}
prevent := g.evaluateClose(context.Background(), deps)
if !prevent {
t.Errorf("expected prevent=true on active task")
}
if deps.emitCalls != 1 {
t.Errorf("expected emit called once, got %d", deps.emitCalls)
}
// payload schemahasActive + tasks(slice) 必須存在
if deps.lastEmitPayload["hasActive"] != true {
t.Errorf("expected payload.hasActive=true, got %v", deps.lastEmitPayload["hasActive"])
}
tasksAny, ok := deps.lastEmitPayload["tasks"].([]interface{})
if !ok {
t.Fatalf("expected payload.tasks []interface{}, got %T", deps.lastEmitPayload["tasks"])
}
if len(tasksAny) != 1 {
t.Fatalf("expected 1 task in payload, got %d", len(tasksAny))
}
task, ok := tasksAny[0].(map[string]interface{})
if !ok {
t.Fatalf("expected task is map, got %T", tasksAny[0])
}
// 重要欄位 frontend modal 會用
if task["taskId"] != "upgrade-KL520-0" {
t.Errorf("expected taskId, got %v", task["taskId"])
}
if task["deviceName"] != "KL520 #1" {
t.Errorf("expected deviceName, got %v", task["deviceName"])
}
if task["chip"] != "KL520" {
t.Errorf("expected chip, got %v", task["chip"])
}
if task["stage"] != "flashing" {
t.Errorf("expected stage, got %v", task["stage"])
}
if task["etaSeconds"] != 45 {
t.Errorf("expected etaSeconds=45, got %v", task["etaSeconds"])
}
}
// ──────────────────────────────────────────────────────────────────────
// 6. ConfirmForceClose 設旗標
// ──────────────────────────────────────────────────────────────────────
func TestConfirmForceClose_SetsAndConsumesFlag(t *testing.T) {
g := NewFirmwareCloseGuard()
if g.consumeForceCloseAccepted() {
t.Errorf("expected default flag=false")
}
g.ConfirmForceClose()
if !g.consumeForceCloseAccepted() {
t.Errorf("expected flag=true after ConfirmForceClose")
}
// consume 後旗標清掉
if g.consumeForceCloseAccepted() {
t.Errorf("expected flag=false after consume")
}
}
// ──────────────────────────────────────────────────────────────────────
// 7. nil deps → 防呆放行
// ──────────────────────────────────────────────────────────────────────
func TestEvaluateClose_NilDeps(t *testing.T) {
g := NewFirmwareCloseGuard()
prevent := g.evaluateClose(context.Background(), nil)
if prevent {
t.Errorf("expected prevent=false with nil deps (defensive)")
}
}
// ──────────────────────────────────────────────────────────────────────
// 8. concurrent ConfirmForceClose 與 evaluateClose 不 race-race 模式)
// ──────────────────────────────────────────────────────────────────────
func TestConfirmForceClose_ConcurrentAccess(t *testing.T) {
g := NewFirmwareCloseGuard()
deps := &fakeCloseGuardDeps{port: 3721, queryResp: FirmwareActiveTasksResponse{HasActive: false}}
const N = 100
var wg sync.WaitGroup
var preventCount, allowCount int64
for i := 0; i < N; i++ {
wg.Add(2)
go func() {
defer wg.Done()
g.ConfirmForceClose()
}()
go func() {
defer wg.Done()
prevent := g.evaluateClose(context.Background(), deps)
if prevent {
atomic.AddInt64(&preventCount, 1)
} else {
atomic.AddInt64(&allowCount, 1)
}
}()
}
wg.Wait()
// 不檢查具體 prevent/allow 數量race condition between Confirm + Evaluate
// 順序不可預期)、只驗 -race 模式沒抓到 race
t.Logf("concurrent stress: prevent=%d allow=%d (race-free under -race)", preventCount, allowCount)
}