package web import ( "safesight-control/internal/config" "safesight-control/internal/models" "safesight-control/internal/service" "safesight-control/internal/storage" "bytes" "context" "encoding/json" "fmt" "mime/multipart" "net" "net/http" "net/http/httptest" "net/url" "os" "path/filepath" "strconv" "strings" "testing" "time" "github.com/go-chi/chi/v5" ) func TestUI_ActionDevicesBatchAction_RedirectsToTask(t *testing.T) { cfg := &config.Config{Concurrency: 1, OfflineAfterMs: 1000000} reg := service.NewRegistryService(cfg, nil) reg.UpdateDevice(&models.Device{DeviceID: "dev1", IP: "127.0.0.1", AgentPort: 9100, Online: true}) reg.UpdateDevice(&models.Device{DeviceID: "dev2", IP: "127.0.0.1", AgentPort: 9100, Online: true}) tasks := service.NewTaskService(cfg, nil, reg) ui, err := NewUI(nil, reg, nil, tasks, nil) if err != nil { t.Fatalf("NewUI: %v", err) } form := url.Values{} form.Set("action", "media_start") form.Add("device_id", "dev1") form.Add("device_id", "dev2") form.Set("config", "cam1") req := httptest.NewRequest(http.MethodPost, "/ui/devices/batch-action", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr := httptest.NewRecorder() ui.actionDevicesBatchAction(rr, req) if rr.Code != http.StatusFound { t.Fatalf("expected 302, got %d: %s", rr.Code, rr.Body.String()) } loc := rr.Header().Get("Location") if !strings.HasPrefix(loc, "/tasks/") { t.Fatalf("expected redirect to /ui/tasks/*, got %q", loc) } } func TestUI_ActionDevicesBatchActionDeduplicatesKnownDevices(t *testing.T) { ui := newTestUI(t) ui.registry.UpdateDevice(&models.Device{DeviceID: "edge-02", DeviceName: "辅助节点", IP: "127.0.0.2", AgentPort: 9100, MediaPort: 9000, Online: true}) form := url.Values{} form.Set("action", "reload") form.Add("device_id", "edge-01") form.Add("device_id", "edge-01") form.Add("device_id", "edge-02") form.Add("device_id", "missing") req := httptest.NewRequest(http.MethodPost, "/ui/devices/batch-action", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr := httptest.NewRecorder() ui.actionDevicesBatchAction(rr, req) if rr.Code != http.StatusFound { t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String()) } loc := rr.Header().Get("Location") if !strings.HasPrefix(loc, "/tasks/") { t.Fatalf("expected redirect to task page, got %q", loc) } taskID := strings.TrimPrefix(loc, "/tasks/") items := ui.tasks.ListTasks() var task *models.Task for i := range items { if items[i].ID == taskID { t := items[i] task = &t break } } if task == nil { t.Fatalf("expected task %s to exist", taskID) } if got := len(task.DeviceIDs); got != 2 { t.Fatalf("expected deduplicated device count 2, got %d: %#v", got, task.DeviceIDs) } if task.DeviceIDs[0] != "edge-01" || task.DeviceIDs[1] != "edge-02" { t.Fatalf("expected selection order preserved, got %#v", task.DeviceIDs) } } func TestUI_SelectedDeviceQueryHelpersStayStable(t *testing.T) { ids := selectedIDsFromQuery([]string{" edge-02 ", "edge-01", "edge-02", ""}) if len(ids) != 2 || ids[0] != "edge-02" || ids[1] != "edge-01" { t.Fatalf("selectedIDsFromQuery normalized to %#v", ids) } if got := selectedQueryString(ids); got != "selected=edge-02&selected=edge-01" { t.Fatalf("selectedQueryString returned %q", got) } } func TestUI_ConsoleTypographyStaysModerate(t *testing.T) { css, err := os.ReadFile("ui/assets/style.css") if err != nil { t.Fatalf("read stylesheet: %v", err) } text := string(css) for _, want := range []string{ `body[data-theme="blue-light"]`, `body[data-theme="graphite-gold"]`, "--radius:4px", ".nav-section{padding:14px 10px 6px;font-size:11px", ".side-nav a{display:flex;align-items:center;gap:10px;padding:9px 10px;border-radius:var(--radius);color:var(--sidebar-text);font-size:13px;font-weight:500}", ".topbar h1{margin:0;font-size:18px;font-weight:600", ".card h2{margin:0 0 8px;font-size:15px;font-weight:600;color:var(--text)}", ".card h3{margin:0 0 6px;font-size:13px;font-weight:600;color:var(--text)}", ".btn.ghost.icon-only{background:transparent;border-color:transparent;color:var(--muted)}", ".topbar{height:60px;background:var(--topbar);border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;padding:0 28px;position:sticky;top:0;z-index:100}", ".topbar-icon-btn{position:relative", ".theme-menu-panel{position:fixed;right:28px;top:54px;", "z-index:9999", ".btn,button{display:inline-flex", ".stats{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px}", ".card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius)", } { if !strings.Contains(text, want) { t.Fatalf("expected stylesheet to contain %q", want) } } } func TestUI_BlueDarkThemeKeepsWorkspaceDarkAndTablesReadable(t *testing.T) { css, err := os.ReadFile("ui/assets/style.css") if err != nil { t.Fatalf("read stylesheet: %v", err) } text := string(css) for _, want := range []string{ "--bg:#090909", "--surface:#121212", "--surface-soft:#1a1a1a", "--table-text:#c8c8c8", "--table-link:#f4f4f4", "--primary:#dddddd", "--selected-row:#303030", "--border:#303030", "--border-strong:#565656", "--button-soft:#1d1d1d", "--button-soft-hover:#292929", "--input-bg:#101010", "--green:#66c98f", "--amber:#d8a657", "--red:#e46f72", ".card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:16px 18px;margin:14px 0;box-shadow:var(--shadow);color:var(--text)}", ".form-hint{color:var(--muted);font-size:12px;line-height:1.35}", "th,td{padding:8px 12px;border-bottom:1px solid var(--border);text-align:left;vertical-align:middle;line-height:1.25;color:var(--table-text)}", ".table-wrap td a{color:var(--table-link)}", ".device-name{font-size:13px;font-weight:400;color:var(--table-link)}", ".asset-link>span:first-child{color:var(--table-link)}", } { if !strings.Contains(text, want) { t.Fatalf("expected blue dark theme stylesheet to contain %q", want) } } } func newTestUI(t *testing.T) *UI { t.Helper() cfg := &config.Config{Concurrency: 1, OfflineAfterMs: 1000000} store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) if err != nil { t.Fatalf("OpenSQLite: %v", err) } t.Cleanup(func() { _ = store.Close() }) reg := service.NewRegistryService(cfg, nil, storage.NewDevicesRepo(store.DB())) reg.UpdateDevice(&models.Device{DeviceID: "edge-01", DeviceName: "入口识别节点", IP: "127.0.0.1", AgentPort: 9100, MediaPort: 9000, Online: true, Version: "1.0.0"}) tasks := service.NewTaskService(cfg, nil, reg) ui, err := NewUI(nil, reg, nil, tasks, nil) if err != nil { t.Fatalf("NewUI: %v", err) } return ui } func renderPage(t *testing.T, ui *UI, path string) string { t.Helper() router, err := ui.Routes() if err != nil { t.Fatalf("build routes: %v", err) } if strings.HasPrefix(path, "/ui/") { path = strings.TrimPrefix(path, "/ui") } req := httptest.NewRequest(http.MethodGet, path, nil) rr := httptest.NewRecorder() router.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Fatalf("expected 200 for %s, got %d: %s", path, rr.Code, rr.Body.String()) } return rr.Body.String() } func TestUI_DeviceOverviewShowsBatchBarWhenDevicesSelected(t *testing.T) { ui := newTestUI(t) ui.registry.UpdateDevice(&models.Device{DeviceID: "edge-02", DeviceName: "辅助节点", IP: "127.0.0.2", AgentPort: 9100, MediaPort: 9000, Online: true}) req := httptest.NewRequest(http.MethodGet, "/ui/devices?selected=edge-01&selected=edge-02", nil) rr := httptest.NewRecorder() ui.pageDevices(rr, req) if rr.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) } body := rr.Body.String() for _, want := range []string{"batch-toolbar", "已选 2 台", "重启服务", "启动服务", "停止服务", "重载运行配置", "回滚运行配置", "下发设备分配", "清空选择", "将重载当前运行配置", "将回滚到上一版运行配置"} { if !strings.Contains(body, want) { t.Fatalf("expected batch controls HTML to contain %q, got:\n%s", want, body) } } for _, forbidden := range []string{"批量配置", "重载服务"} { if strings.Contains(body, forbidden) { t.Fatalf("device overview batch controls should not contain %q, got:\n%s", forbidden, body) } } if !strings.Contains(body, `href="/devices/batch-config?selected=edge-01&selected=edge-02"`) { t.Fatalf("device overview batch config link should preserve selected query params, got:\n%s", body) } } func TestUI_ActionDevicesBatchActionKeepsDevicesOnError(t *testing.T) { ui := newTestUI(t) ui.registry.UpdateDevice(&models.Device{DeviceID: "edge-02", DeviceName: "辅助节点", IP: "127.0.0.2", AgentPort: 9100, MediaPort: 9000, Online: true}) form := url.Values{} form.Set("action", "nope") form.Add("device_id", "edge-01") form.Add("device_id", "edge-02") req := httptest.NewRequest(http.MethodPost, "/ui/devices/batch-action", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr := httptest.NewRecorder() ui.actionDevicesBatchAction(rr, req) if rr.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) } body := rr.Body.String() for _, want := range []string{"不支持的操作: nope", "入口识别节点", "辅助节点", "已选 2 台", `value="edge-01" checked`, `value="edge-02" checked`} { if !strings.Contains(body, want) { t.Fatalf("expected error render to contain %q, got:\n%s", want, body) } } } func createBatchConfigMediaRepo(t *testing.T) string { t.Helper() root := t.TempDir() writeTestFile(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{ "name":"std_workshop_face_recognition_shoe_alarm", "description":"helmet and shoe alarm", "template":{"nodes":[],"edges":[]} }`) writeTestFile(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{ "name":"local_3588_test", "business_name":"A厂区视觉识别", "description":"默认班次识别配置", "instances":[ { "name":"cam1", "template":"std_workshop_face_recognition_shoe_alarm", "scene_meta":{"display_name":"东门入口","site_name":"A厂区"}, "input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}, "output_bindings":{"stream_output_main":{"channel_no":"cam1"}} } ] }`) writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{"name":"overlay"}`) writeTestFile(t, filepath.Join(root, "tools", "render_config.py"), `import argparse import json import os parser = argparse.ArgumentParser() parser.add_argument("--template", required=True) parser.add_argument("--profile", required=True) parser.add_argument("--out", required=True) parser.add_argument("--config-id", required=True) parser.add_argument("--config-version", required=True) parser.add_argument("--rendered-at", required=True) parser.add_argument("--overlay", action="append", default=[]) args = parser.parse_args() with open(args.profile, "r", encoding="utf-8") as fh: profile_doc = json.load(fh) doc = { "metadata": { "config_id": args.config_id, "config_version": args.config_version, "template": os.path.splitext(os.path.basename(args.template))[0], "profile": profile_doc.get("name") or os.path.splitext(os.path.basename(args.profile))[0], "overlays": [os.path.splitext(os.path.basename(item))[0] for item in args.overlay], "rendered_at": args.rendered_at, }, "pipelines": [], } with open(args.out, "w", encoding="utf-8") as fh: json.dump(doc, fh, ensure_ascii=False, indent=2) `) return root } func createProfileEditorMediaRepo(t *testing.T) string { t.Helper() root := t.TempDir() writeTestFile(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{ "name": "std_workshop_face_recognition_shoe_alarm", "slots": { "inputs": [ {"name": "video_input_main", "type": "video_source", "required": true, "description": "主视频输入"} ], "services": [ {"name": "object_storage_main", "type": "object_storage", "required": false, "description": "对象存储"}, {"name": "token_service_main", "type": "token_service", "required": false, "description": "认证服务"}, {"name": "alarm_service_main", "type": "alarm_service", "required": false, "description": "告警服务"} ], "outputs": [ {"name": "stream_output_main", "type": "stream_publish", "required": true, "description": "主视频输出"} ] }, "template": {"nodes": [], "edges": []} }`) writeTestFile(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{ "name": "local_3588_test", "description": "test profile", "business_name": "A厂区视觉识别", "queue": {"size": 8, "strategy": "drop_oldest"}, "instances": [ { "name": "cam1", "template": "std_workshop_face_recognition_shoe_alarm", "params": {"queue_debug": true}, "scene_meta": {"display_name": "东门入口", "device_code": "rk3588-a-001", "site_name": "A厂区"}, "input_bindings": {"video_input_main": {"video_source_ref": "gate_cam_01"}}, "service_bindings": { "object_storage_main": {"service_ref": "minio_main"}, "token_service_main": {"service_ref": "token_main"}, "alarm_service_main": {"service_ref": "alarm_main"} }, "output_bindings": {"stream_output_main": {"publish_hls_path": "./web/hls/cam1/index.m3u8", "publish_rtsp_port": 8555, "publish_rtsp_path": "/live/cam1", "channel_no": "cam1"}} }, { "name": "cam2", "template": "std_workshop_face_recognition_shoe_alarm", "scene_meta": {"display_name": "西门入口", "site_name": "A厂区"}, "input_bindings": {"video_input_main": {"video_source_ref": "line_cam_02"}}, "output_bindings": {"stream_output_main": {"publish_hls_path": "./web/hls/cam2/index.m3u8", "publish_rtsp_port": 8555, "publish_rtsp_path": "/live/cam2", "channel_no": "cam2"}} }, { "name": "cam3", "template": "std_workshop_face_recognition_shoe_alarm", "scene_meta": {"display_name": "1号产线", "site_name": "A厂区"}, "input_bindings": {"video_input_main": {"video_source_ref": "line_cam_03"}}, "output_bindings": {"stream_output_main": {"publish_hls_path": "./web/hls/cam3/index.m3u8", "publish_rtsp_port": 8555, "publish_rtsp_path": "/live/cam3", "channel_no": "cam3"}} }, { "name": "cam4", "template": "std_workshop_face_recognition_shoe_alarm", "scene_meta": {"display_name": "2号产线", "site_name": "A厂区"}, "input_bindings": {"video_input_main": {"video_source_ref": "line_cam_04"}}, "output_bindings": {"stream_output_main": {"publish_hls_path": "./web/hls/cam4/index.m3u8", "publish_rtsp_port": 8555, "publish_rtsp_path": "/live/cam4", "channel_no": "cam4"}} } ] }`) writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{}`) return root } func withChiURLParam(req *http.Request, key string, value string) *http.Request { rctx := chi.NewRouteContext() rctx.URLParams.Add(key, value) return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) } func createBatchConfigBrokenMediaRepo(t *testing.T) string { t.Helper() root := t.TempDir() writeTestFile(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{"name":"std_workshop_face_recognition_shoe_alarm","template":{"nodes":[],"edges":[]}}`) writeTestFile(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{ "name":"local_3588_test", "business_name":"A厂区视觉识别", "instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","scene_meta":{"display_name":"东门入口"},"input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}] }`) writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{"name":"overlay"}`) return root } func writeTestFile(t *testing.T, path string, body string) { t.Helper() if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatalf("mkdir %s: %v", filepath.Dir(path), err) } if err := os.WriteFile(path, []byte(body), 0o644); err != nil { t.Fatalf("write %s: %v", path, err) } } func mustSaveIntegrationService(t *testing.T, repo *storage.AssetsRepo, name string, serviceType string, body string) { t.Helper() if err := repo.SaveIntegrationService(name, serviceType, name, true, body); err != nil { t.Fatalf("SaveIntegrationService(%s): %v", name, err) } } func mustSaveTemplate(t *testing.T, repo *storage.AssetsRepo, name string, description string, body string) { t.Helper() if err := repo.SaveTemplate(name, description, body); err != nil { t.Fatalf("SaveTemplate(%s): %v", name, err) } } func mustImportAssetsForUI(t *testing.T, preview *service.ConfigPreviewService) { t.Helper() if _, err := preview.ImportAssetsFromMediaRepo(); err != nil { t.Fatalf("ImportAssetsFromMediaRepo: %v", err) } } func mustSaveVideoSource(t *testing.T, repo *storage.AssetsRepo, name string, body string) { t.Helper() if err := repo.SaveVideoSource(0, name, "rtsp", "", name, body); err != nil { t.Fatalf("SaveVideoSource(%s): %v", name, err) } } func TestUI_DevicePageIncludesFilterAndControlEntry(t *testing.T) { ui := newTestUI(t) req := httptest.NewRequest(http.MethodGet, "/ui/devices", nil) rr := httptest.NewRecorder() ui.pageDevices(rr, req) body := rr.Body.String() for _, want := range []string{"device-filter", "详情", "控制"} { if !strings.Contains(body, want) { t.Fatalf("expected device page HTML to contain %q", want) } } for _, forbidden := range []string{"查看基础配置", "查看操作审计"} { if strings.Contains(body, forbidden) { t.Fatalf("device overview should not contain redundant link %q", forbidden) } } } func TestUI_DeviceDetailIncludesWorkspaceSections(t *testing.T) { ui := newTestUI(t) routes, err := ui.Routes() if err != nil { t.Fatalf("Routes: %v", err) } rr := httptest.NewRecorder() routes.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/devices/edge-01", nil)) body := rr.Body.String() for _, want := range []string{"设备详情", "当前设备", "设备工作台", "概览", "运行与服务", "设备分配"} { if !strings.Contains(body, want) { t.Fatalf("expected device detail HTML to contain %q", want) } } for _, forbidden := range []string{"device-tabs", "返回设备总览", "进入管理"} { if strings.Contains(body, forbidden) { t.Fatalf("device detail should not contain redundant nav %q", forbidden) } } } func TestUI_DeviceDetailUsesUnifiedWorkspace(t *testing.T) { ui := newTestUI(t) routes, err := ui.Routes() if err != nil { t.Fatalf("Routes: %v", err) } rr := httptest.NewRecorder() routes.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/devices/edge-01", nil)) body := rr.Body.String() for _, want := range []string{"设备详情", "设备工作台", "运行与服务", "设备分配", "模型与资源", "日志与指标"} { if !strings.Contains(body, want) { t.Fatalf("expected unified device workspace HTML to contain %q", want) } } for _, forbidden := range []string{"只读查看页", "权威摘要位置", "当前框架版"} { if strings.Contains(body, forbidden) { t.Fatalf("device detail should not contain placeholder copy %q", forbidden) } } for _, forbidden := range []string{ `action="/ui/devices/edge-01/alias"`, } { if strings.Contains(body, forbidden) { t.Fatalf("device detail workspace should not contain obsolete entry %q", forbidden) } } } func TestUI_DeviceSubpagesIncludeContextNavigation(t *testing.T) { ui := newTestUI(t) dev := &models.Device{DeviceID: "edge-01", DeviceName: "入口识别节点", IP: "127.0.0.1", AgentPort: 9100, MediaPort: 9000, Online: true} for _, content := range []string{"device_logs", "device_graphs", "config_ui"} { req := httptest.NewRequest(http.MethodGet, "/ui/devices/edge-01", nil) rr := httptest.NewRecorder() ui.render(rr, req, content, PageData{Title: "节点子页面", Device: dev}) body := rr.Body.String() for _, want := range []string{"当前设备"} { if !strings.Contains(body, want) { t.Fatalf("expected %s HTML to contain %q", content, want) } } if strings.Contains(body, "device-tabs") { t.Fatalf("expected %s HTML to avoid device cross-page tabs", content) } } } func TestUI_ConfigFriendlyIncludesWizard(t *testing.T) { ui := newTestUI(t) routes, err := ui.Routes() if err != nil { t.Fatalf("Routes: %v", err) } rr := httptest.NewRecorder() routes.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/devices/edge-01/config-friendly", nil)) body := rr.Body.String() for _, want := range []string{"config-wizard", "选择节点", "编辑视频通道", "预览变更", "部署结果", "data-wizard-section=\"channels\"", "下一步:编辑视频通道", "前往预览变更", "查看部署结果"} { if !strings.Contains(body, want) { t.Fatalf("expected config page HTML to contain %q", want) } } } func TestUI_ConfigPreviewCollapsesJSONAfterActionResult(t *testing.T) { ui := newTestUI(t) req := httptest.NewRequest(http.MethodGet, "/ui/devices/edge-01/config-preview", nil) rr := httptest.NewRecorder() ui.render(rr, req, "config_preview", PageData{ Title: "配置预览", Device: &models.Device{DeviceID: "edge-01", DeviceName: "入口识别节点", IP: "127.0.0.1", AgentPort: 9100}, ConfigSources: service.ConfigPreviewSources{Templates: []service.ConfigSource{{Name: "std_workshop_face_recognition_shoe_alarm"}}, Profiles: []service.ConfigSource{{Name: "local_3588_test"}}, Overlays: []service.ConfigSource{{Name: "face_debug"}}}, SelectedTemplate: "std_workshop_face_recognition_shoe_alarm", SelectedProfile: "local_3588_test", SelectedOverlays: []string{"face_debug"}, SelectedConfigID: "preview_edge-01", ConfigPreview: &service.ConfigPreviewResult{ JSON: `{"templates":{"tpl":{"nodes":[],"edges":[]}},"instances":[]}`, Metadata: map[string]any{"config_id": "preview_edge-01"}, Size: 64, }, ResultTitle: "候选配置结果", RawText: `{"ok":true}`, }) body := rr.Body.String() if !strings.Contains(body, "") { t.Fatalf("expected json panel to be collapsed after action result, got:\n%s", body) } } func TestUI_ConfigPreviewExplainsConfigIdentityFields(t *testing.T) { ui := newTestUI(t) req := httptest.NewRequest(http.MethodGet, "/ui/devices/edge-01/config-preview", nil) rr := httptest.NewRecorder() ui.render(rr, req, "config_preview", PageData{ Title: "配置预览", Device: &models.Device{DeviceID: "edge-01", DeviceName: "入口识别节点", IP: "127.0.0.1", AgentPort: 9100}, SelectedTemplate: "std_workshop_face_recognition_shoe_alarm", SelectedProfile: "local_3588_test", SelectedOverlays: []string{"face_debug"}, SelectedConfigID: "preview_edge-01", ConfigPreview: &service.ConfigPreviewResult{ JSON: `{"templates":{"tpl":{"nodes":[],"edges":[]}},"instances":[],"metadata":{"config_id":"preview_edge-01","config_version":"v2","overlays":["face_debug"]}}`, Metadata: map[string]any{ "config_id": "preview_edge-01", "config_version": "v2", "template": "std_workshop_face_recognition_shoe_alarm", "profile": "local_3588_test", "overlays": []any{"face_debug"}, }, Sha256: "eecdf8d422705f3affa0f892199604f037f60ea8fe578fe2a65527e1800044c5", Size: 64, }, }) body := rr.Body.String() for _, want := range []string{ "config_id", "config_version", "SHA256", "调试参数", "face_debug", } { if !strings.Contains(body, want) { t.Fatalf("expected config preview HTML to contain %q, got:\n%s", want, body) } } for _, forbidden := range []string{ "是配置名,默认会带上", "表示本次生成版本", "是最终文件内容指纹", } { if strings.Contains(body, forbidden) { t.Fatalf("config preview should not contain explanatory copy %q", forbidden) } } } func TestUI_LoadConfigStatusPrefersPreviousConfigFields(t *testing.T) { agentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet || r.URL.Path != "/v1/config/status" { t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{ "ok": true, "metadata": {"config_id": "preview-edge", "config_version": "v2"}, "previous_config_path": "/opt/rk3588-media-server/etc/media-server.json.last_good.json", "previous_config": { "exists": true, "path": "/opt/rk3588-media-server/etc/media-server.json.last_good.json", "sha256": "abc12345", "metadata": {"config_id": "prev-edge", "config_version": "v1", "overlays": ["face_debug"]} }, "media_server": {"running": true, "pid": 1234} }`)) })) defer agentServer.Close() host, portText, err := net.SplitHostPort(strings.TrimPrefix(agentServer.URL, "http://")) if err != nil { t.Fatalf("parse test server address: %v", err) } port, err := strconv.Atoi(portText) if err != nil { t.Fatalf("parse test server port: %v", err) } cfg := &config.Config{Concurrency: 1, OfflineAfterMs: 1000000} agent := service.NewAgentClient(cfg) reg := service.NewRegistryService(cfg, agent) reg.UpdateDevice(&models.Device{DeviceID: "edge-01", DeviceName: "入口识别节点", IP: host, AgentPort: port, MediaPort: 9000, Online: true}) tasks := service.NewTaskService(cfg, agent, reg) ui, err := NewUI(nil, reg, agent, tasks, nil) if err != nil { t.Fatalf("NewUI: %v", err) } status, _, err := ui.loadConfigStatus(&models.Device{DeviceID: "edge-01", IP: host, AgentPort: port}) if err != nil { t.Fatalf("loadConfigStatus: %v", err) } if status == nil || status.PreviousConfig == nil { t.Fatalf("expected previous config to be present, got %#v", status) } if status.PreviousConfig.Metadata.ConfigID != "prev-edge" { t.Fatalf("previous config metadata = %#v", status.PreviousConfig.Metadata) } } func TestUI_ModelsPageShowsStandardModelsAndDeviceStatus(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/v1/models/status": w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"models":[{"name":"face_det_scrfd_500m_640_rk3588","file_name":"face_det_scrfd_500m_640_rk3588.rknn","sha256":"sha-1","size_bytes":123},{"name":"best-640","file_name":"best-640.rknn","sha256":"sha-x","size_bytes":456}]}`)) default: http.NotFound(w, r) } })) defer server.Close() host, portText, err := net.SplitHostPort(strings.TrimPrefix(server.Listener.Addr().String(), "[::]")) if err != nil { t.Fatalf("SplitHostPort: %v", err) } port, err := strconv.Atoi(portText) if err != nil { t.Fatalf("Atoi: %v", err) } cfg := &config.Config{Concurrency: 1, OfflineAfterMs: 1000000} dbPath := filepath.Join(t.TempDir(), "models.db") store, err := storage.OpenSQLite(dbPath) if err != nil { t.Fatalf("OpenSQLite: %v", err) } defer store.Close() reg := service.NewRegistryService(cfg, nil, storage.NewDevicesRepo(store.DB())) reg.UpdateDevice(&models.Device{DeviceID: "edge-01", DeviceName: "入口识别节点", IP: host, AgentPort: port, MediaPort: 9000, Online: true}) agent := service.NewAgentClient(cfg) tasks := service.NewTaskService(cfg, agent, reg) ui, err := NewUI(nil, reg, agent, tasks, nil) if err != nil { t.Fatalf("NewUI: %v", err) } modelsRepo := storage.NewModelsRepo(store.DB()) if err := modelsRepo.Save(storage.StandardModelRecord{ Name: "face_det_scrfd_500m_640_rk3588", FileName: "face_det_scrfd_500m_640_rk3588.rknn", Version: "v1", SHA256: "sha-1", SizeBytes: 123, ModelType: "face_detection", }); err != nil { t.Fatalf("Save: %v", err) } ui.SetDBPath(dbPath) req := httptest.NewRequest(http.MethodGet, "/ui/models", nil) rr := httptest.NewRecorder() ui.pageModels(rr, req) if rr.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) } body := rr.Body.String() for _, want := range []string{"模型管理", "标准模型", "设备模型状态", "完整设备数", "缺失设备数", "版本不一致设备数", "更新全部模型", "非标准模型", "人脸检测"} { if !strings.Contains(body, want) { t.Fatalf("expected model management HTML to contain %q", want) } } for _, want := range []string{"1 个 · 更多", "best-640.rknn"} { if !strings.Contains(body, want) { t.Fatalf("expected model management HTML to contain %q", want) } } for _, forbidden := range []string{"统一模型目录", "查看设备模型"} { if strings.Contains(body, forbidden) { t.Fatalf("model management page should not contain legacy text %q", forbidden) } } } func TestUI_ModelsPageLoadsDevicesBeforeBuildingStatusBoard(t *testing.T) { modelServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/v1/models/status": w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"models":[{"name":"face_det_scrfd_500m_640_rk3588","file_name":"face_det_scrfd_500m_640_rk3588.rknn","sha256":"sha-1","size_bytes":123}]}`)) default: http.NotFound(w, r) } })) defer modelServer.Close() host, portText, err := net.SplitHostPort(strings.TrimPrefix(modelServer.Listener.Addr().String(), "[::]")) if err != nil { t.Fatalf("SplitHostPort: %v", err) } agentPort, err := strconv.Atoi(portText) if err != nil { t.Fatalf("Atoi: %v", err) } discoveryConn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4zero, Port: 35689}) if err != nil { t.Fatalf("ListenUDP: %v", err) } defer discoveryConn.Close() done := make(chan struct{}) go func() { defer close(done) buf := make([]byte, 2048) _ = discoveryConn.SetReadDeadline(time.Now().Add(5 * time.Second)) n, addr, err := discoveryConn.ReadFromUDP(buf) if err != nil { return } text := strings.TrimSpace(string(buf[:n])) lines := strings.SplitN(text, "\n", 3) if len(lines) < 2 || strings.TrimSpace(lines[0]) != "RK3588SYS_DISCOVERY_V1" { return } var req struct { ReqID string `json:"req_id"` } if err := json.Unmarshal([]byte(strings.TrimSpace(lines[1])), &req); err != nil { return } reply := fmt.Sprintf("RK3588SYS_DISCOVERY_V1\n{\"type\":\"discover_reply\",\"req_id\":%q,\"device_id\":\"edge-01\",\"device_name\":\"入口识别节点\",\"ip\":%q,\"agent_port\":%d,\"media_port\":9000}\n", req.ReqID, host, agentPort) _, _ = discoveryConn.WriteToUDP([]byte(reply), addr) }() cfg := &config.Config{ Concurrency: 1, OfflineAfterMs: 1000000, DiscoveryTimeoutMs: 300, DiscoveryPort: 35689, } dbPath := filepath.Join(t.TempDir(), "models.db") store, err := storage.OpenSQLite(dbPath) if err != nil { t.Fatalf("OpenSQLite: %v", err) } defer store.Close() reg := service.NewRegistryService(cfg, nil, storage.NewDevicesRepo(store.DB())) discovery := service.NewDiscoveryService(cfg, reg) agent := service.NewAgentClient(cfg) tasks := service.NewTaskService(cfg, agent, reg) ui, err := NewUI(discovery, reg, agent, tasks, nil) if err != nil { t.Fatalf("NewUI: %v", err) } modelsRepo := storage.NewModelsRepo(store.DB()) if err := modelsRepo.Save(storage.StandardModelRecord{ Name: "face_det_scrfd_500m_640_rk3588", FileName: "face_det_scrfd_500m_640_rk3588.rknn", Version: "v1", SHA256: "sha-1", SizeBytes: 123, ModelType: "face_detection", }); err != nil { t.Fatalf("Save: %v", err) } ui.SetDBPath(dbPath) req := httptest.NewRequest(http.MethodGet, "/ui/models", nil) rr := httptest.NewRecorder() ui.pageModels(rr, req) if rr.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) } body := rr.Body.String() for _, want := range []string{"入口识别节点", "设备模型状态", "一致"} { if !strings.Contains(body, want) { t.Fatalf("expected model page to contain %q after lazy device load, got:\n%s", want, body) } } if strings.Contains(body, "暂无设备模型状态") { t.Fatalf("expected model page to build status board after lazy device load, got:\n%s", body) } <-done } func TestUI_ActionModelSyncCreatesTask(t *testing.T) { ui := newTestUI(t) form := url.Values{} form.Set("action", "model_sync_all") form.Add("device_id", "edge-01") req := httptest.NewRequest(http.MethodPost, "/ui/models/sync", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr := httptest.NewRecorder() ui.actionModelSync(rr, req) if rr.Code != http.StatusFound { t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String()) } tasks := ui.tasks.ListTasks() if len(tasks) != 1 || tasks[0].Type != "model_sync_all" { t.Fatalf("unexpected tasks: %#v", tasks) } } func TestUI_ResourcesPageShowsUnifiedResourceStatus(t *testing.T) { ui := newTestUI(t) html := renderPage(t, ui, "/ui/resources") for _, want := range []string{"资源管理", "资源概览", "标准资源", "设备资源状态"} { if !strings.Contains(html, want) { t.Fatalf("expected resources HTML to contain %q", want) } } for _, forbidden := range []string{"上传模型", "单台设备"} { if strings.Contains(html, forbidden) { t.Fatalf("resources page should not contain legacy text %q", forbidden) } } } func TestUI_LogAuditPageRendersLogAndMetricLinks(t *testing.T) { ui := newTestUI(t) req := httptest.NewRequest(http.MethodGet, "/ui/diagnostics", nil) rr := httptest.NewRecorder() ui.pageDiagnostics(rr, req) if rr.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) } body := rr.Body.String() for _, want := range []string{"日志分析", "审计记录", "入口识别节点", "诊断日志", "运行指标"} { if !strings.Contains(body, want) { t.Fatalf("expected diagnostics HTML to contain %q", want) } } if strings.Contains(body, "进入系统状态") { t.Fatalf("log audit page should not contain system status section") } } func TestUI_DeviceConfigPageShowsDeviceSelector(t *testing.T) { ui := newTestUI(t) req := httptest.NewRequest(http.MethodGet, "/ui/device-config", nil) rr := httptest.NewRecorder() ui.pageDeviceConfig(rr, req) if rr.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) } body := rr.Body.String() for _, want := range []string{"单设备配置已并入设备详情", "设备入口", "edge-01", "入口识别节点", `href="/devices/edge-01#device-config"`} { if !strings.Contains(body, want) { t.Fatalf("expected config selector page to contain %q, got:\n%s", want, body) } } for _, forbidden := range []string{"识别模板", "调试参数"} { if strings.Contains(body, forbidden) { t.Fatalf("expected config selector page to avoid asset-library copy %q", forbidden) } } } func TestUI_DeviceControlPageShowsLiveActions(t *testing.T) { ui := newTestUI(t) routes, err := ui.Routes() if err != nil { t.Fatalf("Routes: %v", err) } rr := httptest.NewRecorder() routes.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/device-config/edge-01", nil)) if rr.Code != http.StatusFound { t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String()) } if got := rr.Header().Get("Location"); got != "/devices/edge-01#device-config" { t.Fatalf("expected device-config detail redirect to device workspace, got %q", got) } } func TestUI_AssetIntegrationsPageShowsStructuredServices(t *testing.T) { ui := newTestUI(t) root := t.TempDir() writeTestFile(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{"name":"std_workshop_face_recognition_shoe_alarm","template":{"nodes":[],"edges":[]}}`) writeTestFile(t, filepath.Join(root, "configs", "profiles", "line_a.json"), `{"name":"line_a","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}},"service_bindings":{"object_storage_main":{"service_ref":"minio_main"}}}]}`) writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{}`) store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db")) if err != nil { t.Fatalf("OpenSQLite: %v", err) } defer store.Close() repo := storage.NewAssetsRepo(store.DB()) mustSaveIntegrationService(t, repo, "minio_main", "object_storage", `{"name":"minio_main","type":"object_storage","description":"主对象存储","config":{"endpoint":"http://10.0.0.49:9000","bucket":"myminio","access_key":"admin","secret_key":"password"}}`) mustSaveIntegrationService(t, repo, "alarm_main", "alarm_service", `{"name":"alarm_main","type":"alarm_service","description":"告警","config":{"get_token_url":"http://10.0.0.49:8080/api/getToken","put_message_url":"http://10.0.0.49:8080/api/putMessage","tenant_code":"32"}}`) ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) mustImportAssetsForUI(t, ui.preview) req := httptest.NewRequest(http.MethodGet, "/ui/assets/integrations?name=alarm_main", nil) rr := httptest.NewRecorder() ui.pageAssetIntegrations(rr, req) body := rr.Body.String() for _, want := range []string{ "第三方服务列表", "第三方服务详情", "新增服务", "编辑", "删除", "minio_main", "alarm_main", "对象存储", "告警服务", "http://10.0.0.49:9000 / myminio", "1", "查看模式", `alarm_main`, `data-nav-row`, `data-nav-href="/assets/integrations?name=alarm_main"`, `class="selected"`, } { if !strings.Contains(body, want) { t.Fatalf("expected integrations page to contain %q, got:\n%s", want, body) } } if strings.Contains(body, ">保存") { t.Fatalf("expected readonly details to hide save button, got:\n%s", body) } } func TestUI_AssetIntegrationsPageNewModeClearsDetailForm(t *testing.T) { ui := newTestUI(t) root := t.TempDir() writeTestFile(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{"name":"std_workshop_face_recognition_shoe_alarm","template":{"nodes":[],"edges":[]}}`) writeTestFile(t, filepath.Join(root, "configs", "profiles", "line_a.json"), `{"name":"line_a","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`) writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{}`) store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db")) if err != nil { t.Fatalf("OpenSQLite: %v", err) } defer store.Close() repo := storage.NewAssetsRepo(store.DB()) mustSaveIntegrationService(t, repo, "minio_main", "object_storage", `{"name":"minio_main","type":"object_storage","description":"主对象存储","config":{"endpoint":"http://10.0.0.49:9000","bucket":"myminio","access_key":"admin","secret_key":"password"}}`) ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) mustImportAssetsForUI(t, ui.preview) req := httptest.NewRequest(http.MethodGet, "/ui/assets/integrations?new=1", nil) rr := httptest.NewRecorder() ui.pageAssetIntegrations(rr, req) body := rr.Body.String() if strings.Contains(body, `value="minio_main"`) { t.Fatalf("expected new mode to clear selected service detail, got:\n%s", body) } if !strings.Contains(body, `autofocus`) { t.Fatalf("expected new mode to autofocus empty name field, got:\n%s", body) } } func TestUI_AssetIntegrationsPageEditModeEnablesFormAndAutofocusesName(t *testing.T) { ui := newTestUI(t) root := t.TempDir() writeTestFile(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{"name":"std_workshop_face_recognition_shoe_alarm","template":{"nodes":[],"edges":[]}}`) writeTestFile(t, filepath.Join(root, "configs", "profiles", "line_a.json"), `{"name":"line_a","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`) writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{}`) store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db")) if err != nil { t.Fatalf("OpenSQLite: %v", err) } defer store.Close() repo := storage.NewAssetsRepo(store.DB()) mustSaveIntegrationService(t, repo, "minio_main", "object_storage", `{"name":"minio_main","type":"object_storage","description":"主对象存储","config":{"endpoint":"http://10.0.0.49:9000","bucket":"myminio","access_key":"admin","secret_key":"password"}}`) ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) mustImportAssetsForUI(t, ui.preview) req := httptest.NewRequest(http.MethodGet, "/ui/assets/integrations?name=minio_main&edit=1", nil) rr := httptest.NewRecorder() ui.pageAssetIntegrations(rr, req) body := rr.Body.String() if !strings.Contains(body, `autofocus`) { t.Fatalf("expected edit mode to autofocus selected name field, got:\n%s", body) } if !strings.Contains(body, ">保存") { t.Fatalf("expected edit mode to show save button, got:\n%s", body) } } func TestUI_ActionAssetIntegrationSaveCreatesService(t *testing.T) { ui := newTestUI(t) root := t.TempDir() writeTestFile(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{"name":"std_workshop_face_recognition_shoe_alarm","template":{"nodes":[],"edges":[]}}`) writeTestFile(t, filepath.Join(root, "configs", "profiles", "line_a.json"), `{"name":"line_a","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`) writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{}`) store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db")) if err != nil { t.Fatalf("OpenSQLite: %v", err) } defer store.Close() repo := storage.NewAssetsRepo(store.DB()) ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) mustImportAssetsForUI(t, ui.preview) form := url.Values{} form.Set("name", "minio_main") form.Set("type", "object_storage") form.Set("description", "主对象存储") form.Set("enabled", "1") form.Set("endpoint", "http://10.0.0.49:9000") form.Set("bucket", "myminio") form.Set("access_key", "admin") form.Set("secret_key", "password") req := httptest.NewRequest(http.MethodPost, "/ui/assets/integrations", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr := httptest.NewRecorder() ui.actionAssetIntegrationSave(rr, req) if rr.Code != http.StatusFound { t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String()) } record, err := repo.GetIntegrationService("minio_main") if err != nil { t.Fatalf("GetIntegrationService: %v", err) } if record == nil { t.Fatal("expected service record") } if record.ServiceType != "object_storage" { t.Fatalf("unexpected service type: %#v", record) } } func TestUI_ActionAssetIntegrationDeleteBlocksReferencedService(t *testing.T) { ui := newTestUI(t) root := t.TempDir() writeTestFile(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{"name":"std_workshop_face_recognition_shoe_alarm","template":{"nodes":[],"edges":[]}}`) writeTestFile(t, filepath.Join(root, "configs", "profiles", "line_a.json"), `{"name":"line_a","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}},"service_bindings":{"object_storage_main":{"service_ref":"minio_main"}}}]}`) writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{}`) store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db")) if err != nil { t.Fatalf("OpenSQLite: %v", err) } defer store.Close() repo := storage.NewAssetsRepo(store.DB()) mustSaveIntegrationService(t, repo, "minio_main", "object_storage", `{"name":"minio_main","type":"object_storage","description":"主对象存储","config":{"endpoint":"http://10.0.0.49:9000","bucket":"myminio","access_key":"admin","secret_key":"password"}}`) if err := repo.SaveProfile("line_a", "std_workshop_face_recognition_shoe_alarm", "line a", "profile", `{"name":"line_a","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}},"service_bindings":{"object_storage_main":{"service_ref":"minio_main"}}}]}`); err != nil { t.Fatalf("SaveProfile: %v", err) } ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) mustImportAssetsForUI(t, ui.preview) req := httptest.NewRequest(http.MethodPost, "/ui/assets/integrations/minio_main/delete", nil) req = withChiURLParam(req, "name", "minio_main") rr := httptest.NewRecorder() ui.actionAssetIntegrationDelete(rr, req) if rr.Code != http.StatusFound { t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String()) } if got := rr.Header().Get("Location"); !strings.Contains(got, "error=") { t.Fatalf("expected error redirect, got %q", got) } } func TestUI_AssetVideoSourcesPageShowsStructuredSources(t *testing.T) { ui := newTestUI(t) root := t.TempDir() writeTestFile(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{"name":"std_workshop_face_recognition_shoe_alarm","template":{"nodes":[],"edges":[]}}`) writeTestFile(t, filepath.Join(root, "configs", "profiles", "line_a.json"), `{"name":"line_a","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`) writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{}`) store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db")) if err != nil { t.Fatalf("OpenSQLite: %v", err) } defer store.Close() repo := storage.NewAssetsRepo(store.DB()) mustSaveVideoSource(t, repo, "gate_cam_01", `{"name":"gate_cam_01","source_type":"rtsp","area":"东门入口","description":"东门主入口摄像头","config":{"url":"rtsp://10.0.0.1/live","resolution":"1080p","fps":"25"}}`) ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) mustImportAssetsForUI(t, ui.preview) req := httptest.NewRequest(http.MethodGet, "/ui/assets/video-sources", nil) rr := httptest.NewRecorder() ui.pageAssetVideoSources(rr, req) body := rr.Body.String() for _, want := range []string{ "视频源列表", "视频源详情", "新增视频源", "编辑", "删除", "gate_cam_01", "RTSP", "东门入口", "1080p", "查看模式", `gate_cam_01`, `data-nav-row`, `data-nav-href="/assets/video-sources?name=gate_cam_01"`, `class="selected"`, } { if !strings.Contains(body, want) { t.Fatalf("expected video sources page to contain %q, got:\n%s", want, body) } } } func TestUI_AssetVideoSourcesPageNewModeClearsDetailForm(t *testing.T) { ui := newTestUI(t) root := t.TempDir() writeTestFile(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{"name":"std_workshop_face_recognition_shoe_alarm","template":{"nodes":[],"edges":[]}}`) writeTestFile(t, filepath.Join(root, "configs", "profiles", "line_a.json"), `{"name":"line_a","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`) writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{}`) store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db")) if err != nil { t.Fatalf("OpenSQLite: %v", err) } defer store.Close() repo := storage.NewAssetsRepo(store.DB()) mustSaveVideoSource(t, repo, "gate_cam_01", `{"name":"gate_cam_01","source_type":"rtsp","config":{"url":"rtsp://10.0.0.1/live"}}`) ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) mustImportAssetsForUI(t, ui.preview) req := httptest.NewRequest(http.MethodGet, "/ui/assets/video-sources?new=1", nil) rr := httptest.NewRecorder() ui.pageAssetVideoSources(rr, req) body := rr.Body.String() if strings.Contains(body, `value="gate_cam_01"`) { t.Fatalf("expected new mode to clear selected source detail, got:\n%s", body) } if !strings.Contains(body, `autofocus`) { t.Fatalf("expected new mode to autofocus empty name field, got:\n%s", body) } } func TestUI_ActionAssetVideoSourceSavePreservesFormOnError(t *testing.T) { ui := newTestUI(t) root := t.TempDir() writeTestFile(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{"name":"std_workshop_face_recognition_shoe_alarm","template":{"nodes":[],"edges":[]}}`) writeTestFile(t, filepath.Join(root, "configs", "profiles", "line_a.json"), `{"name":"line_a","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`) writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{}`) store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db")) if err != nil { t.Fatalf("OpenSQLite: %v", err) } defer store.Close() ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, storage.NewAssetsRepo(store.DB())) form := url.Values{} form.Set("name", "东门主入口") form.Set("source_type", "rtsp") form.Set("area", "东门") form.Set("description", "入口相机") req := httptest.NewRequest(http.MethodPost, "/ui/assets/video-sources", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr := httptest.NewRecorder() ui.actionAssetVideoSourceSave(rr, req) if rr.Code != http.StatusOK { t.Fatalf("expected inline render on error, got %d: %s", rr.Code, rr.Body.String()) } body := rr.Body.String() for _, want := range []string{"视频源地址不能为空", `name="name" value="东门主入口" autofocus`, `name="area" value="东门"`, `name="description" value="入口相机"`} { if !strings.Contains(body, want) { t.Fatalf("expected preserved error form to contain %q, got:\n%s", want, body) } } } func TestUI_RequiredMarkUsesThemeColorClass(t *testing.T) { css, err := os.ReadFile("ui/assets/style.css") if err != nil { t.Fatalf("read stylesheet: %v", err) } text := string(css) for _, want := range []string{ `.field-grid label>span .required-mark{display:inline;color:var(--red);font-weight:600;margin-left:4px}`, `--red:#e46f72`, `--red:#a43f3f`, `--red:#d66a63`, } { if !strings.Contains(text, want) { t.Fatalf("expected stylesheet to contain %q", want) } } } func TestUI_TemplateAssetsPageShowsListAndSelectedDetail(t *testing.T) { ui := newTestUI(t) root := t.TempDir() store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db")) if err != nil { t.Fatalf("OpenSQLite: %v", err) } defer store.Close() repo := storage.NewAssetsRepo(store.DB()) mustSaveTemplate(t, repo, "std_workshop_face_recognition_shoe_alarm", "template", `{"name":"std_workshop_face_recognition_shoe_alarm","source":"standard","description":"template","slots":{"inputs":[{"name":"video_input_main","type":"video_source","required":true,"description":"主视频输入"}],"outputs":[{"name":"stream_output_main","type":"stream_publish","required":true,"description":"主视频输出"}]},"template":{"nodes":[{},{}],"edges":[[]]}}`) mustSaveTemplate(t, repo, "helmet", "helmet template", `{"name":"helmet","description":"helmet template","template":{"nodes":[{}],"edges":[]}}`) writeTestFile(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{"name":"local_3588_test","business_name":"A厂区视觉识别","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","scene_meta":{"display_name":"东门入口"}}]}`) writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{"description":"debug","instance_overrides":{"*":{"override":{}}}}`) ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) mustImportAssetsForUI(t, ui.preview) req := httptest.NewRequest(http.MethodGet, "/ui/assets/templates?name=helmet", nil) rr := httptest.NewRecorder() ui.pageAssetTemplates(rr, req) body := rr.Body.String() for _, want := range []string{ "识别模板列表", "std_workshop_face_recognition_shoe_alarm", "helmet", "模板详情", "helmet template", `data-nav-row`, `data-nav-href="/assets/templates?name=helmet"`, `class="selected"`, } { if !strings.Contains(body, want) { t.Fatalf("expected template assets page to contain %q, got:\n%s", want, body) } } } func TestUI_OverlayAssetsPageShowsListAndSelectedDetail(t *testing.T) { ui := newTestUI(t) root := t.TempDir() writeTestFile(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{"name":"std_workshop_face_recognition_shoe_alarm","template":{"nodes":[{}],"edges":[]}}`) writeTestFile(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{"name":"local_3588_test","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","scene_meta":{"display_name":"东门入口"}}]}`) writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{"description":"启用人脸识别和陌生候选调试日志,用于联调和测试。","instance_overrides":{"*":{"override":{}}}}`) writeTestFile(t, filepath.Join(root, "configs", "overlays", "night_relaxed.json"), `{"description":"夜间场景下放宽识别约束,减少低照环境漏检。","instance_overrides":{"cam2":{"override":{}}}}`) store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db")) if err != nil { t.Fatalf("OpenSQLite: %v", err) } defer store.Close() repo := storage.NewAssetsRepo(store.DB()) mustSaveTemplate(t, repo, "std_workshop_face_recognition_shoe_alarm", "标准模板", `{"name":"std_workshop_face_recognition_shoe_alarm","source":"standard","template":{"nodes":[{}],"edges":[]}}`) ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) mustImportAssetsForUI(t, ui.preview) req := httptest.NewRequest(http.MethodGet, "/ui/assets/overlays?name=night_relaxed", nil) rr := httptest.NewRecorder() ui.pageAssetOverlays(rr, req) body := rr.Body.String() for _, want := range []string{ "调试参数列表", "face_debug", "night_relaxed", "夜间场景下放宽识别约束,减少低照环境漏检。", "cam2", `data-nav-row`, `data-nav-href="/assets/overlays?name=night_relaxed"`, `class="selected"`, } { if !strings.Contains(body, want) { t.Fatalf("expected overlay assets page to contain %q, got:\n%s", want, body) } } } func TestUI_DeviceAssignmentsPageShowsBoard(t *testing.T) { ui := newTestUI(t) store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db")) if err != nil { t.Fatalf("OpenSQLite: %v", err) } defer store.Close() repo := storage.NewAssetsRepo(store.DB()) if err := repo.SaveProfile("scene_a", "helmet", "Scene A", "desc", `{"name":"scene_a","primary_template_name":"helmet"}`); err != nil { t.Fatalf("SaveProfile: %v", err) } if err := repo.SaveRecognitionUnit(storage.RecognitionUnitRecord{ SceneTemplateName: "scene_a", Name: "cam1", DisplayName: "东门入口", VideoSourceRef: "vs1", OutputChannel: "cam1", RTSPPort: "8555", BodyJSON: `{"name":"cam1","scene_meta":{"display_name":"东门入口"},"input_bindings":{"video_input_main":{"video_source_ref":"vs1"}}}`, }); err != nil { t.Fatalf("SaveRecognitionUnit cam1: %v", err) } if err := repo.SaveRecognitionUnit(storage.RecognitionUnitRecord{ SceneTemplateName: "scene_a", Name: "cam2", DisplayName: "西门入口", VideoSourceRef: "vs2", OutputChannel: "cam2", RTSPPort: "8555", BodyJSON: `{"name":"cam2","scene_meta":{"display_name":"西门入口"},"input_bindings":{"video_input_main":{"video_source_ref":"vs2"}}}`, }); err != nil { t.Fatalf("SaveRecognitionUnit cam2: %v", err) } if err := repo.SaveDeviceAssignment("edge-01", "scene_a", "", `{"device_id":"edge-01","profile_name":"scene_a","recognition_units":["scene_a::cam1"]}`); err != nil { t.Fatalf("SaveDeviceAssignment: %v", err) } ui.preview = service.NewConfigPreviewService(&config.Config{}, repo) ui.registry.UpdateDevice(&models.Device{DeviceID: "edge-02", DeviceName: "辅助节点", IP: "127.0.0.2", AgentPort: 9100, MediaPort: 9000, Online: true}) req := httptest.NewRequest(http.MethodGet, "/ui/device-assignments", nil) rr := httptest.NewRecorder() ui.pageDeviceAssignments(rr, req) body := rr.Body.String() for _, want := range []string{"东门入口", "西门入口"} { if !strings.Contains(body, want) { t.Fatalf("expected device assignment board to contain %q, got:\n%s", want, body) } } for _, want := range []string{"测试设备 03", "测试设备 08"} { if !strings.Contains(body, want) { t.Fatalf("expected device assignment board to include preview device %q, got:\n%s", want, body) } } } func TestUI_DeviceAssignmentsPageHidesAddControlsForFullDevice(t *testing.T) { ui := newTestUI(t) store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db")) if err != nil { t.Fatalf("OpenSQLite: %v", err) } defer store.Close() repo := storage.NewAssetsRepo(store.DB()) if err := repo.SaveProfile("scene_a", "helmet", "Scene A", "desc", `{"name":"scene_a","primary_template_name":"helmet"}`); err != nil { t.Fatalf("SaveProfile: %v", err) } if err := repo.SaveRecognitionUnit(storage.RecognitionUnitRecord{ SceneTemplateName: "scene_a", Name: "cam1", DisplayName: "东门入口", VideoSourceRef: "vs1", OutputChannel: "cam1", RTSPPort: "8555", BodyJSON: `{"name":"cam1","scene_meta":{"display_name":"东门入口"},"input_bindings":{"video_input_main":{"video_source_ref":"vs1"}}}`, }); err != nil { t.Fatalf("SaveRecognitionUnit cam1: %v", err) } if err := repo.SaveDeviceAssignment("edge-01", "scene_a", "", `{"device_id":"edge-01","profile_name":"scene_a","recognition_units":["scene_a::cam1"]}`); err != nil { t.Fatalf("SaveDeviceAssignment: %v", err) } ui.preview = service.NewConfigPreviewService(&config.Config{}, repo) req := httptest.NewRequest(http.MethodGet, "/ui/device-assignments?max_units_per_device=1", nil) rr := httptest.NewRecorder() ui.pageDeviceAssignments(rr, req) body := rr.Body.String() start := strings.Index(body, `data-device-card="edge-01"`) if start < 0 { t.Fatalf("expected edge-01 card in html: %s", body) } end := strings.Index(body[start:], ``) if end < 0 { t.Fatalf("expected end of edge-01 card in html: %s", body) } cardHTML := body[start : start+end] if strings.Contains(cardHTML, "添加识别单元") { t.Fatalf("expected full device card to hide add controls, got:\n%s", cardHTML) } } func TestUI_ActionDeviceAssignmentSavePersistsBoardState(t *testing.T) { ui := newTestUI(t) store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db")) if err != nil { t.Fatalf("OpenSQLite: %v", err) } defer store.Close() repo := storage.NewAssetsRepo(store.DB()) if err := repo.SaveProfile("scene_a", "helmet", "Scene A", "desc", `{"name":"scene_a","primary_template_name":"helmet"}`); err != nil { t.Fatalf("SaveProfile: %v", err) } if err := repo.SaveRecognitionUnit(storage.RecognitionUnitRecord{ SceneTemplateName: "scene_a", Name: "cam1", VideoSourceRef: "vs1", OutputChannel: "cam1", RTSPPort: "8555", BodyJSON: `{"name":"cam1","input_bindings":{"video_input_main":{"video_source_ref":"vs1"}}}`, }); err != nil { t.Fatalf("SaveRecognitionUnit cam1: %v", err) } ui.preview = service.NewConfigPreviewService(&config.Config{}, repo) form := url.Values{} form.Set("max_units_per_device", "4") form.Set("board_state_json", `{"devices":{"edge-01":["scene_a::cam1"]}}`) req := httptest.NewRequest(http.MethodPost, "/ui/device-assignments", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr := httptest.NewRecorder() ui.actionDeviceAssignmentSave(rr, req) if rr.Code != http.StatusFound { t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String()) } item, err := ui.preview.GetDeviceAssignment("edge-01") if err != nil { t.Fatalf("GetDeviceAssignment: %v", err) } if item == nil || item.ProfileName != "scene_a" || len(item.RecognitionUnits) != 1 || item.RecognitionUnits[0] != "scene_a::cam1" { t.Fatalf("unexpected saved assignment: %#v", item) } } func TestUI_ActionDeviceAssignmentSaveIgnoresPreviewDevices(t *testing.T) { ui := newTestUI(t) store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db")) if err != nil { t.Fatalf("OpenSQLite: %v", err) } defer store.Close() repo := storage.NewAssetsRepo(store.DB()) if err := repo.SaveProfile("scene_a", "helmet", "Scene A", "desc", `{"name":"scene_a","primary_template_name":"helmet"}`); err != nil { t.Fatalf("SaveProfile: %v", err) } if err := repo.SaveRecognitionUnit(storage.RecognitionUnitRecord{ SceneTemplateName: "scene_a", Name: "cam1", VideoSourceRef: "vs1", OutputChannel: "cam1", RTSPPort: "8555", BodyJSON: `{"name":"cam1","input_bindings":{"video_input_main":{"video_source_ref":"vs1"}}}`, }); err != nil { t.Fatalf("SaveRecognitionUnit cam1: %v", err) } ui.preview = service.NewConfigPreviewService(&config.Config{}, repo) form := url.Values{} form.Set("max_units_per_device", "4") form.Set("board_state_json", `{"devices":{"demo-edge-02":["scene_a::cam1"]}}`) req := httptest.NewRequest(http.MethodPost, "/ui/device-assignments", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr := httptest.NewRecorder() ui.actionDeviceAssignmentSave(rr, req) if rr.Code != http.StatusFound { t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String()) } item, err := ui.preview.GetDeviceAssignment("demo-edge-02") if err == nil && item != nil { t.Fatalf("expected preview device assignment to be ignored, got %#v", item) } } func TestUI_DeviceAssignmentsBoardUsesDenseGridLayout(t *testing.T) { css, err := os.ReadFile(filepath.Join("ui", "assets", "style.css")) if err != nil { t.Fatalf("ReadFile style.css: %v", err) } if !strings.Contains(string(css), ".assignment-board-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));") { t.Fatalf("expected dense four-column assignment board grid, got:\n%s", string(css)) } } func TestUI_DeviceAssignmentsBoardUsesBrightOrangeLoadStates(t *testing.T) { css, err := os.ReadFile(filepath.Join("ui", "assets", "style.css")) if err != nil { t.Fatalf("ReadFile style.css: %v", err) } text := string(css) for _, want := range []string{ "--assignment-low-bg:#24190d;", "--assignment-busy-bg:#3a2812;", "--assignment-full-bg:#4b3110;", "--assignment-low-bg:#f4ead8;", "--assignment-busy-bg:#f0dcc0;", "--assignment-full-bg:#eccb96;", "--assignment-low-bg:#332514;", "--assignment-busy-bg:#3f3015;", "--assignment-full-bg:#57401a;", } { if !strings.Contains(text, want) { t.Fatalf("expected brighter orange assignment state token %q in css", want) } } } func TestUI_DashboardShowsGlobalOperationsSummary(t *testing.T) { ui := newTestUI(t) html := renderPage(t, ui, "/ui/dashboard") for _, text := range []string{"在线率", "最近任务", "异常设备", "告警趋势"} { if !strings.Contains(html, text) { t.Fatalf("expected dashboard text %q in html: %s", text, html) } } } func TestUI_DashboardOmitsRedundantExplanatoryChrome(t *testing.T) { ui := newTestUI(t) html := renderPage(t, ui, "/ui/dashboard") for _, text := range []string{ "只看 fleet 级运行态势、最近任务和需要关注的异常设备。", "当前版本基于节点在线状态和视频分析服务查询入口汇总。", "按常见现场操作顺序进入对应页面。", "视频分析概览", "运维工作流", "快速入口", "任务健康", "失败任务", "成功任务", `设备列表`, `任务`, } { if strings.Contains(html, text) { t.Fatalf("dashboard should omit redundant chrome %q in html: %s", text, html) } } } func TestUI_TasksPageOwnsBatchExecutionDomain(t *testing.T) { ui := newTestUI(t) html := renderPage(t, ui, "/ui/tasks") for _, text := range []string{"执行历史", "设备数"} { if !strings.Contains(html, text) { t.Fatalf("expected tasks text %q in html: %s", text, html) } } } func TestUI_LogAuditPageOwnsLogsAndAudit(t *testing.T) { ui := newTestUI(t) html := renderPage(t, ui, "/ui/diagnostics") for _, text := range []string{"日志", "审计"} { if !strings.Contains(html, text) { t.Fatalf("expected diagnostics text %q in html: %s", text, html) } } if strings.Contains(html, "进入系统状态") { t.Fatalf("log audit page should not include system status: %s", html) } } func TestUI_DeviceDetailActsAsSingleDeviceWorkspace(t *testing.T) { ui := newTestUI(t) html := renderPage(t, ui, "/ui/devices/edge-01") for _, text := range []string{"概览", "运行与服务", "设备分配", "日志与指标"} { if !strings.Contains(html, text) { t.Fatalf("expected device workspace text %q in html: %s", text, html) } } if strings.Contains(html, "进入配置管理") { t.Fatalf("device detail should not contain cross-module config shortcut: %s", html) } } func TestUI_DeviceWorkspaceContainsSingleDeviceOperations(t *testing.T) { ui := newTestUI(t) html := renderPage(t, ui, "/ui/devices/edge-01") for _, text := range []string{"概览", "运行与服务", "设备分配", "模型与资源", "日志与指标"} { if !strings.Contains(html, text) { t.Fatalf("expected device workspace section %q in html: %s", text, html) } } for _, removed := range []string{"返回设备选择", "配置管理面向单台设备"} { if strings.Contains(html, removed) { t.Fatalf("did not expect legacy standalone config flow text %q in html: %s", removed, html) } } } func TestUI_TaskDetailPresentsExecutionSections(t *testing.T) { ui := newTestUI(t) task, err := ui.tasks.CreateTask("config_apply", []string{"edge-01"}, map[string]any{"config": map[string]any{}}) if err != nil { t.Fatalf("create task: %v", err) } html := renderPage(t, ui, "/tasks/"+task.ID) for _, text := range []string{"任务概览", "执行进度", "设备结果"} { if !strings.Contains(html, text) { t.Fatalf("expected task detail section %q in html: %s", text, html) } } } func TestUI_SystemManagementPagesUseSystemManagementCrumb(t *testing.T) { ui := newTestUI(t) for _, item := range []struct { path string want string }{ {path: "/ui/system", want: "服务状态"}, {path: "/ui/audit", want: "系统设置 / 日志审计 / 审计记录"}, {path: "/ui/api", want: "系统设置 / 日志审计 / 高级调试"}, } { html := renderPage(t, ui, item.path) if !strings.Contains(html, item.want) { t.Fatalf("expected %s to contain crumb %q, got: %s", item.path, item.want, html) } } } func TestUI_TasksPageDoesNotExposeTaskCreationForm(t *testing.T) { ui := newTestUI(t) ui.registry.UpdateDevice(&models.Device{DeviceID: "edge-02", DeviceName: "辅助节点", IP: "127.0.0.2", AgentPort: 9100, MediaPort: 9000, Online: true}) html := renderPage(t, ui, "/ui/tasks?selected=edge-02") for _, forbidden := range []string{"

