visionA/local-tool/server/internal/api/ws/firmware_ws_test.go
jim800121chen 06ff2fe987 feat(local-tool): M9-4 — Frontend FW badge + 升級 modal + WS hot-fix
A 階段第四個 milestone、完整 Frontend FW UI(badge / modal / 8 種 reason 復原)+ backend WS hot-fix(補對稱於 flash 的 firmware WS endpoint)。

Frontend(13 修改 / 7 新檔):
- 新 firmware/ component group (badge / upgrade-button / upgrade-dialog 4-phase / progress-view / error-view 8-reason / index)
- Zustand store (firmware-store.ts) + WS hook (use-firmware-progress.ts) 對齊既有 useFlashProgress pattern
- DeviceCard 整合 FirmwareBadge + FirmwareUpgradeButton
- i18n: settings.firmware.* namespace (對齊 Design Spec §9 SoT) + devices.card.fwBadge.* (zh-TW + en, 57 leaf keys × 2 lang = 114 strings)
- toast.ts ToastOptions interface (duration param)
- types/device.ts: FW 衍生欄位 + FirmwareStage/Reason/ProgressEvent/ActiveTask types

Backend WS hot-fix (3 檔):
- ws/firmware_ws.go (50 行、純對稱 flash_ws.go)
- ws/firmware_ws_test.go (165 行、2 smoke tests: broadcast + room isolation)
- router.go: GET /ws/devices/:id/firmware-progress

關鍵設計:
- R-FW-11 緩解: upgrading phase modal 不可關 (onInteractOutside/onEscapeKeyDown preventDefault + 隱藏 X)
- 多裝置隔離 defense in depth: store handleEvent activeDeviceId mismatch 直接 return
- 8 種 reason → 4 種 UX (recoverable/destructive/brick 警告/contactSupport)
- ContactSupport mailto handler (RFC 6068 + encodeURIComponent)

Reviewer 兩輪審查:
- Round 1: 0 Critical / 3 Major / 8 Minor / 5 Suggestion
- Round 2: 0 Critical / 0 Major / 0 Minor / 2 Suggestion(接受方案 A、不需 frontend 第 3 輪)
- MJ1 i18n namespace 採方案 A (settings.firmware.*)、Design SoT 優先、Reviewer 同意

測試:
- pnpm test --run: 60 tests pass (32 firmware: 22 store + 10 badge + 新 9 error-view + 19 既有)
- npx tsc --noEmit: 0 error
- pnpm build: production build 成功
- go test ./internal/api/ws/... -race: 1.964s 全綠
- pnpm lint firmware/: 0 hit (17 既有 lint 問題不屬 M9-4、follow-up)

未做(範圍外):
- Settings 韌體面板 (M9-12 B 階段)
- 手動降版 UI (M9-12)
- 版本切換 dropdown (B 階段)
- Wails 控制台 force-quit modal (M9-4.5)

A 階段 MVP 後端 + 前端開發全部完成、剩 M9-4.5 (SIGTERM + Wails OnBeforeClose) + M9-5 (三平台實機驗證)

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

