jim800121chen c54f16fca0 Initial commit: visionA monorepo with local-tool subproject
local-tool/: visionA-local desktop app
- M1: Wails shell + Go server + Next.js UI + Mock mode (macOS dmg ready)
- M2: i18n (zh-TW/en) + Settings 4-tab refactor
- M3: Embedded Python 3.12 runtime (python-build-standalone) + KneronPLUS wheels
- M4: Windows Inno Setup script (build on Windows runner)
- M5: Linux AppImage script + udev rule (build on Linux runner)
- M6: ffmpeg (GPL, pending legal review) + yt-dlp bundled
- Lifecycle: watchServer health check, fatal native dialog,
            Wails IPC raise endpoint, stale process cleanup

.autoflow/: full PRD / Design Spec / Architecture / Testing docs
            (4 rounds tri-party discussion + cross review)
.github/workflows/: macOS / Windows / Linux build CI

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 22:10:38 +08:00

248 lines
6.5 KiB
Go

package api_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"visiona-local/server/internal/api"
"visiona-local/server/internal/api/handlers"
"visiona-local/server/internal/api/ws"
"visiona-local/server/internal/camera"
"visiona-local/server/internal/device"
"visiona-local/server/internal/inference"
"visiona-local/server/internal/model"
"visiona-local/server/pkg/logger"
)
// apiResponse is the standard JSON envelope for all API responses.
type apiResponse struct {
Success bool `json:"success"`
Data json.RawMessage `json:"data,omitempty"`
Error *struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error,omitempty"`
}
// setupTestServer creates a fully wired Gin router with mock devices.
func setupTestServer(t *testing.T, mockCount int) *httptest.Server {
t.Helper()
registry := device.NewRegistry()
deviceMgr := device.NewManager(registry, true, mockCount, "")
// Drain event bus to prevent blocking
go func() {
for range deviceMgr.Events() {
}
}()
modelRepo := model.NewRepository("")
modelStore := model.NewModelStore(t.TempDir())
cameraMgr := camera.NewManager(true)
inferenceSvc := inference.NewService(deviceMgr)
wsHub := ws.NewHub()
logBroadcaster := logger.NewBroadcaster(100, nil)
sysHandler := handlers.NewSystemHandler("test", "now", nil)
router := api.NewRouter(
modelRepo, modelStore, deviceMgr, cameraMgr,
inferenceSvc, wsHub, nil, logBroadcaster, sysHandler,
)
return httptest.NewServer(router)
}
func getJSON(t *testing.T, ts *httptest.Server, path string) apiResponse {
t.Helper()
resp, err := http.Get(ts.URL + path)
if err != nil {
t.Fatalf("GET %s: %v", path, err)
}
defer resp.Body.Close()
var r apiResponse
if err := json.NewDecoder(resp.Body).Decode(&r); err != nil {
t.Fatalf("GET %s: decode error: %v", path, err)
}
return r
}
func postJSON(t *testing.T, ts *httptest.Server, path string, body string) apiResponse {
t.Helper()
resp, err := http.Post(ts.URL+path, "application/json", strings.NewReader(body))
if err != nil {
t.Fatalf("POST %s: %v", path, err)
}
defer resp.Body.Close()
var r apiResponse
if err := json.NewDecoder(resp.Body).Decode(&r); err != nil {
t.Fatalf("POST %s: decode error: %v", path, err)
}
return r
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
func TestHealthCheck(t *testing.T) {
ts := setupTestServer(t, 0)
defer ts.Close()
resp, err := http.Get(ts.URL + "/api/system/health")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
t.Fatalf("health check status = %d, want 200", resp.StatusCode)
}
var data map[string]string
json.NewDecoder(resp.Body).Decode(&data)
if data["status"] != "ok" {
t.Fatalf("health check status = %q, want 'ok'", data["status"])
}
}
func TestDeviceWorkflow_MockMode(t *testing.T) {
ts := setupTestServer(t, 2)
defer ts.Close()
// 1. List devices — should have 2 mock devices
t.Run("list devices", func(t *testing.T) {
r := getJSON(t, ts, "/api/devices")
if !r.Success {
t.Fatal("list devices should succeed")
}
var data struct {
Devices []json.RawMessage `json:"devices"`
}
json.Unmarshal(r.Data, &data)
if len(data.Devices) != 2 {
t.Fatalf("expected 2 devices, got %d", len(data.Devices))
}
})
// 2. Get single device
t.Run("get device", func(t *testing.T) {
r := getJSON(t, ts, "/api/devices/mock-device-1")
if !r.Success {
t.Fatal("get device should succeed")
}
})
// 3. Get non-existing device → 404
t.Run("get device not found", func(t *testing.T) {
r := getJSON(t, ts, "/api/devices/nonexistent")
if r.Success {
t.Fatal("should fail for non-existing device")
}
if r.Error == nil || r.Error.Code != "DEVICE_NOT_FOUND" {
t.Fatalf("expected DEVICE_NOT_FOUND error, got %+v", r.Error)
}
})
// 4. Connect device
t.Run("connect device", func(t *testing.T) {
r := postJSON(t, ts, "/api/devices/mock-device-1/connect", "")
if !r.Success {
t.Fatalf("connect should succeed: %+v", r.Error)
}
// Verify status changed to connected
r2 := getJSON(t, ts, "/api/devices/mock-device-1")
var info struct {
Status string `json:"status"`
}
json.Unmarshal(r2.Data, &info)
if info.Status != "connected" {
t.Fatalf("expected status 'connected', got '%s'", info.Status)
}
})
// 5. Start inference
t.Run("start inference", func(t *testing.T) {
r := postJSON(t, ts, "/api/devices/mock-device-1/inference/start", "")
if !r.Success {
t.Fatalf("start inference should succeed: %+v", r.Error)
}
})
// 6. Stop inference
t.Run("stop inference", func(t *testing.T) {
r := postJSON(t, ts, "/api/devices/mock-device-1/inference/stop", "")
if !r.Success {
t.Fatalf("stop inference should succeed: %+v", r.Error)
}
})
// 7. Disconnect device
t.Run("disconnect device", func(t *testing.T) {
r := postJSON(t, ts, "/api/devices/mock-device-1/disconnect", "")
if !r.Success {
t.Fatalf("disconnect should succeed: %+v", r.Error)
}
})
}
func TestDeviceScan_MockMode(t *testing.T) {
ts := setupTestServer(t, 1)
defer ts.Close()
// In mock mode, scan just returns existing mock devices
r := postJSON(t, ts, "/api/devices/scan", "")
if !r.Success {
t.Fatalf("scan should succeed: %+v", r.Error)
}
var data struct {
Devices []json.RawMessage `json:"devices"`
}
json.Unmarshal(r.Data, &data)
if len(data.Devices) != 1 {
t.Fatalf("expected 1 device after scan, got %d", len(data.Devices))
}
}
func TestModelList(t *testing.T) {
ts := setupTestServer(t, 0)
defer ts.Close()
r := getJSON(t, ts, "/api/models")
if !r.Success {
t.Fatal("list models should succeed")
}
}
func TestConnectNonExistentDevice(t *testing.T) {
ts := setupTestServer(t, 1)
defer ts.Close()
r := postJSON(t, ts, "/api/devices/nonexistent/connect", "")
if r.Success {
t.Fatal("connect nonexistent device should fail")
}
}
func TestMultiDeviceIsolation(t *testing.T) {
ts := setupTestServer(t, 3)
defer ts.Close()
// Connect device 1 only
r := postJSON(t, ts, "/api/devices/mock-device-1/connect", "")
if !r.Success {
t.Fatalf("connect device 1 should succeed: %+v", r.Error)
}
// Device 2 should still be detected (not connected)
r2 := getJSON(t, ts, "/api/devices/mock-device-2")
var info struct {
Status string `json:"status"`
}
json.Unmarshal(r2.Data, &info)
if info.Status != "detected" {
t.Fatalf("device 2 should be 'detected', got '%s'", info.Status)
}
}