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>
248 lines
6.5 KiB
Go
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)
|
|
}
|
|
}
|