3588AdminBackend/internal/service/config_assets_test.go

1112 lines
42 KiB
Go

package service
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"3588AdminBackend/internal/config"
"3588AdminBackend/internal/models"
"3588AdminBackend/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)
}
}