目标设备

", `name="device_id"`, `name="device_ids"`, `name="payload_json"`, "创建任务", ""} { if strings.Contains(html, forbidden) { t.Fatalf("tasks page should not expose task-creation UI %q, got: %s", forbidden, html) } } } func TestUI_TasksPageDoesNotShowTransitionQuickActions(t *testing.T) { ui := newTestUI(t) html := renderPage(t, ui, "/ui/tasks") for _, forbidden := range []string{"常用动作", "批量下发", "批量重启", "批量回滚"} { if strings.Contains(html, forbidden) { t.Fatalf("tasks page should not contain transition quick action block %q, got: %s", forbidden, html) } } } func TestUI_ActionCreateTaskUsesSelectedDeviceCheckboxes(t *testing.T) { ui := newTestUI(t) ui.registry.UpdateDevice(&models.Device{DeviceID: "edge-02", DeviceName: "辅助节点", IP: "127.0.0.2", AgentPort: 9100, MediaPort: 9000, Online: true}) form := url.Values{} form.Set("type", "media_restart") form.Add("device_id", "edge-01") form.Add("device_id", "edge-02") form.Set("payload_json", `{}`) req := httptest.NewRequest(http.MethodPost, "/ui/tasks", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr := httptest.NewRecorder() ui.actionCreateTask(rr, req) if rr.Code != http.StatusFound { t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String()) } loc := rr.Header().Get("Location") taskID := strings.TrimPrefix(loc, "/tasks/") var created *models.Task for _, item := range ui.tasks.ListTasks() { if item.ID == taskID { task := item created = &task break } } if created == nil { t.Fatalf("expected created task %q to exist", taskID) } if got := strings.Join(created.DeviceIDs, ","); got != "edge-01,edge-02" { t.Fatalf("expected selected devices preserved in order, got %q", got) } } func TestUI_TasksPageShowsPersistedHistory(t *testing.T) { cfg := &config.Config{Concurrency: 1, OfflineAfterMs: 1000000} reg := service.NewRegistryService(cfg, nil) reg.UpdateDevice(&models.Device{DeviceID: "edge-01", DeviceName: "入口识别节点", IP: "127.0.0.1", AgentPort: 9100, MediaPort: 9000, Online: true}) store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) if err != nil { t.Fatalf("OpenSQLite: %v", err) } defer store.Close() repo := storage.NewTasksRepo(store.DB()) task := models.NewTask("task-persisted", "reload", []string{"edge-01"}, nil) task.Status = models.TaskSuccess task.Devices["edge-01"].Status = models.TaskSuccess task.Devices["edge-01"].Progress = 1 if err := repo.Save(task); err != nil { t.Fatalf("Save: %v", err) } tasks := service.NewTaskService(cfg, nil, reg, repo) if err := tasks.LoadPersistedTasks(); err != nil { t.Fatalf("LoadPersistedTasks: %v", err) } ui, err := NewUI(nil, reg, nil, tasks, nil) if err != nil { t.Fatalf("NewUI: %v", err) } html := renderPage(t, ui, "/ui/tasks") for _, want := range []string{"task-persisted", "重载识别服务", "1 台"} { if !strings.Contains(html, want) { t.Fatalf("expected persisted task history to contain %q, got: %s", want, html) } } } func TestUI_AssetsOverviewOmitsLegacyImportAction(t *testing.T) { ui := newTestUI(t) html := renderPage(t, ui, "/ui/assets") for _, forbidden := range []string{"资产操作", "导入现有 JSON", `action="/ui/assets/import"`} { if strings.Contains(html, forbidden) { t.Fatalf("expected assets overview to omit legacy import action %q, got: %s", forbidden, html) } } } func TestUI_AssetTemplateExportsJSON(t *testing.T) { store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) if err != nil { t.Fatalf("OpenSQLite: %v", err) } defer store.Close() repo := storage.NewAssetsRepo(store.DB()) const raw = "{\n \"name\": \"helmet\",\n \"template\": {\n \"nodes\": [],\n \"edges\": []\n }\n}\n" if err := repo.SaveTemplate("helmet", "helmet template", raw); err != nil { t.Fatalf("SaveTemplate: %v", err) } ui := newTestUI(t) ui.preview = service.NewConfigPreviewService(&config.Config{}, repo) req := httptest.NewRequest(http.MethodGet, "/assets/templates/helmet/export", nil) rctx := chi.NewRouteContext() rctx.URLParams.Add("name", "helmet") req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) rr := httptest.NewRecorder() ui.pageAssetTemplateExport(rr, req) if rr.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) } if got := rr.Header().Get("Content-Disposition"); !strings.Contains(got, "helmet.json") { t.Fatalf("expected attachment filename, got %q", got) } if rr.Body.String() != raw { t.Fatalf("unexpected export body: %s", rr.Body.String()) } } func TestUI_AssetTemplateShowsSaveAsExportButton(t *testing.T) { store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) if err != nil { t.Fatalf("OpenSQLite: %v", err) } defer store.Close() repo := storage.NewAssetsRepo(store.DB()) if err := repo.SaveTemplate("helmet", "helmet template", `{"name":"helmet","template":{"nodes":[],"edges":[]}}`); err != nil { t.Fatalf("SaveTemplate: %v", err) } ui := newTestUI(t) ui.preview = service.NewConfigPreviewService(&config.Config{}, repo) html := renderPage(t, ui, "/ui/assets/templates?name=helmet") for _, want := range []string{"导出为 JSON", `data-export-url="/assets/templates/helmet/export"`, `data-default-filename="helmet.json"`, "showSaveFilePicker", "当前浏览器不支持选择保存目录和文件名"} { if !strings.Contains(html, want) { t.Fatalf("expected asset template html to contain %q, got: %s", want, html) } } } func TestUI_AssetTemplatesPageShowsDirectStandardCloneAction(t *testing.T) { store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) if err != nil { t.Fatalf("OpenSQLite: %v", err) } defer store.Close() repo := storage.NewAssetsRepo(store.DB()) if err := repo.SaveTemplate("std_face_recognition_stream", "标准人脸流模板", `{"name":"std_face_recognition_stream","description":"标准人脸流模板","source":"standard","template":{"nodes":[{"id":"in","type":"input_rtsp","role":"source"}],"edges":[]}}`); err != nil { t.Fatalf("SaveTemplate: %v", err) } ui := newTestUI(t) ui.preview = service.NewConfigPreviewService(&config.Config{}, repo) html := renderPage(t, ui, "/ui/assets/templates?name=std_face_recognition_stream") for _, want := range []string{"标准模板应作为基线", "复制标准模板", "模板详情"} { if !strings.Contains(html, want) { t.Fatalf("expected template page to contain %q, got: %s", want, html) } } for _, forbidden := range []string{"新增模板", `name="clone_source" value="std_face_recognition_stream"`, "复制并编辑"} { if strings.Contains(html, forbidden) { t.Fatalf("expected template page to omit %q, got: %s", forbidden, html) } } } func TestUI_AssetTemplateGraphPageRendersEditorShell(t *testing.T) { ui := newTestUI(t) store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) if err != nil { t.Fatalf("OpenSQLite: %v", err) } defer store.Close() repo := storage.NewAssetsRepo(store.DB()) if err := repo.SaveTemplate("helmet", "helmet template", `{ "name": "helmet", "description": "helmet template", "template": { "nodes": [{"id":"in","type":"input_rtsp","role":"source","enable":true}], "edges": [] } }`); err != nil { t.Fatalf("SaveTemplate: %v", err) } ui.preview = service.NewConfigPreviewService(&config.Config{}, repo) html := renderPage(t, ui, "/assets/templates/helmet/graph") for _, want := range []string{ "模板可视化编辑", "graph-editor", "graph_editor.css", "graph_editor.js", "graph-template-json", `"id":"in"`, `"type":"input_rtsp"`, "graph-node-form", `name="id"`, `name="role"`, `name="enable"`, "graph-connect-target", "graph-typed-param-fields", "graph-auto-layout", "graph-param-editor", "graph-edge-form", "graph-node-palette-list", `data-catalog-url="/api/graph-node-types"`, `name="from"`, `name="to"`, "常用参数", "高级 JSON", "自动布局", } { if !strings.Contains(html, want) { t.Fatalf("expected graph editor page to contain %q, got: %s", want, html) } } } func TestUI_AssetTemplatePageShowsEditAndDeleteForUserTemplate(t *testing.T) { ui := newTestUI(t) store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) if err != nil { t.Fatalf("OpenSQLite: %v", err) } defer store.Close() repo := storage.NewAssetsRepo(store.DB()) if err := repo.SaveTemplate("helmet", "helmet template", `{"name":"helmet","description":"helmet template","template":{"nodes":[],"edges":[]}}`); err != nil { t.Fatalf("SaveTemplate: %v", err) } ui.preview = service.NewConfigPreviewService(&config.Config{}, repo) html := renderPage(t, ui, "/ui/assets/templates?name=helmet") for _, want := range []string{`href="/assets/templates?name=helmet&edit=1"`, `action="/assets/templates/helmet/delete"`, "模板详情", "查看模式"} { if !strings.Contains(html, want) { t.Fatalf("expected template detail to contain %q, got: %s", want, html) } } } func TestUI_ActionAssetTemplateCloneRedirectsWithCloneSource(t *testing.T) { ui := newTestUI(t) store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) if err != nil { t.Fatalf("OpenSQLite: %v", err) } defer store.Close() repo := storage.NewAssetsRepo(store.DB()) if err := repo.SaveTemplate("std_face_recognition_stream", "helmet template", `{"name":"std_face_recognition_stream","description":"helmet template","source":"standard","template":{"nodes":[{"id":"in","type":"input_rtsp","role":"source"}],"edges":[["in","det"]]},"extra":{"mode":"std"}}`); err != nil { t.Fatalf("SaveTemplate: %v", err) } ui.preview = service.NewConfigPreviewService(&config.Config{}, repo) req := httptest.NewRequest(http.MethodPost, "/assets/templates/std_face_recognition_stream/clone", nil) rr := httptest.NewRecorder() router, err := ui.Routes() if err != nil { t.Fatalf("Routes: %v", err) } router.ServeHTTP(rr, req) if rr.Code != http.StatusFound { t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String()) } wantLocation := "/assets/templates?clone_source=std_face_recognition_stream&clone_name=face_recognition_stream_copy&clone_desc=helmet+template" if got := rr.Header().Get("Location"); got != wantLocation { t.Fatalf("expected redirect to %q, got %q", wantLocation, got) } saved, err := repo.GetTemplate("face_recognition_stream_copy") if err != nil { t.Fatalf("GetTemplate: %v", err) } if saved != nil { t.Fatal("expected clone template NOT to be saved until form submission") } } func TestUI_GraphNodeTypesAPIFallbackListsCatalog(t *testing.T) { ui := newTestUI(t) router, err := ui.Routes() if err != nil { t.Fatalf("Routes: %v", err) } req := httptest.NewRequest(http.MethodGet, "/api/graph-node-types", nil) rr := httptest.NewRecorder() router.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) } var payload struct { Items []graphNodeTypeInfo `json:"items"` } if err := json.Unmarshal(rr.Body.Bytes(), &payload); err != nil { t.Fatalf("invalid json: %v", err) } got := map[string]graphNodeTypeInfo{} for _, item := range payload.Items { got[item.Type] = item } for _, want := range []string{"input_rtsp", "ai_shoe_det", "event_fusion", "zlm_http"} { item, ok := got[want] if !ok { t.Fatalf("expected graph node type %q in %#v", want, payload.Items) } if item.Label == "" || item.Icon == "" || item.Description == "" { t.Fatalf("expected %q to include label, icon and description: %#v", want, item) } if item.Defaults["type"] != want { t.Fatalf("expected %q defaults.type, got %#v", want, item.Defaults) } } } func TestUI_AssetTemplateGraphSavePersistsNodeAndEdgeParameters(t *testing.T) { ui := newTestUI(t) store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) if err != nil { t.Fatalf("OpenSQLite: %v", err) } defer store.Close() repo := storage.NewAssetsRepo(store.DB()) if err := repo.SaveTemplate("helmet", "helmet template", `{"name":"helmet","template":{"nodes":[],"edges":[]}}`); err != nil { t.Fatalf("SaveTemplate: %v", err) } ui.preview = service.NewConfigPreviewService(&config.Config{}, repo) form := url.Values{} form.Set("json", `{"name":"helmet","description":"helmet template","template":{"nodes":[{"id":"in","type":"input_rtsp","role":"source","enable":true,"url":"${slot:video_input_main.url}"},{"id":"det","type":"ai_yolo","role":"filter","enable":true,"conf":0.42}],"edges":[{"from":"in","to":"det","stream":"video"}]}}`) req := httptest.NewRequest(http.MethodPost, "/assets/templates/helmet/graph", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr := httptest.NewRecorder() router, err := ui.Routes() if err != nil { t.Fatalf("Routes: %v", err) } router.ServeHTTP(rr, req) if rr.Code != http.StatusFound { t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String()) } saved, err := repo.GetTemplate("helmet") if err != nil { t.Fatalf("GetTemplate: %v", err) } for _, want := range []string{`"url": "${slot:video_input_main.url}"`, `"conf": 0.42`, `"stream": "video"`} { if saved == nil || !strings.Contains(saved.BodyJSON, want) { t.Fatalf("expected saved graph parameter %q, got %#v", want, saved) } } } func TestUI_AssetTemplateGraphSavePersistsLayout(t *testing.T) { ui := newTestUI(t) store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) if err != nil { t.Fatalf("OpenSQLite: %v", err) } defer store.Close() repo := storage.NewAssetsRepo(store.DB()) if err := repo.SaveTemplate("helmet", "helmet template", `{"name":"helmet","description":"helmet template","template":{"nodes":[{"id":"in","type":"input_rtsp","role":"source"}],"edges":[]}}`); err != nil { t.Fatalf("SaveTemplate: %v", err) } ui.preview = service.NewConfigPreviewService(&config.Config{}, repo) form := url.Values{} form.Set("json", `{"name":"helmet","description":"helmet template","ui":{"layout":{"version":1,"nodes":{"in":{"x":123,"y":456}}}},"template":{"nodes":[{"id":"in","type":"input_rtsp","role":"source"}],"edges":[]}}`) req := httptest.NewRequest(http.MethodPost, "/assets/templates/helmet/graph", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr := httptest.NewRecorder() router, err := ui.Routes() if err != nil { t.Fatalf("Routes: %v", err) } router.ServeHTTP(rr, req) if rr.Code != http.StatusFound { t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String()) } saved, err := repo.GetTemplate("helmet") if err != nil { t.Fatalf("GetTemplate: %v", err) } if saved == nil || !strings.Contains(saved.BodyJSON, `"x": 123`) || !strings.Contains(saved.BodyJSON, `"y": 456`) { t.Fatalf("expected saved layout, got %#v", saved) } } func TestUI_AssetTemplateGraphSaveCanRenameTemplate(t *testing.T) { ui := newTestUI(t) store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) if err != nil { t.Fatalf("OpenSQLite: %v", err) } defer store.Close() repo := storage.NewAssetsRepo(store.DB()) if err := repo.SaveTemplate("helmet", "helmet template", `{"name":"helmet","description":"helmet template","template":{"nodes":[{"id":"in","type":"input_rtsp","role":"source"}],"edges":[]}}`); err != nil { t.Fatalf("SaveTemplate: %v", err) } if err := repo.SaveProfile("gate_a", "helmet", "gate", "gate profile", `{"name":"gate_a","business_name":"gate","instances":[{"name":"cam1","template":"helmet","scene_meta":{"display_name":"Gate A"},"input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`); err != nil { t.Fatalf("SaveProfile: %v", err) } ui.preview = service.NewConfigPreviewService(&config.Config{}, repo) form := url.Values{} form.Set("name", "helmet_v2") form.Set("description", "helmet v2") form.Set("json", `{"name":"helmet","description":"helmet template","template":{"nodes":[{"id":"in","type":"input_rtsp","role":"source"}],"edges":[]}}`) req := httptest.NewRequest(http.MethodPost, "/assets/templates/helmet/graph", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr := httptest.NewRecorder() router, err := ui.Routes() if err != nil { t.Fatalf("Routes: %v", err) } router.ServeHTTP(rr, req) if rr.Code != http.StatusFound { t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String()) } if got := rr.Header().Get("Location"); !strings.Contains(got, "/assets/templates/helmet_v2/graph") { t.Fatalf("expected renamed graph redirect, got %q", got) } oldRecord, err := repo.GetTemplate("helmet") if err != nil { t.Fatalf("GetTemplate old: %v", err) } if oldRecord != nil { t.Fatalf("expected old template removed, got %#v", oldRecord) } newRecord, err := repo.GetTemplate("helmet_v2") if err != nil { t.Fatalf("GetTemplate new: %v", err) } if newRecord == nil || !strings.Contains(newRecord.BodyJSON, `"name": "helmet_v2"`) { t.Fatalf("expected renamed template saved, got %#v", newRecord) } profile, err := repo.GetProfile("gate_a") if err != nil { t.Fatalf("GetProfile: %v", err) } if profile == nil || profile.TemplateName != "helmet_v2" { t.Fatalf("expected profile ref updated, got %#v", profile) } } func TestUI_ActionAssetTemplateRenameFromDetailPage(t *testing.T) { ui := newTestUI(t) store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) if err != nil { t.Fatalf("OpenSQLite: %v", err) } defer store.Close() repo := storage.NewAssetsRepo(store.DB()) if err := repo.SaveTemplate("helmet", "helmet template", `{"name":"helmet","description":"helmet template","template":{"nodes":[],"edges":[]}}`); err != nil { t.Fatalf("SaveTemplate: %v", err) } ui.preview = service.NewConfigPreviewService(&config.Config{}, repo) form := url.Values{} form.Set("name", "helmet_v2") form.Set("description", "helmet v2") req := httptest.NewRequest(http.MethodPost, "/assets/templates/helmet/rename", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr := httptest.NewRecorder() router, err := ui.Routes() if err != nil { t.Fatalf("Routes: %v", err) } router.ServeHTTP(rr, req) if rr.Code != http.StatusFound { t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String()) } if got := rr.Header().Get("Location"); !strings.Contains(got, "/assets/templates?name=helmet_v2") { t.Fatalf("expected detail redirect, got %q", got) } record, err := repo.GetTemplate("helmet_v2") if err != nil { t.Fatalf("GetTemplate: %v", err) } if record == nil || record.Description != "helmet_v2" && record.Description != "helmet v2" { t.Fatalf("expected renamed template, got %#v", record) } } func TestUI_ActionAssetTemplateDeleteFromDetailPage(t *testing.T) { ui := newTestUI(t) store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) if err != nil { t.Fatalf("OpenSQLite: %v", err) } defer store.Close() repo := storage.NewAssetsRepo(store.DB()) if err := repo.SaveTemplate("helmet", "helmet template", `{"name":"helmet","description":"helmet template","template":{"nodes":[],"edges":[]}}`); err != nil { t.Fatalf("SaveTemplate: %v", err) } ui.preview = service.NewConfigPreviewService(&config.Config{}, repo) req := httptest.NewRequest(http.MethodPost, "/assets/templates/helmet/delete", nil) rr := httptest.NewRecorder() router, err := ui.Routes() if err != nil { t.Fatalf("Routes: %v", err) } router.ServeHTTP(rr, req) if rr.Code != http.StatusFound { t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String()) } if got := rr.Header().Get("Location"); !strings.Contains(got, "/assets/templates?msg=") { t.Fatalf("expected list redirect with message, got %q", got) } record, err := repo.GetTemplate("helmet") if err != nil { t.Fatalf("GetTemplate: %v", err) } if record != nil { t.Fatalf("expected deleted template, got %#v", record) } } func TestUI_AssetTemplateGraphSaveRejectsUnknownEdgeEndpoint(t *testing.T) { ui := newTestUI(t) store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) if err != nil { t.Fatalf("OpenSQLite: %v", err) } defer store.Close() repo := storage.NewAssetsRepo(store.DB()) if err := repo.SaveTemplate("helmet", "helmet template", `{"name":"helmet","template":{"nodes":[],"edges":[]}}`); err != nil { t.Fatalf("SaveTemplate: %v", err) } ui.preview = service.NewConfigPreviewService(&config.Config{}, repo) form := url.Values{} form.Set("json", `{"name":"helmet","template":{"nodes":[{"id":"in","type":"input_rtsp","role":"source"}],"edges":[["in","missing"]]}}`) req := httptest.NewRequest(http.MethodPost, "/assets/templates/helmet/graph", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr := httptest.NewRecorder() router, err := ui.Routes() if err != nil { t.Fatalf("Routes: %v", err) } router.ServeHTTP(rr, req) if rr.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d: %s", rr.Code, rr.Body.String()) } if !strings.Contains(rr.Body.String(), "edge references unknown node") { t.Fatalf("expected edge validation error, got: %s", rr.Body.String()) } } func TestUI_AssetTemplatePageMarksBuiltinTemplateReadonly(t *testing.T) { ui := newTestUI(t) store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) if err != nil { t.Fatalf("OpenSQLite: %v", err) } defer store.Close() repo := storage.NewAssetsRepo(store.DB()) if err := repo.SaveTemplate("std_face_recognition_stream", "builtin helmet", `{"name":"std_face_recognition_stream","description":"builtin helmet","source":"standard","template":{"nodes":[{"id":"input_rtsp_main","type":"input_rtsp","role":"source"}],"edges":[]}}`); err != nil { t.Fatalf("SaveTemplate: %v", err) } ui.preview = service.NewConfigPreviewService(&config.Config{}, repo) html := renderPage(t, ui, "/ui/assets/templates?name=std_face_recognition_stream") for _, want := range []string{"标准模板(只读)", "复制标准模板", "可视化预览", "builtin helmet"} { if !strings.Contains(html, want) { t.Fatalf("expected builtin template page to contain %q, got: %s", want, html) } } } func TestUI_AssetTemplateGraphSaveRejectsBuiltinTemplate(t *testing.T) { ui := newTestUI(t) store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) if err != nil { t.Fatalf("OpenSQLite: %v", err) } defer store.Close() repo := storage.NewAssetsRepo(store.DB()) if err := repo.SaveTemplate("std_face_recognition_stream", "builtin helmet", `{"name":"std_face_recognition_stream","description":"builtin helmet","source":"standard","template":{"nodes":[{"id":"input_rtsp_main","type":"input_rtsp","role":"source"}],"edges":[]}}`); err != nil { t.Fatalf("SaveTemplate: %v", err) } ui.preview = service.NewConfigPreviewService(&config.Config{}, repo) form := url.Values{} form.Set("json", `{"name":"std_face_recognition_stream","description":"builtin helmet","template":{"nodes":[{"id":"input_rtsp_main","type":"input_rtsp","role":"source"}],"edges":[]}}`) req := httptest.NewRequest(http.MethodPost, "/assets/templates/std_face_recognition_stream/graph", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr := httptest.NewRecorder() router, err := ui.Routes() if err != nil { t.Fatalf("Routes: %v", err) } router.ServeHTTP(rr, req) if rr.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d: %s", rr.Code, rr.Body.String()) } if !strings.Contains(rr.Body.String(), "read-only") { t.Fatalf("expected readonly rejection, got: %s", rr.Body.String()) } } func TestUI_AuditPagePrefersPersistedAuditLogs(t *testing.T) { ui := newTestUI(t) store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) if err != nil { t.Fatalf("OpenSQLite: %v", err) } defer store.Close() auditRepo := storage.NewAuditLogsRepo(store.DB()) if err := auditRepo.Append(storage.AuditLogRecord{ Actor: "system", Action: "config_apply", TargetType: "device", TargetID: "edge-01", DetailsJSON: `{"task_id":"task-99","profile":"gate_a","status":"success"}`, }); err != nil { t.Fatalf("Append: %v", err) } ui.auditRepo = auditRepo html := renderPage(t, ui, "/ui/audit") for _, want := range []string{"task-99", "gate_a", "下发设备分配", "成功", "edge-01"} { if !strings.Contains(html, want) { t.Fatalf("expected audit page to contain %q, got: %s", want, html) } } if strings.Contains(html, "暂无审计记录") { t.Fatalf("expected persisted audit logs to replace empty state, got: %s", html) } if strings.Contains(html, "config_apply") { t.Fatalf("expected audit page to avoid raw enum 'config_apply', got: %s", html) } } func TestUI_DeviceDetailFallsBackToPersistedConfigState(t *testing.T) { ui := newTestUI(t) store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) if err != nil { t.Fatalf("OpenSQLite: %v", err) } defer store.Close() stateRepo := storage.NewDeviceConfigStateRepo(store.DB()) if err := stateRepo.Upsert(storage.DeviceConfigStateRecord{ DeviceID: "edge-01", TemplateName: "helmet", ProfileName: "gate_a", OverlaysJSON: `["night_relaxed"]`, ConfigID: "cfg-001", ConfigVersion: "20260427.1", LastAppliedTaskID: "task-1", }); err != nil { t.Fatalf("Upsert: %v", err) } ui.stateRepo = stateRepo html := renderPage(t, ui, "/ui/devices/edge-01") for _, want := range []string{"cfg-001", "20260427.1", "helmet", "gate_a", "night_relaxed"} { if !strings.Contains(html, want) { t.Fatalf("expected device detail to contain %q, got: %s", want, html) } } } func TestUI_SystemPageShowsDatabaseBackupAction(t *testing.T) { ui := newTestUI(t) ui.dbPath = filepath.Join(t.TempDir(), "app.db") html := renderPage(t, ui, "/ui/system") for _, want := range []string{ "数据备份", "恢复数据库", `class="btn ghost js-export-db"`, `data-export-url="/system/db-backup"`, `data-default-filename="app.db"`, } { if !strings.Contains(html, want) { t.Fatalf("expected system page to contain %q, got: %s", want, html) } } } func TestUI_SystemPageShowsFlashMessageFromQuery(t *testing.T) { ui := newTestUI(t) ui.dbPath = filepath.Join(t.TempDir(), "app.db") html := renderPage(t, ui, "/ui/system?msg=%E6%95%B0%E6%8D%AE%E5%BA%93%E6%81%A2%E5%A4%8D%E5%AE%8C%E6%88%90") if !strings.Contains(html, "数据库恢复完成") { t.Fatalf("expected system page to show success message, got: %s", html) } } func TestUI_SystemDBBackupUsesTimestampedFilename(t *testing.T) { ui := newTestUI(t) dir := t.TempDir() ui.dbPath = filepath.Join(dir, "app.db") if err := os.WriteFile(ui.dbPath, []byte("sqlite-bytes"), 0644); err != nil { t.Fatalf("WriteFile: %v", err) } req := httptest.NewRequest(http.MethodGet, "/ui/system/db-backup", nil) rr := httptest.NewRecorder() ui.pageSystemDBBackup(rr, req) if rr.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) } got := rr.Header().Get("Content-Disposition") if !strings.Contains(got, "attachment;") || !strings.Contains(got, "app-") || !strings.Contains(got, ".db") { t.Fatalf("expected timestamped filename, got %q", got) } } func TestUI_SystemDBRestoreReplacesDatabaseFile(t *testing.T) { ui := newTestUI(t) dir := t.TempDir() ui.dbPath = filepath.Join(dir, "app.db") if err := os.WriteFile(ui.dbPath, []byte("old-db"), 0644); err != nil { t.Fatalf("WriteFile: %v", err) } var body bytes.Buffer writer := multipart.NewWriter(&body) part, err := writer.CreateFormFile("file", "restore.db") if err != nil { t.Fatalf("CreateFormFile: %v", err) } if _, err := part.Write([]byte("new-db")); err != nil { t.Fatalf("Write part: %v", err) } if err := writer.Close(); err != nil { t.Fatalf("Close writer: %v", err) } req := httptest.NewRequest(http.MethodPost, "/ui/system/db-restore", &body) req.Header.Set("Content-Type", writer.FormDataContentType()) rr := httptest.NewRecorder() ui.actionSystemDBRestore(rr, req) if rr.Code != http.StatusFound { t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String()) } restored, err := os.ReadFile(ui.dbPath) if err != nil { t.Fatalf("ReadFile: %v", err) } if string(restored) != "new-db" { t.Fatalf("expected restored db contents, got %q", string(restored)) } } func TestUI_SystemDBRestoreRequiresSelectedFile(t *testing.T) { ui := newTestUI(t) dir := t.TempDir() ui.dbPath = filepath.Join(dir, "app.db") if err := os.WriteFile(ui.dbPath, []byte("old-db"), 0644); err != nil { t.Fatalf("WriteFile: %v", err) } var body bytes.Buffer writer := multipart.NewWriter(&body) if err := writer.Close(); err != nil { t.Fatalf("Close writer: %v", err) } req := httptest.NewRequest(http.MethodPost, "/ui/system/db-restore", &body) req.Header.Set("Content-Type", writer.FormDataContentType()) rr := httptest.NewRecorder() ui.actionSystemDBRestore(rr, req) if rr.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d: %s", rr.Code, rr.Body.String()) } html := rr.Body.String() if !strings.Contains(html, "请先选择数据库备份文件") { t.Fatalf("expected friendly error message, got: %s", html) } if strings.Contains(html, "http: no such file") { t.Fatalf("expected raw form-file error to stay hidden, got: %s", html) } } func newTestUIWithPreview(t *testing.T) (*UI, *storage.Store) { t.Helper() dbPath := filepath.Join(t.TempDir(), "console.db") store, err := storage.OpenSQLite(dbPath) if err != nil { t.Fatalf("OpenSQLite: %v", err) } t.Cleanup(func() { _ = store.Close() }) cfg := &config.Config{Concurrency: 1, OfflineAfterMs: 1000000} reg := service.NewRegistryService(cfg, nil, storage.NewDevicesRepo(store.DB())) reg.UpdateDevice(&models.Device{DeviceID: "dev1", DeviceName: "设备1", IP: "10.0.0.1", AgentPort: 9100, Online: true}) tasks := service.NewTaskService(cfg, nil, reg) repo := storage.NewAssetsRepo(store.DB()) preview := service.NewConfigPreviewService(cfg, repo) // Seed template repo.SaveTemplate("std_face_recognition_stream", "test", `{"name":"std_face_recognition_stream","template":{"nodes":[{"id":"in","type":"input_rtsp"}],"edges":[]}}`) // Seed video source preview.SaveVideoSourceAsset(service.ConfigVideoSourceAsset{ Name: "cam1", SourceType: "rtsp", Config: service.VideoSourceConfig{URL: "rtsp://10.0.0.1/live", Resolution: "1920x1080", FPS: "30"}, }, "") ui, err := NewUI(nil, reg, nil, tasks, nil, preview) if err != nil { t.Fatalf("NewUI: %v", err) } ui.SetDBPath(dbPath) autoCfg := service.NewAutoConfigService(preview, tasks, service.NewFeatureRegistry()) ui.SetAutoConfig(autoCfg) return ui, store } func TestUI_ConsoleSave_PipelineCreatesTask(t *testing.T) { ui, _ := newTestUIWithPreview(t) form := url.Values{} form.Set("dirty_device_ids", "dev1") form.Set("device_dev1_feature", "face") form.Set("device_dev1_source", "cam1") router, _ := ui.Routes() req := httptest.NewRequest(http.MethodPost, "/console", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr := httptest.NewRecorder() router.ServeHTTP(rr, req) // Should redirect to task page if rr.Code != http.StatusFound { t.Fatalf("expected 302, got %d body=%s", rr.Code, rr.Body.String()) } loc := rr.Header().Get("Location") if !strings.Contains(loc, "task=") { t.Errorf("expected task in redirect, got %s", loc) } } // ── P1 #5: Wizard Apply ── func TestUI_ActionWizardApply(t *testing.T) { store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "wizard.db")) if err != nil { t.Fatalf("OpenSQLite: %v", err) } defer store.Close() repo := storage.NewAssetsRepo(store.DB()) svc := service.NewConfigPreviewService(&config.Config{}, repo) // Seed template and video source repo.SaveTemplate("std_face_recognition_stream", "standard", `{"name":"std_face_recognition_stream","template":{"nodes":[{"id":"in","type":"input_rtsp"}],"edges":[]}}`) svc.SaveVideoSourceAsset(service.ConfigVideoSourceAsset{ Name: "cam1", SourceType: "rtsp", Config: service.VideoSourceConfig{URL: "rtsp://10.0.0.1/live", Resolution: "1920x1080", FPS: "30"}, }, "") reg := service.NewFeatureRegistry() tasks := service.NewTaskService(&config.Config{}, nil, service.NewRegistryService(&config.Config{Concurrency: 1}, nil)) autoCfg := service.NewAutoConfigService(svc, tasks, reg) ui, err := NewUI(nil, nil, nil, tasks, nil, svc) if err != nil { t.Fatalf("NewUI: %v", err) } ui.SetAutoConfig(autoCfg) body := `{"device_id":"wizard-dev-001","features":["face"],"source_names":["cam1"]}` req := httptest.NewRequest(http.MethodPost, "/wizard/apply", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") rr := httptest.NewRecorder() ui.actionWizardApply(rr, req) if rr.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) } var result service.AutoConfigResult if err := json.Unmarshal(rr.Body.Bytes(), &result); err != nil { t.Fatalf("unmarshal result: %v", err) } if result.Error != "" { t.Fatalf("wizard apply error: %s", result.Error) } // Verify features were persisted features, err := svc.GetDeviceFeatures("wizard-dev-001") if err != nil || len(features) == 0 { t.Error("expected device features to be persisted") } } func TestUI_ActionWizardApply_InvalidJSON(t *testing.T) { // Without autoConfig, wizard returns 503 (service unavailable) ui, _ := NewUI(nil, nil, nil, nil, nil) req := httptest.NewRequest(http.MethodPost, "/wizard/apply", strings.NewReader(`not json`)) req.Header.Set("Content-Type", "application/json") rr := httptest.NewRecorder() ui.actionWizardApply(rr, req) if rr.Code != http.StatusServiceUnavailable { t.Errorf("expected 503, got %d", rr.Code) } } // ── Device Overlay Save ── func TestUI_ActionDeviceOverlaysSave(t *testing.T) { store, _ := storage.OpenSQLite(filepath.Join(t.TempDir(), "devoverlay.db")) defer store.Close() repo := storage.NewAssetsRepo(store.DB()) svc := service.NewConfigPreviewService(&config.Config{}, repo) ui, _ := NewUI(nil, nil, nil, nil, nil, svc) form := url.Values{} form.Add("overlay", "face_debug") form.Add("overlay", "production_quiet") req := httptest.NewRequest(http.MethodPost, "/devices/dev1/overlays", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr := httptest.NewRecorder() // Set chi param rctx := chi.NewRouteContext() rctx.URLParams.Add("id", "dev1") req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) ui.actionDeviceOverlaysSave(rr, req) if rr.Code != http.StatusFound { t.Fatalf("expected 302, got %d", rr.Code) } overlays, err := svc.GetDeviceOverlays("dev1") if err != nil { t.Fatalf("GetDeviceOverlays: %v", err) } if len(overlays) != 2 { t.Errorf("expected 2 overlays, got %d", len(overlays)) } } func TestUI_ActionDeviceOverlaysSave_Clears(t *testing.T) { store, _ := storage.OpenSQLite(filepath.Join(t.TempDir(), "devoverlay2.db")) defer store.Close() repo := storage.NewAssetsRepo(store.DB()) svc := service.NewConfigPreviewService(&config.Config{}, repo) svc.SaveDeviceOverlays("dev1", []string{"old_overlay"}) ui, _ := NewUI(nil, nil, nil, nil, nil, svc) // Submit with no overlays checked req := httptest.NewRequest(http.MethodPost, "/devices/dev1/overlays", strings.NewReader("")) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr := httptest.NewRecorder() rctx := chi.NewRouteContext() rctx.URLParams.Add("id", "dev1") req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) ui.actionDeviceOverlaysSave(rr, req) overlays, _ := svc.GetDeviceOverlays("dev1") if len(overlays) != 0 { t.Errorf("expected 0 overlays after clear, got %d", len(overlays)) } }