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>
166 lines
4.1 KiB
Go
166 lines
4.1 KiB
Go
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")
|
||
}
|
||
}
|