package service import ( "encoding/json" "os" "path/filepath" "strings" "testing" "safesight-control/internal/config" "safesight-control/internal/models" "safesight-control/internal/storage" ) func mustSaveTemplateRecord(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 mustReadFileBytes(t *testing.T, path string) []byte { t.Helper() body, err := os.ReadFile(path) if err != nil { t.Fatalf("ReadFile(%s): %v", path, err) } return body } func mustImportPreviewAssets(t *testing.T, svc *ConfigPreviewService) { t.Helper() if _, err := svc.ImportAssetsFromMediaRepo(); err != nil { t.Fatalf("ImportAssetsFromMediaRepo: %v", err) } } func TestConfigPreviewServiceGetsProfileAssetSummary(t *testing.T) { root := t.TempDir() templateBody := `{ "name": "std_workshop_face_recognition_shoe_alarm", "source": "standard", "params": { "minio_endpoint": "http://10.0.0.49:9000", "minio_bucket": "myminio", "external_get_token_url": "http://10.0.0.49:8080/api/getToken", "external_put_message_url": "http://10.0.0.49:8080/api/putMessage", "tenant_code": "32", "snapshot_region": "us-east-1" }, "template": {"nodes": [], "edges": []} }` mustWrite(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{ "name": "local_3588_test", "business_name": "A厂区视觉识别", "description": "test profile", "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"}}, "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"}} }] }`) mustWrite(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()) mustSaveTemplateRecord(t, repo, "std_workshop_face_recognition_shoe_alarm", "标准模板", templateBody) svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) if err := repo.SaveProfile("local_3588_test", "std_workshop_face_recognition_shoe_alarm", "A厂区视觉识别", "test profile", string(mustReadFileBytes(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json")))); err != nil { t.Fatalf("SaveProfile: %v", err) } if err := repo.SaveOverlay("face_debug", "", `{}`); err != nil { t.Fatalf("SaveOverlay: %v", err) } item, err := svc.GetProfileAsset("local_3588_test") if err != nil { t.Fatalf("GetProfileAsset: %v", err) } if item.Name != "local_3588_test" || len(item.Instances) != 1 { t.Fatalf("unexpected profile summary: %#v", item) } inst := item.Instances[0] if inst.DisplayName != "东门入口" { t.Fatalf("expected display name to be parsed, got %#v", inst) } if inst.PublishRTSPPort != "8555" { t.Fatalf("expected rtsp port to be stringified, got %#v", inst.PublishRTSPPort) } if _, ok := inst.AdvancedParams["queue_debug"]; !ok { t.Fatalf("expected advanced params to preserve extra keys, got %#v", inst.AdvancedParams) } if item, err := svc.GetTemplateAsset("std_workshop_face_recognition_shoe_alarm"); err != nil { t.Fatalf("GetTemplateAsset: %v", err) } else { if _, ok := item.AdvancedParams["snapshot_region"]; !ok { t.Fatalf("expected template advanced params to preserve extra keys, got %#v", item.AdvancedParams) } } } func TestConfigPreviewServiceGetsOverlayAssetTargets(t *testing.T) { root := t.TempDir() mustWrite(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{}`) mustWrite(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{}`) mustWrite(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{ "description": "启用人脸识别和陌生候选调试日志,用于联调和测试。", "instance_overrides": { "*": {"override": {}}, "cam1": {"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()) svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) mustImportPreviewAssets(t, svc) item, err := svc.GetOverlayAsset("face_debug") if err != nil { t.Fatalf("GetOverlayAsset: %v", err) } if item.OverrideTargetNum != 2 { t.Fatalf("expected 2 override targets, got %#v", item) } if item.OverrideTargets[0] != "*" || item.OverrideTargets[1] != "cam1" { t.Fatalf("unexpected targets: %#v", item.OverrideTargets) } if item.Description != "启用人脸识别和陌生候选调试日志,用于联调和测试。" { t.Fatalf("expected localized overlay description, got %#v", item.Description) } } func TestConfigPreviewServiceBuildsProfileEditor(t *testing.T) { root := t.TempDir() mustWrite(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{ "name": "local_3588_test", "business_name": "A厂区视觉识别", "description": "test profile", "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"}}, "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"}} } ] }`) 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.SaveTemplate("std_workshop_face_recognition_shoe_alarm", "标准模板", `{"name":"std_workshop_face_recognition_shoe_alarm","source":"standard","template":{"nodes":[],"edges":[]}}`); err != nil { t.Fatalf("SaveTemplate: %v", err) } if err := repo.SaveProfile("local_3588_test", "std_workshop_face_recognition_shoe_alarm", "A厂区视觉识别", "test profile", string(mustReadFileBytes(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json")))); err != nil { t.Fatalf("SaveProfile: %v", err) } svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) editor, err := svc.GetProfileEditor("local_3588_test") if err != nil { t.Fatalf("GetProfileEditor: %v", err) } if editor.Name != "local_3588_test" { t.Fatalf("unexpected profile name: %#v", editor) } if editor.BusinessName != "A厂区视觉识别" { t.Fatalf("expected business name to be parsed, got %#v", editor.BusinessName) } if editor.SiteName != "A厂区" { t.Fatalf("expected profile site name to be parsed, got %#v", editor.SiteName) } if editor.DeviceCode != "rk3588-a-001" { t.Fatalf("expected legacy device code to be preserved internally, got %#v", editor.DeviceCode) } if len(editor.Instances) != 2 { t.Fatalf("expected two instances, got %#v", editor.Instances) } if editor.Instances[0].Name != "cam1" || editor.Instances[0].DisplayName != "东门入口" { t.Fatalf("unexpected first instance summary: %#v", editor.Instances[0]) } if editor.Instances[1].Name != "cam2" || editor.Instances[1].VideoSourceRef != "line_cam_02" { t.Fatalf("unexpected second instance summary: %#v", editor.Instances[1]) } if editor.Instances[0].PublishRTSPPort != "8555" { t.Fatalf("expected rtsp port to be stringified, got %#v", editor.Instances[0].PublishRTSPPort) } if editor.Queue.Size != "8" || editor.Queue.Strategy != "drop_oldest" { t.Fatalf("unexpected queue model: %#v", editor.Queue) } if editor.Instances[0].VideoSourceRef != "gate_cam_01" { t.Fatalf("expected slot-driven video source ref, got %#v", editor.Instances[0]) } if _, ok := editor.Instances[0].AdvancedParams["queue_debug"]; !ok { t.Fatalf("expected advanced param to remain in editor, got %#v", editor.Instances[0].AdvancedParams) } } func TestConfigPreviewServiceBuildsProfileDocumentFromEditor(t *testing.T) { svc := NewConfigPreviewService(&config.Config{}) editor := ConfigProfileEditor{ Name: "local_3588_test", BusinessName: "A厂区视觉识别", Description: "test profile", OverlayName: "face_debug", DeviceCode: "rk3588-a-001", SiteName: "A厂区", Instances: []ConfigProfileInstanceEditor{ { Name: "cam1", Template: "std_workshop_face_recognition_shoe_alarm", VideoSourceRef: "gate_cam_01", DisplayName: "东门入口", PublishHLSPath: "./web/hls/cam1/index.m3u8", PublishRTSPPort: "8555", PublishRTSPPath: "/live/cam1", ChannelNo: "cam1", AdvancedParams: map[string]any{ "queue_debug": true, }, }, { Name: "cam2", Template: "std_workshop_face_recognition_shoe_alarm", VideoSourceRef: "line_cam_02", DisplayName: "视觉识别终端-B厂区", PublishHLSPath: "./web/hls/cam2/index.m3u8", PublishRTSPPort: "8556", PublishRTSPPath: "/live/cam2", ChannelNo: "cam2", }, }, } doc, err := svc.BuildProfileDocument(editor) if err != nil { t.Fatalf("BuildProfileDocument: %v", err) } if doc["name"] != "local_3588_test" { t.Fatalf("unexpected doc name: %#v", doc) } if doc["business_name"] != "A厂区视觉识别" { t.Fatalf("unexpected business name: %#v", doc) } if overlays, _ := doc["overlays"].([]any); len(overlays) != 1 || overlays[0] != "face_debug" { t.Fatalf("unexpected overlays doc: %#v", doc["overlays"]) } queue, _ := doc["queue"].(map[string]any) if queue["size"] != 8 || queue["strategy"] != "drop_oldest" { t.Fatalf("unexpected queue doc: %#v", queue) } instances, _ := doc["instances"].([]map[string]any) if len(instances) != 2 { t.Fatalf("expected two instances, got %#v", doc["instances"]) } params, _ := instances[0]["params"].(map[string]any) if params["queue_debug"] != true { t.Fatalf("expected advanced param to survive rebuild, got %#v", params) } if _, exists := params["video_source_ref"]; exists { t.Fatalf("expected new profile document to avoid legacy video_source_ref in params, got %#v", params) } params2, _ := instances[1]["params"].(map[string]any) if len(params2) != 0 { t.Fatalf("expected second instance params to stay empty under new model, got %#v", params2) } sceneMeta, _ := instances[0]["scene_meta"].(map[string]any) if sceneMeta["display_name"] != "东门入口" || sceneMeta["site_name"] != "A厂区" || sceneMeta["device_code"] != "rk3588-a-001" { t.Fatalf("expected scene meta to carry scene fields, got %#v", sceneMeta) } } func TestBuildProfileDocumentUsesSlotBindings(t *testing.T) { svc := NewConfigPreviewService(&config.Config{}) doc, err := svc.BuildProfileDocument(ConfigProfileEditor{ Name: "line_a", Instances: []ConfigProfileInstanceEditor{{ Name: "cam1", Template: "std_workshop_face_recognition_shoe_alarm", DisplayName: "B厂区通道1", SiteName: "B厂区", InputBindings: map[string]InputBindingEditor{ "video_input_main": {VideoSourceRef: "gate_cam_01"}, }, ServiceBindings: map[string]ServiceBindingEditor{ "object_storage_main": {ServiceRef: "minio_main"}, "token_service_main": {ServiceRef: "token_main"}, "alarm_service_main": {ServiceRef: "alarm_main"}, }, OutputBindings: map[string]OutputBindingEditor{ "stream_output_main": { PublishHLSPath: "./web/hls/cam1/index.m3u8", PublishRTSPPort: "8555", PublishRTSPPath: "/live/cam1", ChannelNo: "cam1", }, }, }}, }) if err != nil { t.Fatalf("BuildProfileDocument: %v", err) } instances, _ := doc["instances"].([]map[string]any) if len(instances) != 1 { t.Fatalf("expected one instance, got %#v", doc["instances"]) } inst := instances[0] inputBindings, _ := inst["input_bindings"].(map[string]any) if inputBindings == nil { t.Fatalf("expected input_bindings, got %#v", inst) } videoInput, _ := inputBindings["video_input_main"].(map[string]any) if videoInput["video_source_ref"] != "gate_cam_01" { t.Fatalf("unexpected input binding: %#v", videoInput) } serviceBindings, _ := inst["service_bindings"].(map[string]any) objectStorage, _ := serviceBindings["object_storage_main"].(map[string]any) if objectStorage["service_ref"] != "minio_main" { t.Fatalf("unexpected service binding: %#v", serviceBindings) } outputBindings, _ := inst["output_bindings"].(map[string]any) streamOutput, _ := outputBindings["stream_output_main"].(map[string]any) if streamOutput["publish_rtsp_port"] != 8555 { t.Fatalf("unexpected output binding: %#v", streamOutput) } sceneMeta, _ := inst["scene_meta"].(map[string]any) if sceneMeta["display_name"] != "B厂区通道1" || sceneMeta["site_name"] != "B厂区" { t.Fatalf("unexpected scene meta: %#v", sceneMeta) } } func TestBuildProfileDocumentDefaultsStreamOutputFromInstanceName(t *testing.T) { svc := NewConfigPreviewService(&config.Config{}) doc, err := svc.BuildProfileDocument(ConfigProfileEditor{ Name: "line_a", Instances: []ConfigProfileInstanceEditor{{ Name: "cam7", Template: "std_workshop_face_recognition_shoe_alarm", DisplayName: "B厂区通道7", InputBindings: map[string]InputBindingEditor{ "video_input_main": {VideoSourceRef: "gate_cam_07"}, }, OutputBindings: map[string]OutputBindingEditor{ "stream_output_main": { PublishRTSPPort: "8558", }, }, }}, }) if err != nil { t.Fatalf("BuildProfileDocument: %v", err) } instances, _ := doc["instances"].([]map[string]any) inst := instances[0] outputBindings, _ := inst["output_bindings"].(map[string]any) streamOutput, _ := outputBindings["stream_output_main"].(map[string]any) if streamOutput["publish_hls_path"] != "./web/hls/cam7/index.m3u8" { t.Fatalf("expected default hls path, got %#v", streamOutput) } if streamOutput["publish_rtsp_path"] != "/live/cam7" { t.Fatalf("expected default rtsp path, got %#v", streamOutput) } if streamOutput["channel_no"] != "cam7" { t.Fatalf("expected default channel no, got %#v", streamOutput) } if streamOutput["publish_rtsp_port"] != 8558 { t.Fatalf("expected explicit rtsp port to be preserved, got %#v", streamOutput) } } func TestListVideoSources(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.SaveVideoSource( "gate_cam_01", "rtsp", "东门入口", "东门主入口摄像头", `{"name":"gate_cam_01","source_type":"rtsp","area":"东门入口","description":"东门主入口摄像头","config":{"url":"rtsp://10.0.0.1/live","resolution":"1080p","frame_size":"1920x1080","fps":"25","video_format":"h264","focal_length":"4mm","mount_height":"3.2m","mount_angle":"15deg"}}`, ); err != nil { t.Fatalf("SaveVideoSource: %v", err) } svc := NewConfigPreviewService(&config.Config{}, repo) items, err := svc.ListVideoSources() if err != nil { t.Fatalf("ListVideoSources: %v", err) } if len(items) != 1 { t.Fatalf("expected 1 video source, got %#v", items) } if items[0].SourceType != "rtsp" || items[0].SourceTypeLabel != "RTSP" { t.Fatalf("unexpected video source summary: %#v", items[0]) } if items[0].Config.Resolution != "1080p" || items[0].Config.FrameSize != "1920x1080" { t.Fatalf("unexpected video source config: %#v", items[0]) } } func TestDeleteVideoSourceBlocksWhenReferenced(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.SaveVideoSource( "gate_cam_01", "rtsp", "东门入口", "东门主入口摄像头", `{"name":"gate_cam_01","source_type":"rtsp","config":{"url":"rtsp://10.0.0.1/live"}}`, ); err != nil { t.Fatalf("SaveVideoSource: %v", err) } 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"}}}]}`); err != nil { t.Fatalf("SaveProfile: %v", err) } svc := NewConfigPreviewService(&config.Config{}, repo) err = svc.DeleteVideoSource("gate_cam_01") if err == nil || !strings.Contains(err.Error(), "已被场景模板引用") { t.Fatalf("expected referenced delete to be blocked, got %v", err) } } func TestSaveVideoSourceAssetAllowsChineseName(t *testing.T) { store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) if err != nil { t.Fatalf("OpenSQLite: %v", err) } defer store.Close() svc := NewConfigPreviewService(&config.Config{}, storage.NewAssetsRepo(store.DB())) err = svc.SaveVideoSourceAsset(ConfigVideoSourceAsset{ Name: "东门主入口", SourceType: "rtsp", Area: "东门", Description: "入口相机", Config: VideoSourceConfig{ URL: "rtsp://10.0.0.1/live", }, }) if err != nil { t.Fatalf("expected chinese video source name to be accepted, got %v", err) } item, err := svc.GetVideoSource("东门主入口") if err != nil { t.Fatalf("GetVideoSource: %v", err) } if item == nil || item.Name != "东门主入口" { t.Fatalf("unexpected saved item: %#v", item) } } func TestConfigPreviewServiceBuildProfileDocumentRejectsBadPort(t *testing.T) { svc := NewConfigPreviewService(&config.Config{}) _, err := svc.BuildProfileDocument(ConfigProfileEditor{ Name: "local_3588_test", Instances: []ConfigProfileInstanceEditor{ { Name: "cam1", VideoSourceRef: "gate_cam_01", DisplayName: "视觉识别终端-A厂区", PublishRTSPPort: "bad-port", }, }, }) if err == nil { t.Fatal("expected invalid port error") } } func TestConfigPreviewServiceBuildProfileDocumentJSONShape(t *testing.T) { svc := NewConfigPreviewService(&config.Config{}) doc, err := svc.BuildProfileDocument(ConfigProfileEditor{ Name: "local_3588_test", Instances: []ConfigProfileInstanceEditor{ { Name: "cam1", VideoSourceRef: "gate_cam_01", DisplayName: "视觉识别终端-A厂区", }, }, }) if err != nil { t.Fatalf("BuildProfileDocument: %v", err) } body, err := json.Marshal(doc) if err != nil { t.Fatalf("Marshal: %v", err) } if len(body) == 0 { t.Fatal("expected json body") } } func TestConfigPreviewServiceListsSourcesFromAssetsRepo(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) } 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) } if err := repo.SaveOverlay("night_relaxed", "night overlay", `{"name":"night_relaxed","instance_overrides":{"cam1":{"override":{}}}}`); err != nil { t.Fatalf("SaveOverlay: %v", err) } svc := NewConfigPreviewService(&config.Config{}, repo) sources, err := svc.ListSources() if err != nil { t.Fatalf("ListSources: %v", err) } if got := sourceNames(sources.Templates); len(got) != 1 || got[0] != "helmet" { t.Fatalf("unexpected templates: %#v", got) } if got := sourceNames(sources.Profiles); len(got) != 1 || got[0] != "gate_a" { t.Fatalf("unexpected profiles: %#v", got) } if got := sourceNames(sources.Overlays); len(got) != 1 || got[0] != "night_relaxed" { t.Fatalf("unexpected overlays: %#v", got) } } func TestListIntegrationServices(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.SaveIntegrationService( "minio_primary", "object_storage", "primary object store", false, `{"name":"minio_primary","type":"object_storage","provider":"minio","enabled":false,"config":{"endpoint":"http://10.0.0.49:9000","bucket":"myminio","access_key":"admin","secret_key":"password"}}`, ); err != nil { t.Fatalf("SaveIntegrationService: %v", err) } svc := NewConfigPreviewService(&config.Config{}, repo) items, err := svc.ListIntegrationServices() if err != nil { t.Fatalf("ListIntegrationServices: %v", err) } if len(items) != 1 { t.Fatalf("expected 1 integration service, got %#v", items) } if items[0].Name != "minio_primary" || items[0].Type != "object_storage" || items[0].Enabled { t.Fatalf("unexpected integration service summary: %#v", items[0]) } item, err := svc.GetIntegrationService("minio_primary") if err != nil { t.Fatalf("GetIntegrationService: %v", err) } if item == nil { t.Fatal("expected integration service") } if item.Description != "primary object store" { t.Fatalf("unexpected integration service description: %#v", item) } if item.Type != "object_storage" || item.Enabled { t.Fatalf("unexpected integration service status: %#v", item) } if got := stringValue(item.Raw["type"]); got != "object_storage" { t.Fatalf("expected raw type to come from body_json, got %#v", item.Raw) } if got := stringValue(item.Raw["provider"]); got != "minio" { t.Fatalf("unexpected integration service provider: %#v", item.Raw) } if item.TypeLabel != "对象存储" || item.AddressSummary != "http://10.0.0.49:9000 / myminio" { t.Fatalf("unexpected integration display fields: %#v", item) } if item.ObjectStorage == nil || item.ObjectStorage.Bucket != "myminio" { t.Fatalf("expected object storage details, got %#v", item) } configMap, _ := item.Raw["config"].(map[string]any) if got := stringValue(configMap["endpoint"]); got != "http://10.0.0.49:9000" { t.Fatalf("unexpected integration service endpoint: %#v", item.Raw) } if enabled, ok := item.Raw["enabled"].(bool); !ok || enabled { t.Fatalf("expected raw enabled=false, got %#v", item.Raw) } } func TestListIntegrationServicesCountsProfileReferences(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.SaveIntegrationService( "minio_primary", "object_storage", "primary object store", true, `{"name":"minio_primary","type":"object_storage","config":{"endpoint":"http://10.0.0.49:9000","bucket":"myminio","access_key":"admin","secret_key":"password"}}`, ); err != nil { t.Fatalf("SaveIntegrationService: %v", err) } 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_primary"}}}]}`); err != nil { t.Fatalf("SaveProfile: %v", err) } svc := NewConfigPreviewService(&config.Config{}, repo) items, err := svc.ListIntegrationServices() if err != nil { t.Fatalf("ListIntegrationServices: %v", err) } if len(items) != 1 || items[0].RefCount != 1 { t.Fatalf("expected one referenced service, got %#v", items) } } func TestGetIntegrationServiceNotFound(t *testing.T) { store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) if err != nil { t.Fatalf("OpenSQLite: %v", err) } defer store.Close() svc := NewConfigPreviewService(&config.Config{}, storage.NewAssetsRepo(store.DB())) item, err := svc.GetIntegrationService("missing_service") if !strings.Contains(err.Error(), "file does not exist") && !os.IsNotExist(err) { t.Fatalf("expected not found error, got item=%#v err=%v", item, err) } if item != nil { t.Fatalf("expected nil item for missing integration service, got %#v", item) } } func TestGetIntegrationServicePrefersRecordTypeOverRawType(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.SaveIntegrationService( "minio_primary", "object_storage", "primary object store", true, `{"name":"minio_primary","type":"minio","provider":"minio","enabled":true}`, ); err != nil { t.Fatalf("SaveIntegrationService: %v", err) } svc := NewConfigPreviewService(&config.Config{}, repo) item, err := svc.GetIntegrationService("minio_primary") if err != nil { t.Fatalf("GetIntegrationService: %v", err) } if item.Type != "object_storage" { t.Fatalf("expected canonical type from record, got %#v", item) } if got := stringValue(item.Raw["type"]); got != "minio" { t.Fatalf("expected raw type to preserve original body_json, got %#v", item.Raw) } } func TestDeleteIntegrationServiceBlocksWhenReferenced(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.SaveIntegrationService( "minio_primary", "object_storage", "primary object store", true, `{"name":"minio_primary","type":"object_storage","config":{"endpoint":"http://10.0.0.49:9000","bucket":"myminio","access_key":"admin","secret_key":"password"}}`, ); err != nil { t.Fatalf("SaveIntegrationService: %v", err) } 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_primary"}}}]}`); err != nil { t.Fatalf("SaveProfile: %v", err) } svc := NewConfigPreviewService(&config.Config{}, repo) err = svc.DeleteIntegrationService("minio_primary") if err == nil || !strings.Contains(err.Error(), "used by scene configs") { t.Fatalf("expected referenced delete to be blocked, got %v", err) } } func TestConfigPreviewServiceSavesProfileEditorToAssetsRepo(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()) svc := NewConfigPreviewService(&config.Config{}, repo) editor := ConfigProfileEditor{ Name: "gate_a", BusinessName: "厂区入口", Description: "白班识别", SiteName: "A厂区", Instances: []ConfigProfileInstanceEditor{ { Name: "cam1", Template: "helmet", DisplayName: "东门入口", VideoSourceRef: "gate_cam_01", }, }, } if err := svc.SaveProfileEditor(editor); err != nil { t.Fatalf("SaveProfileEditor: %v", err) } saved, err := repo.GetProfile("gate_a") if err != nil { t.Fatalf("GetProfile: %v", err) } if saved == nil { t.Fatal("expected saved profile") } if saved.BusinessName != "厂区入口" { t.Fatalf("expected business name, got %#v", saved) } if saved.TemplateName != "helmet" { t.Fatalf("expected template name to be inferred, got %#v", saved) } if saved.Description != "白班识别" { t.Fatalf("expected description, got %#v", saved) } } func TestConfigPreviewServicePrefersRepoTemplateOverBuiltinFallback(t *testing.T) { root := t.TempDir() mustWrite(t, filepath.Join(root, "configs", "templates", "helmet.json"), `{ "name": "helmet", "description": "builtin template", "template": {"nodes": [{"id":"input_rtsp_main"}], "edges": []} }`) 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", "shadow template", `{"name":"helmet","description":"shadow template","template":{"nodes":[],"edges":[]}}`); err != nil { t.Fatalf("SaveTemplate: %v", err) } svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) item, err := svc.GetTemplateAsset("helmet") if err != nil { t.Fatalf("GetTemplateAsset: %v", err) } if item.ReadOnly || item.Origin != "user" { t.Fatalf("expected sqlite template to be preferred, got %#v", item) } if item.Description != "shadow template" { t.Fatalf("expected sqlite template payload, got %#v", item) } items, err := svc.ListTemplateAssets() if err != nil { t.Fatalf("ListTemplateAssets: %v", err) } if len(items) != 1 || items[0].Name != "helmet" || items[0].ReadOnly { t.Fatalf("expected only sqlite template in merged list, got %#v", items) } } func TestConfigPreviewServiceRejectsSavingStandardTemplateName(t *testing.T) { root := t.TempDir() 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()) svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) err = svc.SaveTemplateAsset("std_face_recognition_stream", "new body", `{"name":"std_face_recognition_stream","template":{"nodes":[],"edges":[]}}`) if err == nil || !strings.Contains(err.Error(), "read-only") { t.Fatalf("expected readonly rejection, got %v", err) } } func TestConfigPreviewServiceRenamesTemplateAndUpdatesProfileRefs(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","description":"helmet template","template":{"nodes":[],"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) } svc := NewConfigPreviewService(&config.Config{}, repo) err = svc.RenameTemplateAsset("helmet", "helmet_v2", "helmet v2", `{"name":"helmet_v2","description":"helmet v2","template":{"nodes":[],"edges":[]}}`) if err != nil { t.Fatalf("RenameTemplateAsset: %v", err) } record, err := repo.GetTemplate("helmet_v2") if err != nil { t.Fatalf("GetTemplate: %v", err) } if record == nil || !strings.Contains(record.BodyJSON, `"name":"helmet_v2"`) { t.Fatalf("expected renamed template, got %#v", record) } profile, err := repo.GetProfile("gate_a") if err != nil { t.Fatalf("GetProfile: %v", err) } if profile == nil || profile.TemplateName != "helmet_v2" || (!strings.Contains(profile.BodyJSON, `"primary_template_name": "helmet_v2"`) && !strings.Contains(profile.BodyJSON, `"primary_template_name":"helmet_v2"`)) { t.Fatalf("expected updated profile refs, got %#v", profile) } } func TestConfigPreviewServiceRejectsDeletingReferencedTemplate(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) } 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) } svc := NewConfigPreviewService(&config.Config{}, repo) err = svc.DeleteTemplateAsset("helmet") if err == nil || !strings.Contains(err.Error(), "used by business configs") { t.Fatalf("expected reference rejection, got %v", err) } } func TestConfigPreviewServiceImportAssetsIncludesTemplates(t *testing.T) { root := t.TempDir() mustWrite(t, filepath.Join(root, "configs", "templates", "std_face_recognition_stream.json"), `{"name":"std_face_recognition_stream","template":{"nodes":[],"edges":[]}}`) mustWrite(t, filepath.Join(root, "configs", "profiles", "gate_a.json"), `{"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"}}}]}`) mustWrite(t, filepath.Join(root, "configs", "overlays", "night_relaxed.json"), `{"name":"night_relaxed","instance_overrides":{"cam1":{"override":{}}}}`) 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()) svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) result, err := svc.ImportAssetsFromMediaRepo() if err != nil { t.Fatalf("ImportAssetsFromMediaRepo: %v", err) } if result.Templates != 1 || result.Profiles != 1 || result.Overlays != 1 { t.Fatalf("unexpected import result: %#v", result) } record, err := repo.GetTemplate("std_face_recognition_stream") if err != nil { t.Fatalf("GetTemplate: %v", err) } if record == nil || !strings.Contains(record.BodyJSON, `"source": "standard"`) && !strings.Contains(record.BodyJSON, `"source":"standard"`) { t.Fatalf("expected standard template to be imported into sqlite, got %#v", record) } } func TestImportStandardTemplatesFromDirSyncsExisting(t *testing.T) { root := t.TempDir() mustWrite(t, filepath.Join(root, "std_face_recognition_stream.json"), `{"name":"std_face_recognition_stream","description":"standard face","template":{"nodes":[],"edges":[]}}`) mustWrite(t, filepath.Join(root, "std_service_test_stream.json"), `{"name":"std_service_test_stream","description":"standard service","template":{"nodes":[],"edges":[]}}`) 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_service_test_stream", "existing service", `{"name":"std_service_test_stream","source":"standard","template":{"nodes":[],"edges":[]}}`); err != nil { t.Fatalf("SaveTemplate: %v", err) } imported, err := ImportStandardTemplatesFromDir(repo, root) if err != nil { t.Fatalf("ImportStandardTemplatesFromDir: %v", err) } if imported != 2 { t.Fatalf("expected two synced standard templates, got %d", imported) } face, err := repo.GetTemplate("std_face_recognition_stream") if err != nil || face == nil { t.Fatalf("expected imported face template, got %#v err=%v", face, err) } if !strings.Contains(face.BodyJSON, `"source": "standard"`) && !strings.Contains(face.BodyJSON, `"source":"standard"`) { t.Fatalf("expected imported template source marker, got %#v", face) } serviceRecord, err := repo.GetTemplate("std_service_test_stream") if err != nil || serviceRecord == nil { t.Fatalf("expected existing service template, got %#v err=%v", serviceRecord, err) } if serviceRecord.Description != "standard service" { t.Fatalf("expected existing template to sync from directory, got %#v", serviceRecord) } if !strings.Contains(serviceRecord.BodyJSON, `"description": "standard service"`) && !strings.Contains(serviceRecord.BodyJSON, `"description":"standard service"`) { t.Fatalf("expected synced template body, got %#v", serviceRecord) } } func TestBuildDeviceAssignmentBoardDataSummarizesLoad(t *testing.T) { devices := []*models.Device{ {DeviceID: "edge-01", DeviceName: "设备一"}, {DeviceID: "edge-02", DeviceName: "设备二"}, {DeviceID: "edge-03", DeviceName: "设备三"}, {DeviceID: "edge-04", DeviceName: "设备四"}, } units := []RecognitionUnitAsset{ {Ref: "scene_a::cam1", SceneTemplateName: "scene_a", Name: "cam1", VideoSourceRef: "vs1"}, {Ref: "scene_a::cam2", SceneTemplateName: "scene_a", Name: "cam2", VideoSourceRef: "vs2"}, {Ref: "scene_a::cam3", SceneTemplateName: "scene_a", Name: "cam3", VideoSourceRef: "vs3"}, {Ref: "scene_a::cam4", SceneTemplateName: "scene_a", Name: "cam4", VideoSourceRef: "vs4"}, {Ref: "scene_a::cam5", SceneTemplateName: "scene_a", Name: "cam5", VideoSourceRef: "vs5"}, {Ref: "scene_a::cam6", SceneTemplateName: "scene_a", Name: "cam6", VideoSourceRef: "vs6"}, } assignments := []DeviceAssignmentAsset{ {DeviceID: "edge-01", ProfileName: "scene_a", RecognitionUnits: []string{"scene_a::cam1", "scene_a::cam2", "scene_a::cam3"}, RecognitionCount: 3}, {DeviceID: "edge-02", ProfileName: "scene_a", RecognitionUnits: []string{"scene_a::cam4"}, RecognitionCount: 1}, {DeviceID: "edge-03", ProfileName: "scene_a", RecognitionUnits: []string{"scene_a::cam5", "scene_a::cam6"}, RecognitionCount: 2}, } board := BuildDeviceAssignmentBoardData(devices, assignments, units, 4) if board.Stats.TotalUnits != 6 || board.Stats.TotalDevices != 4 { t.Fatalf("unexpected totals: %#v", board.Stats) } if board.Stats.AssignedUnits != 6 || board.Stats.UnassignedUnits != 0 { t.Fatalf("unexpected assignment counts: %#v", board.Stats) } if board.Stats.OverloadedDevices != 0 { t.Fatalf("expected no full devices in this dataset, got %#v", board.Stats) } if len(board.Cards) != 4 { t.Fatalf("expected four device cards, got %#v", board.Cards) } if board.Cards[0].DeviceID != "edge-01" || board.Cards[0].Status != "busy" { t.Fatalf("expected >50%% loaded device to sort first, got %#v", board.Cards) } if board.Cards[1].DeviceID != "edge-03" || board.Cards[1].Status != "busy" { t.Fatalf("expected 50%% loaded device to count as busy, got %#v", board.Cards) } if board.Cards[3].Status != "idle" { t.Fatalf("expected idle device card, got %#v", board.Cards[3]) } } func TestBuildDeviceAssignmentBoardDataTreatsOverMaxAsFull(t *testing.T) { devices := []*models.Device{{DeviceID: "edge-01", DeviceName: "设备一"}} units := []RecognitionUnitAsset{ {Ref: "scene_a::cam1", SceneTemplateName: "scene_a", Name: "cam1"}, {Ref: "scene_a::cam2", SceneTemplateName: "scene_a", Name: "cam2"}, {Ref: "scene_a::cam3", SceneTemplateName: "scene_a", Name: "cam3"}, } assignments := []DeviceAssignmentAsset{ {DeviceID: "edge-01", ProfileName: "scene_a", RecognitionUnits: []string{"scene_a::cam1", "scene_a::cam2", "scene_a::cam3"}, RecognitionCount: 3}, } board := BuildDeviceAssignmentBoardData(devices, assignments, units, 2) if board.Cards[0].Status != "full" { t.Fatalf("expected over-max card to collapse into full, got %#v", board.Cards[0]) } if board.Stats.OverloadedDevices != 1 { t.Fatalf("expected full-device counter to include over-max card, got %#v", board.Stats) } } func TestBuildAutoDeviceAssignmentsBalancesUnits(t *testing.T) { devices := []*models.Device{ {DeviceID: "edge-01"}, {DeviceID: "edge-02"}, {DeviceID: "edge-03"}, } units := []RecognitionUnitAsset{ {Ref: "scene_a::cam1", SceneTemplateName: "scene_a", Name: "cam1"}, {Ref: "scene_a::cam2", SceneTemplateName: "scene_a", Name: "cam2"}, {Ref: "scene_a::cam3", SceneTemplateName: "scene_a", Name: "cam3"}, {Ref: "scene_a::cam4", SceneTemplateName: "scene_a", Name: "cam4"}, {Ref: "scene_a::cam5", SceneTemplateName: "scene_a", Name: "cam5"}, } assignments := BuildAutoDeviceAssignments(devices, units, 2) if len(assignments) != 3 { t.Fatalf("expected three populated device assignments, got %#v", assignments) } counts := map[string]int{} total := 0 for _, item := range assignments { counts[item.DeviceID] = len(item.RecognitionUnits) total += len(item.RecognitionUnits) if item.ProfileName != "scene_a" { t.Fatalf("expected scene template preserved, got %#v", item) } if len(item.RecognitionUnits) > 2 { t.Fatalf("expected max two units per device, got %#v", item) } } if total != 5 { t.Fatalf("expected all units assigned, got %#v", assignments) } if counts["edge-01"] != 2 || counts["edge-02"] != 2 || counts["edge-03"] != 1 { t.Fatalf("expected balanced distribution, got %#v", counts) } } func TestSaveDeviceAssignmentBoardPersistsAssignments(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.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) } if err := repo.SaveRecognitionUnit(storage.RecognitionUnitRecord{ SceneTemplateName: "scene_a", Name: "cam2", VideoSourceRef: "vs2", OutputChannel: "cam2", RTSPPort: "8555", BodyJSON: `{"name":"cam2","input_bindings":{"video_input_main":{"video_source_ref":"vs2"}}}`, }); err != nil { t.Fatalf("SaveRecognitionUnit cam2: %v", err) } svc := NewConfigPreviewService(&config.Config{}, repo) if err := svc.SaveDeviceAssignmentBoard(map[string][]string{ "edge-01": []string{"scene_a::cam1", "scene_a::cam2"}, "edge-02": []string{}, }); err != nil { t.Fatalf("SaveDeviceAssignmentBoard: %v", err) } item, err := svc.GetDeviceAssignment("edge-01") if err != nil { t.Fatalf("GetDeviceAssignment: %v", err) } if item == nil || item.ProfileName != "scene_a" || len(item.RecognitionUnits) != 2 { t.Fatalf("unexpected saved assignment: %#v", item) } }