visionA/local-tool/server/internal/api/ws/firmware_ws_test.go
jim800121chen 8c27da7cca test(local-tool): M9-5 — three-platform validation plan + e2e scripts + MJ3 fix
A 階段最後 milestone、出測試計畫 + 自動化腳本 + 三平台人工 checklist、使用者下週手動跑實機驗證。

Testing artifacts (8 檔、2630 行):
- .autoflow/06-testing/m9-5-validation-plan.md: 656 行(4 情境 × 3 平台 × 2 chip = 24 combo)
- 4 e2e specs (vitest + RTL + mock WS / mock fetch):
  - firmware-upgrade-happy-path.spec.ts (357 / 4 cases)
  - firmware-upgrade-error-recovery.spec.ts (356 / 4 cases + 8 reason it.each)
  - firmware-r-fw-11-modal-not-closable.spec.ts (303 / 6 cases)
  - wails-onbeforeclose-firmware-active.spec.ts (217 / 9 cases、含 5 todo 占位 M9-12)
- 3 manual checklists: macOS 264 / Windows 234 / Linux 243 行

設計取捨:
- 不引入 Playwright/Cypress (visionA-local frontend 沒裝、屬 architect 決策)、走 vitest + mock
- E2E 腳本放 06-testing/scripts/ 作 spec doc + 可選實作參考
- 實機驗證走人工 checklist (dongle 插拔 / kill process / SIGTERM 等需要實體互動)

MJ3 修復 (M9-4 reviewer round 1 留的 follow-up):
- server/internal/api/ws/firmware_ws_test.go: +16/-8
- "type": "firmware:progress" → "firmware_progress" (對齊 firmwareProgressMessage.Type)
- "phase" → "stage" (對齊 TDD §4.2 + FirmwareProgress.Stage)
- 不動 production code、只 test schema 對齊

執行建議 (給你下週):
- Day 1 P0: macOS+Win+Linux × KL520+KL720 happy path (~3h)
- Day 2 P1: R-FW-11 + disconnect_during_op + upgrade_mid_failed + 失敗注入 (4h)
- Day 3 P2: SIGTERM 延遲關閉 + Wails OnBeforeClose force-quit modal (2-3h)

測試:
- go test ./... -race 全綠 (server / wails / frontend 60 tests)
- MJ3 修復不破壞既有測試

A 階段開發 6/7 完成 (M9 文件 + M9-1 ~ M9-4.5)、剩 M9-5 實機驗證 (你下週跑)、跑完依結果決定 A 階段交付或派 sub-agent 修。

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

174 lines
4.6 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 能送達。
//
// MJ3M9-5 Testing 修smoke test 內 schema 對齊 TDD §4.2 + M9-3
// broadcast schema欄位用 `stage`(不是 `phase`、type 用
// `firmware_progress`(不是 `firmware:progress`、那是 room 名)。
// firmware_handler.go forwardProgressToWS 廣播的就是 `type: firmware_progress`
// + `stage: preparing|loading|flashing|verifying|done|error`。
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 事件schema 對齊 TDD §4.2 + handler 端
// firmwareProgressMessage struct
hub.BroadcastToRoom(room, map[string]interface{}{
"type": "firmware_progress",
"deviceId": deviceID,
"stage": "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["stage"] != "flashing" {
t.Errorf("wrong stage: %+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 的 roomschema 對齊 TDD §4.2
hub.BroadcastToRoom("firmware:kl520-0", map[string]interface{}{
"type": "firmware_progress",
"deviceId": "kl520-0",
"stage": "preparing",
"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")
}
}