package web import ( "3588AdminBackend/internal/config" "3588AdminBackend/internal/models" "3588AdminBackend/internal/service" "context" "encoding/json" "net" "net/http" "net/http/httptest" "net/url" "os" "path/filepath" "strconv" "strings" "testing" "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, "/ui/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, "/ui/tasks/") { t.Fatalf("expected redirect to task page, got %q", loc) } taskID := strings.TrimPrefix(loc, "/ui/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_DevicePageUsesEdgeVisionConsoleShell(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}) tasks := service.NewTaskService(cfg, nil, reg) ui, err := NewUI(nil, reg, nil, tasks, nil) if err != nil { t.Fatalf("NewUI: %v", err) } req := httptest.NewRequest(http.MethodGet, "/ui/devices", 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{ "视觉识别运维平台", "配置管理", "操作审计", "系统", "

设备

", } { if !strings.Contains(body, want) { t.Fatalf("expected rendered HTML to contain %q, got:\n%s", want, body) } } } 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{ ".brand-title{font-size:14px;font-weight:600", ".nav-section{padding:14px 10px 6px;font-size:11px", ".topbar h1{margin:4px 0 0;font-size:18px;font-weight:600", ".crumb,.eyebrow{font-size:11px", ".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:8px", } { if !strings.Contains(text, want) { t.Fatalf("expected stylesheet to contain %q", want) } } } func newTestUI(t *testing.T) *UI { t.Helper() 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, 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 TestUI_DeviceOverviewHidesBatchBarWithoutSelection(t *testing.T) { ui := newTestUI(t) req := httptest.NewRequest(http.MethodGet, "/ui/devices", 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 _, forbidden := range []string{"batch-toolbar", "已选", "批量配置", "重启服务", "启动服务", "停止服务", "重载服务", "清空选择"} { if strings.Contains(body, forbidden) { t.Fatalf("device overview should not show batch controls without selection, found %q in:\n%s", forbidden, body) } } } 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) } } if strings.Contains(body, "重载服务") { t.Fatalf("device overview batch controls should not contain reload action") } if !strings.Contains(body, `href="/ui/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_DeviceBatchConfigPageShowsSelectedSummaryAndSources(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}) ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: createBatchConfigMediaRepo(t)}) req := httptest.NewRequest(http.MethodGet, "/ui/devices/batch-config?selected=edge-01&selected=edge-02", nil) rr := httptest.NewRecorder() ui.pageDeviceBatchConfig(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{ "批量配置", "模板", "业务配置", "配置叠加项", "已选设备", "入口识别节点", "辅助节点", "预览摘要", "workshop_face_shoe_alarm", "local_3588_test", "face_debug", } { if !strings.Contains(body, want) { t.Fatalf("expected batch config page to contain %q, got:\n%s", want, body) } } if strings.Contains(body, "已选 2 台设备") { t.Fatalf("batch config page should not contain explanatory selected-count copy") } } func TestUI_ActionDeviceBatchConfigCreatesTaskAndRedirects(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}) ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: createBatchConfigMediaRepo(t)}) form := url.Values{} form.Add("device_id", "edge-01") form.Add("device_id", "edge-02") form.Set("template", "workshop_face_shoe_alarm") form.Set("profile", "local_3588_test") form.Add("overlay", "face_debug") form.Set("config_id", "batch_edge") form.Set("config_version", "20260420.090000") req := httptest.NewRequest(http.MethodPost, "/ui/devices/batch-config", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr := httptest.NewRecorder() ui.actionDeviceBatchConfig(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, "/ui/tasks/") { t.Fatalf("expected redirect to task page, got %q", loc) } taskID := strings.TrimPrefix(loc, "/ui/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 task.Type != "config_apply" { t.Fatalf("expected task type config_apply, got %q", task.Type) } if len(task.DeviceIDs) != 2 || task.DeviceIDs[0] != "edge-01" || task.DeviceIDs[1] != "edge-02" { t.Fatalf("expected selected devices preserved, got %#v", task.DeviceIDs) } payload, ok := task.Payload.(map[string]any) if !ok { t.Fatalf("expected payload map, got %#v", task.Payload) } configDoc, ok := payload["config"].(map[string]any) if !ok { t.Fatalf("expected payload.config object, got %#v", payload["config"]) } metadata, ok := configDoc["metadata"].(map[string]any) if !ok { t.Fatalf("expected metadata object, got %#v", configDoc["metadata"]) } if metadata["template"] != "workshop_face_shoe_alarm" { t.Fatalf("expected template metadata, got %#v", metadata["template"]) } if metadata["profile"] != "local_3588_test" { t.Fatalf("expected profile metadata, got %#v", metadata["profile"]) } } func TestUI_ActionDeviceBatchConfigRenderFailurePreservesUserInput(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}) ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: createBatchConfigBrokenMediaRepo(t)}) form := url.Values{} form.Add("device_id", "edge-01") form.Add("device_id", "edge-02") form.Set("template", "workshop_face_shoe_alarm") form.Set("profile", "local_3588_test") form.Set("config_id", "") form.Set("config_version", "") req := httptest.NewRequest(http.MethodPost, "/ui/devices/batch-config", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr := httptest.NewRecorder() ui.actionDeviceBatchConfig(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{ `name="device_id" value="edge-01"`, `name="device_id" value="edge-02"`, "入口识别节点", "辅助节点", `name="config_id" value=""`, } { if !strings.Contains(body, want) { t.Fatalf("expected failure refill HTML to contain %q, got:\n%s", want, body) } } if strings.Contains(body, `name="overlay" value="face_debug" checked`) { t.Fatalf("expected empty overlay selection to stay empty, got:\n%s", body) } if strings.Contains(body, "完整 JSON 放在折叠区") { t.Fatalf("expected no JSON foldout hint on render failure, 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 TestUI_AssetProfilePageShowsEditorTabs(t *testing.T) { ui := newTestUI(t) ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: createProfileEditorMediaRepo(t)}) req := httptest.NewRequest(http.MethodGet, "/ui/assets/profiles/local_3588_test", nil) req = withChiURLParam(req, "name", "local_3588_test") rr := httptest.NewRecorder() ui.pageAssetProfile(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{ "业务配置", "业务配置名称", "业务名称", "站点名", "视频通道", "cam1", "cam2", "cam3", "cam4", "RTSP 输入", "HLS 输出", "RTSP 输出", "高级设置", "新增通道", "删除", "保存", "A厂区视觉识别", "东门入口", } { if !strings.Contains(body, want) { t.Fatalf("expected profile editor page to contain %q, got:\n%s", want, body) } } for _, forbidden := range []string{"生成预览", "上传为候选配置", "目标设备", "发布入口", "请在设备页中预览并下发", "下发方式", "Profile 编辑页签", ">模板