166 lines
4.1 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 ws
// firmware_ws_test.go — M9-4 hot-fix smoke test
//
// 對稱 system_ws_integration_test.go啟 httptest server 掛
// FirmwareProgressHandler用 gorilla WebSocket client 真的連線進去,
// 然後 hub.BroadcastToRoom("firmware:<deviceID>", ...) 驗證 client 收到。
//
// 目的:保證 /ws/devices/:id/firmware-progress endpoint 把 client 正確
// join 到 "firmware:<deviceID>" room、且 broadcast 能送達。
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
)
func TestFirmwareProgressHandler_ReceivesBroadcast(t *testing.T) {
gin.SetMode(gin.TestMode)
hub := NewHub()
go hub.Run()
r := gin.New()
r.GET("/ws/devices/:id/firmware-progress", FirmwareProgressHandler(hub))
srv := httptest.NewServer(r)
defer srv.Close()
const deviceID = "kl520-0"
room := "firmware:" + deviceID
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/ws/devices/" + deviceID + "/firmware-progress"
dialer := websocket.DefaultDialer
conn, _, err := dialer.Dial(wsURL, http.Header{})
if err != nil {
t.Fatalf("dial: %v", err)
}
defer conn.Close()
_ = conn.SetReadDeadline(time.Now().Add(2 * time.Second))
// 等 hub 吸收 Register最多 500 ms
deadline := time.Now().Add(500 * time.Millisecond)
for time.Now().Before(deadline) {
hub.mu.RLock()
n := len(hub.rooms[room])
hub.mu.RUnlock()
if n > 0 {
break
}
time.Sleep(10 * time.Millisecond)
}
// 廣播 firmware progress 事件
hub.BroadcastToRoom(room, map[string]interface{}{
"type": "firmware:progress",
"deviceId": deviceID,
"phase": "flashing",
"percent": 42,
})
_, data, err := conn.ReadMessage()
if err != nil {
t.Fatalf("read: %v", err)
}
var got map[string]interface{}
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("json: %v; raw=%s", err, string(data))
}
if got["type"] != "firmware:progress" {
t.Errorf("wrong type: %+v", got)
}
if got["deviceId"] != deviceID {
t.Errorf("wrong deviceId: %+v", got)
}
if got["phase"] != "flashing" {
t.Errorf("wrong phase: %+v", got)
}
}
// TestFirmwareProgressHandler_RoomIsolation 驗證不同 deviceID 的 client
// 不會收到對方 room 的訊息room key 帶 deviceID 的關鍵保證)。
func TestFirmwareProgressHandler_RoomIsolation(t *testing.T) {
gin.SetMode(gin.TestMode)
hub := NewHub()
go hub.Run()
r := gin.New()
r.GET("/ws/devices/:id/firmware-progress", FirmwareProgressHandler(hub))
srv := httptest.NewServer(r)
defer srv.Close()
dialFor := func(deviceID string) *websocket.Conn {
t.Helper()
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") +
"/ws/devices/" + deviceID + "/firmware-progress"
conn, _, err := websocket.DefaultDialer.Dial(wsURL, http.Header{})
if err != nil {
t.Fatalf("dial %s: %v", deviceID, err)
}
return conn
}
connA := dialFor("kl520-0")
defer connA.Close()
connB := dialFor("kl720-1")
defer connB.Close()
// 等兩個 room 都被 hub 吸收
wantRooms := []string{"firmware:kl520-0", "firmware:kl720-1"}
deadline := time.Now().Add(500 * time.Millisecond)
for time.Now().Before(deadline) {
ok := true
hub.mu.RLock()
for _, room := range wantRooms {
if len(hub.rooms[room]) == 0 {
ok = false
break
}
}
hub.mu.RUnlock()
if ok {
break
}
time.Sleep(10 * time.Millisecond)
}
// 只 broadcast 到 kl520-0 的 room
hub.BroadcastToRoom("firmware:kl520-0", map[string]interface{}{
"type": "firmware:progress",
"deviceId": "kl520-0",
"percent": 10,
})
// connA 應該收到
_ = connA.SetReadDeadline(time.Now().Add(1 * time.Second))
_, data, err := connA.ReadMessage()
if err != nil {
t.Fatalf("connA read: %v", err)
}
var got map[string]interface{}
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("connA json: %v", err)
}
if got["deviceId"] != "kl520-0" {
t.Errorf("connA got wrong deviceId: %+v", got)
}
// connB 不該收到 — 設短 deadline預期 timeout
_ = connB.SetReadDeadline(time.Now().Add(200 * time.Millisecond))
_, _, err = connB.ReadMessage()
if err == nil {
t.Errorf("connB should not have received message but did")
}
}