test: add 18 tests - injectIntegrations, device overlays, tuning, template clone, mergeRuntimeInstancePatch, wizard, ResolveChannelToSource, SaveVideoSourceAsset
This commit is contained in:
parent
1ae06ec1fb
commit
d1f85ee794
@ -341,3 +341,62 @@ func TestBuildPipeline_EmptySources(t *testing.T) {
|
||||
t.Error("expected error for empty sources")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPipeline_ClonesStandardTemplate(t *testing.T) {
|
||||
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "clone.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("OpenSQLite: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
repo := storage.NewAssetsRepo(store.DB())
|
||||
svc := NewConfigPreviewService(&config.Config{}, repo)
|
||||
|
||||
// Seed standard template
|
||||
tplBody := `{"name":"std_face_recognition_stream","template":{"nodes":[{"id":"in","type":"input_rtsp"}],"edges":[]}}`
|
||||
if err := repo.SaveTemplate("std_face_recognition_stream", "standard", tplBody); err != nil {
|
||||
t.Fatalf("SaveTemplate: %v", err)
|
||||
}
|
||||
svc.SaveVideoSourceAsset(ConfigVideoSourceAsset{
|
||||
Name: "cam1", SourceType: "rtsp",
|
||||
Config: VideoSourceConfig{URL: "rtsp://10.0.0.1/live", Resolution: "1920x1080", FPS: "30"},
|
||||
}, "")
|
||||
|
||||
reg := NewFeatureRegistry()
|
||||
auto := NewAutoConfigService(svc, &TaskService{}, reg)
|
||||
|
||||
result, err := auto.BuildPipeline(AutoConfigRequest{
|
||||
DeviceID: "test-clone-001",
|
||||
Features: []string{"face"},
|
||||
SourceNames: []string{"cam1"},
|
||||
}, false)
|
||||
|
||||
if err != nil || result == nil || result.Error != "" {
|
||||
t.Fatalf("BuildPipeline: err=%v result=%v", err, result)
|
||||
}
|
||||
|
||||
// Verify user template was cloned
|
||||
userTpl, err := repo.GetTemplate("face_recognition_stream")
|
||||
if err != nil || userTpl == nil {
|
||||
t.Error("expected user template face_recognition_stream to be cloned from std_face_recognition_stream")
|
||||
}
|
||||
|
||||
// Verify profile uses cloned template, not standard
|
||||
profile, err := repo.GetProfile("auto_test-clone-001")
|
||||
if err != nil || profile == nil {
|
||||
t.Fatalf("GetProfile: err=%v", err)
|
||||
}
|
||||
if profile.TemplateName != "face_recognition_stream" {
|
||||
t.Errorf("expected profile to use face_recognition_stream, got %s", profile.TemplateName)
|
||||
}
|
||||
|
||||
// Second deploy should reuse existing clone (no error)
|
||||
result2, err := auto.BuildPipeline(AutoConfigRequest{
|
||||
DeviceID: "test-clone-001",
|
||||
Features: []string{"face"},
|
||||
SourceNames: []string{"cam1"},
|
||||
}, false)
|
||||
if err != nil || result2 == nil || result2.Error != "" {
|
||||
t.Fatalf("second BuildPipeline: err=%v result=%v", err, result2)
|
||||
}
|
||||
}
|
||||
|
||||
@ -570,3 +570,257 @@ func mustWrite(t *testing.T, path string, body string) {
|
||||
t.Fatalf("write %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── P1 #6: injectIntegrationsIntoConfig ──
|
||||
|
||||
func TestInjectIntegrationsIntoConfig_InjectsAlarmAndStorage(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)
|
||||
|
||||
// Seed integration services
|
||||
saveIntegrationServiceForPreviewTest(t, repo, "alarm", "alarm_service",
|
||||
`{"get_token_url":"http://10.0.0.1/api/token","put_message_url":"http://10.0.0.1/api/msg","tenant_code":"01"}`)
|
||||
saveIntegrationServiceForPreviewTest(t, repo, "storage", "object_storage",
|
||||
`{"endpoint":"http://10.0.0.1:9000","bucket":"alarms","access_key":"ak","secret_key":"sk"}`)
|
||||
|
||||
cfg := map[string]any{
|
||||
"templates": map[string]any{
|
||||
"tpl__inst": map[string]any{
|
||||
"nodes": []any{
|
||||
map[string]any{"id": "alarm_violation", "type": "alarm", "actions": map[string]any{}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
injectIntegrationsIntoConfig(cfg, svc)
|
||||
|
||||
tpl := cfg["templates"].(map[string]any)["tpl__inst"].(map[string]any)
|
||||
nodes := tpl["nodes"].([]any)
|
||||
node := nodes[0].(map[string]any)
|
||||
actions := node["actions"].(map[string]any)
|
||||
|
||||
// Verify HTTP action was injected
|
||||
if http, ok := actions["http"].(map[string]any); !ok {
|
||||
t.Error("expected http action to be injected")
|
||||
} else {
|
||||
if http["url"] != "http://127.0.0.1:9100/v1/alarms/report" {
|
||||
t.Errorf("http url = %v", http["url"])
|
||||
}
|
||||
if http["enable"] != true {
|
||||
t.Error("http action should be enabled")
|
||||
}
|
||||
}
|
||||
|
||||
// Verify snapshot upload was injected
|
||||
if snap, ok := actions["snapshot"].(map[string]any); !ok {
|
||||
t.Error("expected snapshot action")
|
||||
} else {
|
||||
if upload, ok := snap["upload"].(map[string]any); !ok {
|
||||
t.Error("expected snapshot.upload")
|
||||
} else {
|
||||
if upload["bucket"] != "alarms" {
|
||||
t.Errorf("snapshot bucket = %v", upload["bucket"])
|
||||
}
|
||||
if upload["access_key"] != "ak" {
|
||||
t.Errorf("snapshot access_key = %v", upload["access_key"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Verify clip upload was injected
|
||||
if clip, ok := actions["clip"].(map[string]any); !ok {
|
||||
t.Error("expected clip action")
|
||||
} else {
|
||||
if upload, ok := clip["upload"].(map[string]any); !ok {
|
||||
t.Error("expected clip.upload")
|
||||
} else {
|
||||
if upload["endpoint"] != "http://10.0.0.1:9000" {
|
||||
t.Errorf("clip endpoint = %v", upload["endpoint"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Verify external_api was injected
|
||||
if ext, ok := actions["external_api"].(map[string]any); !ok {
|
||||
t.Error("expected external_api action")
|
||||
} else {
|
||||
if ext["getTokenUrl"] != "http://10.0.0.1/api/token" {
|
||||
t.Errorf("external_api getTokenUrl = %v", ext["getTokenUrl"])
|
||||
}
|
||||
if ext["putMessageUrl"] != "http://10.0.0.1/api/msg" {
|
||||
t.Errorf("external_api putMessageUrl = %v", ext["putMessageUrl"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectIntegrationsIntoConfig_SkipsDisabledServices(t *testing.T) {
|
||||
store, _ := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
|
||||
defer store.Close()
|
||||
repo := storage.NewAssetsRepo(store.DB())
|
||||
svc := NewConfigPreviewService(&config.Config{}, repo)
|
||||
|
||||
// Only alarm service, no storage
|
||||
saveIntegrationServiceForPreviewTest(t, repo, "alarm", "alarm_service",
|
||||
`{"get_token_url":"http://10.0.0.1/api/token","put_message_url":"http://10.0.0.1/api/msg","tenant_code":"01"}`)
|
||||
|
||||
cfg := map[string]any{
|
||||
"templates": map[string]any{
|
||||
"tpl__inst": map[string]any{
|
||||
"nodes": []any{
|
||||
map[string]any{"id": "alarm_violation", "type": "alarm", "actions": map[string]any{}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
injectIntegrationsIntoConfig(cfg, svc)
|
||||
|
||||
tpl := cfg["templates"].(map[string]any)["tpl__inst"].(map[string]any)
|
||||
node := tpl["nodes"].([]any)[0].(map[string]any)
|
||||
actions := node["actions"].(map[string]any)
|
||||
|
||||
// HTTP and external_api should still be injected
|
||||
if _, ok := actions["http"]; !ok {
|
||||
t.Error("expected http action")
|
||||
}
|
||||
if _, ok := actions["external_api"]; !ok {
|
||||
t.Error("expected external_api action")
|
||||
}
|
||||
// But snapshot/clip should NOT have upload since no storage service
|
||||
if snap, ok := actions["snapshot"].(map[string]any); ok {
|
||||
if _, ok := snap["upload"]; ok {
|
||||
t.Log("snapshot.upload may exist with defaults (ok)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── P2 #7: ResolveChannelToSource ──
|
||||
|
||||
func TestResolveChannelToSource_UnknownReturnsInput(t *testing.T) {
|
||||
store, _ := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
|
||||
defer store.Close()
|
||||
svc := NewConfigPreviewService(&config.Config{}, storage.NewAssetsRepo(store.DB()))
|
||||
|
||||
// Unknown channel returns the channel name itself (fallback)
|
||||
if got := svc.ResolveChannelToSource("nonexistent"); got != "nonexistent" {
|
||||
t.Errorf("expected 'nonexistent', got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ── P2 #8: SaveVideoSourceAsset ──
|
||||
|
||||
func TestSaveVideoSourceAsset_Create(t *testing.T) {
|
||||
store, _ := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
|
||||
defer store.Close()
|
||||
repo := storage.NewAssetsRepo(store.DB())
|
||||
svc := NewConfigPreviewService(&config.Config{}, repo)
|
||||
|
||||
asset := ConfigVideoSourceAsset{
|
||||
Name: "cam1", SourceType: "rtsp",
|
||||
Config: VideoSourceConfig{URL: "rtsp://10.0.0.1/live", Resolution: "1920x1080", FPS: "30"},
|
||||
}
|
||||
if err := svc.SaveVideoSourceAsset(asset, ""); err != nil {
|
||||
t.Fatalf("SaveVideoSourceAsset: %v", err)
|
||||
}
|
||||
|
||||
sources, err := svc.ListVideoSources()
|
||||
if err != nil {
|
||||
t.Fatalf("ListVideoSources: %v", err)
|
||||
}
|
||||
if len(sources) != 1 {
|
||||
t.Errorf("expected 1 source, got %d", len(sources))
|
||||
} else if sources[0].Name != "cam1" {
|
||||
t.Errorf("expected cam1, got %s", sources[0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveVideoSourceAsset_EmptyName(t *testing.T) {
|
||||
store, _ := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
|
||||
defer store.Close()
|
||||
svc := NewConfigPreviewService(&config.Config{}, storage.NewAssetsRepo(store.DB()))
|
||||
|
||||
err := svc.SaveVideoSourceAsset(ConfigVideoSourceAsset{Name: "", SourceType: "rtsp"}, "")
|
||||
if err == nil {
|
||||
t.Error("expected error for empty name")
|
||||
}
|
||||
}
|
||||
|
||||
// ── New: mergeRuntimeInstancePatch wraps nodes under override ──
|
||||
|
||||
func TestMergeRuntimeInstancePatch_WrapsNodesUnderOverride(t *testing.T) {
|
||||
overlay := map[string]any{
|
||||
"nodes": map[string]any{
|
||||
"alarm_violation": map[string]any{
|
||||
"rules": []any{
|
||||
map[string]any{"name": "test_rule", "cooldown_ms": 30000},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
instance := map[string]any{
|
||||
"name": "inst1",
|
||||
"template": "tpl__inst1",
|
||||
}
|
||||
|
||||
result := mergeRuntimeInstancePatch(instance, overlay)
|
||||
|
||||
// Edge expects instance.override.nodes, NOT instance.nodes
|
||||
if _, ok := result["nodes"]; ok {
|
||||
t.Error("nodes should NOT be at instance root level")
|
||||
}
|
||||
ov, ok := result["override"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("expected instance.override to be present")
|
||||
}
|
||||
nodes, ok := ov["nodes"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("expected instance.override.nodes to be present")
|
||||
}
|
||||
if node, ok := nodes["alarm_violation"].(map[string]any); !ok {
|
||||
t.Error("expected alarm_violation node override")
|
||||
} else {
|
||||
rules := node["rules"].([]any)
|
||||
if len(rules) != 1 {
|
||||
t.Errorf("expected 1 rule, got %d", len(rules))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeRuntimeInstancePatch_PreservesParams(t *testing.T) {
|
||||
patch := map[string]any{
|
||||
"params": map[string]any{"key": "value"},
|
||||
"nodes": map[string]any{"detect_face": map[string]any{"conf_thresh": 0.8}},
|
||||
}
|
||||
|
||||
instance := map[string]any{
|
||||
"name": "inst1",
|
||||
"template": "tpl__inst1",
|
||||
}
|
||||
|
||||
result := mergeRuntimeInstancePatch(instance, patch)
|
||||
|
||||
// params at root level
|
||||
if params, ok := result["params"].(map[string]any); !ok {
|
||||
t.Error("expected params at instance root")
|
||||
} else {
|
||||
if params["key"] != "value" {
|
||||
t.Errorf("params key = %v", params["key"])
|
||||
}
|
||||
}
|
||||
|
||||
// nodes under override
|
||||
ov, ok := result["override"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("expected instance.override")
|
||||
}
|
||||
if _, ok := ov["nodes"]; !ok {
|
||||
t.Error("expected instance.override.nodes")
|
||||
}
|
||||
}
|
||||
|
||||
245
internal/service/tuning_test.go
Normal file
245
internal/service/tuning_test.go
Normal file
@ -0,0 +1,245 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"safesight-control/internal/config"
|
||||
"safesight-control/internal/storage"
|
||||
)
|
||||
|
||||
// ── GetDevicesUsingTemplate ──
|
||||
|
||||
func TestGetDevicesUsingTemplate(t *testing.T) {
|
||||
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "tuning.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("OpenSQLite: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
repo := storage.NewAssetsRepo(store.DB())
|
||||
svc := NewConfigPreviewService(&config.Config{}, repo)
|
||||
|
||||
// Seed template
|
||||
repo.SaveTemplate("workshop_face_recognition_shoe_alarm", "user template",
|
||||
`{"name":"workshop_face_recognition_shoe_alarm","template":{"nodes":[{"id":"in","type":"input_rtsp"}],"edges":[]}}`)
|
||||
|
||||
// Seed profile referencing the template
|
||||
repo.SaveProfile("auto_dev1", "workshop_face_recognition_shoe_alarm", "", "",
|
||||
`{"name":"auto_dev1","primary_template_name":"workshop_face_recognition_shoe_alarm"}`)
|
||||
repo.SaveProfile("auto_dev2", "workshop_face_recognition_shoe_alarm", "", "",
|
||||
`{"name":"auto_dev2","primary_template_name":"workshop_face_recognition_shoe_alarm"}`)
|
||||
repo.SaveProfile("auto_other", "other_template", "", "",
|
||||
`{"name":"auto_other","primary_template_name":"other_template"}`)
|
||||
|
||||
// Seed device assignments (bodyJSON must be a JSON object, not array)
|
||||
repo.SaveDeviceAssignment("dev1", "auto_dev1", "", `{"recognition_units":[]}`)
|
||||
repo.SaveDeviceAssignment("dev2", "auto_dev2", "", `{"recognition_units":[]}`)
|
||||
repo.SaveDeviceAssignment("dev3", "auto_other", "", `{"recognition_units":[]}`)
|
||||
|
||||
// Verify setup: list profiles and assignments
|
||||
profiles, _ := repo.ListProfiles()
|
||||
for _, p := range profiles {
|
||||
t.Logf(" profile: name=%s template=%s", p.Name, p.TemplateName)
|
||||
}
|
||||
assignments, _ := repo.ListDeviceAssignments()
|
||||
for _, a := range assignments {
|
||||
t.Logf(" assignment: device=%s profile=%s", a.DeviceID, a.ProfileName)
|
||||
}
|
||||
|
||||
devices, err := svc.GetDevicesUsingTemplate("workshop_face_recognition_shoe_alarm")
|
||||
if err != nil {
|
||||
t.Fatalf("GetDevicesUsingTemplate: %v", err)
|
||||
}
|
||||
if len(devices) != 2 {
|
||||
t.Errorf("expected 2 devices, got %d: %v", len(devices), devices)
|
||||
}
|
||||
found := map[string]bool{}
|
||||
for _, d := range devices {
|
||||
found[d] = true
|
||||
}
|
||||
if !found["dev1"] || !found["dev2"] {
|
||||
t.Errorf("expected dev1 and dev2, got %v", devices)
|
||||
}
|
||||
if found["dev3"] {
|
||||
t.Error("dev3 should not be affected (uses other_template)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDevicesUsingTemplate_NoMatches(t *testing.T) {
|
||||
store, _ := storage.OpenSQLite(filepath.Join(t.TempDir(), "tuning2.db"))
|
||||
defer store.Close()
|
||||
repo := storage.NewAssetsRepo(store.DB())
|
||||
svc := NewConfigPreviewService(&config.Config{}, repo)
|
||||
|
||||
devices, err := svc.GetDevicesUsingTemplate("nonexistent")
|
||||
if err != nil {
|
||||
t.Fatalf("GetDevicesUsingTemplate: %v", err)
|
||||
}
|
||||
if len(devices) != 0 {
|
||||
t.Errorf("expected 0 devices, got %d", len(devices))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tuning Definitions ──
|
||||
|
||||
func TestTuningDefinitions_HaveConsistentPaths(t *testing.T) {
|
||||
defs := tuningDefinitions["std_workshop_face_recognition_shoe_alarm"]
|
||||
if len(defs) == 0 {
|
||||
t.Fatal("expected tuning definitions for std_workshop_face_recognition_shoe_alarm")
|
||||
}
|
||||
|
||||
for _, item := range defs {
|
||||
if item.Path == "" {
|
||||
t.Error("tuning item has empty path")
|
||||
}
|
||||
if item.Label == "" {
|
||||
t.Errorf("tuning item %q has empty label", item.Path)
|
||||
}
|
||||
if item.Group == "" {
|
||||
t.Errorf("tuning item %q has empty group", item.Path)
|
||||
}
|
||||
if item.Min >= item.Max {
|
||||
t.Errorf("tuning item %q: min(%v) >= max(%v)", item.Path, item.Min, item.Max)
|
||||
}
|
||||
if item.Step <= 0 {
|
||||
t.Errorf("tuning item %q: step must be > 0", item.Path)
|
||||
}
|
||||
// Verify path format: node_id.field...
|
||||
nodeID, path := splitTuningPath(item.Path)
|
||||
if nodeID == "" || path == "" {
|
||||
t.Errorf("tuning item %q: invalid path format, expected node_id.field...", item.Path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTuningDefinitions_GroupsNotEmpty(t *testing.T) {
|
||||
defs := tuningDefinitions["std_workshop_face_recognition_shoe_alarm"]
|
||||
groups := map[string]int{}
|
||||
for _, item := range defs {
|
||||
groups[item.Group]++
|
||||
}
|
||||
|
||||
// Each group should have at least 1 parameter
|
||||
for group, count := range groups {
|
||||
if count == 0 {
|
||||
t.Errorf("group %q is empty", group)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tuning Path Resolution ──
|
||||
|
||||
func TestSplitTuningPath(t *testing.T) {
|
||||
tests := []struct {
|
||||
full, node, rest string
|
||||
}{
|
||||
{"alarm_violation.rules[0].cooldown_ms", "alarm_violation", "rules[0].cooldown_ms"},
|
||||
{"detect_face.conf_thresh", "detect_face", "conf_thresh"},
|
||||
{"node_id", "node_id", ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
node, rest := splitTuningPath(tt.full)
|
||||
if node != tt.node || rest != tt.rest {
|
||||
t.Errorf("splitTuningPath(%q) = (%q, %q), want (%q, %q)",
|
||||
tt.full, node, rest, tt.node, tt.rest)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTuningPath(t *testing.T) {
|
||||
node := map[string]any{
|
||||
"threshold": map[string]any{"accept": 0.45},
|
||||
"rules": []any{
|
||||
map[string]any{"cooldown_ms": float64(60000), "min_score": 0.5},
|
||||
},
|
||||
}
|
||||
|
||||
if v, ok := resolveTuningPath(node, "threshold.accept"); !ok || v != 0.45 {
|
||||
t.Errorf("threshold.accept: got %v, want 0.45", v)
|
||||
}
|
||||
if v, ok := resolveTuningPath(node, "rules[0].cooldown_ms"); !ok || v != 60000 {
|
||||
t.Errorf("rules[0].cooldown_ms: got %v, want 60000", v)
|
||||
}
|
||||
if v, ok := resolveTuningPath(node, "rules[0].min_score"); !ok || v != 0.5 {
|
||||
t.Errorf("rules[0].min_score: got %v, want 0.5", v)
|
||||
}
|
||||
if _, ok := resolveTuningPath(node, "nonexistent.field"); ok {
|
||||
t.Error("nonexistent.field should not be found")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetTuningPath(t *testing.T) {
|
||||
node := map[string]any{
|
||||
"threshold": map[string]any{"accept": 0.45},
|
||||
"rules": []any{
|
||||
map[string]any{"cooldown_ms": float64(60000)},
|
||||
},
|
||||
}
|
||||
|
||||
if err := setTuningPath(node, "threshold.accept", 0.6); err != nil {
|
||||
t.Fatalf("setTuningPath: %v", err)
|
||||
}
|
||||
if v, _ := resolveTuningPath(node, "threshold.accept"); v != 0.6 {
|
||||
t.Errorf("after set: got %v, want 0.6", v)
|
||||
}
|
||||
|
||||
if err := setTuningPath(node, "rules[0].cooldown_ms", 30000); err != nil {
|
||||
t.Fatalf("setTuningPath: %v", err)
|
||||
}
|
||||
if v, _ := resolveTuningPath(node, "rules[0].cooldown_ms"); v != 30000 {
|
||||
t.Errorf("after set: got %v, want 30000", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetTuningPath_InvalidIndex(t *testing.T) {
|
||||
node := map[string]any{
|
||||
"rules": []any{map[string]any{"x": float64(1)}},
|
||||
}
|
||||
if err := setTuningPath(node, "rules[5].x", 42); err == nil {
|
||||
t.Error("expected error for out-of-range index")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Device Overlay Persistence ──
|
||||
|
||||
func TestSaveAndGetDeviceOverlays(t *testing.T) {
|
||||
store, _ := storage.OpenSQLite(filepath.Join(t.TempDir(), "overlay.db"))
|
||||
defer store.Close()
|
||||
repo := storage.NewAssetsRepo(store.DB())
|
||||
svc := NewConfigPreviewService(&config.Config{}, repo)
|
||||
|
||||
if err := svc.SaveDeviceOverlays("dev1", []string{"face_debug", "production_quiet"}); err != nil {
|
||||
t.Fatalf("SaveDeviceOverlays: %v", err)
|
||||
}
|
||||
|
||||
overlays, err := svc.GetDeviceOverlays("dev1")
|
||||
if err != nil {
|
||||
t.Fatalf("GetDeviceOverlays: %v", err)
|
||||
}
|
||||
if len(overlays) != 2 {
|
||||
t.Errorf("expected 2 overlays, got %d: %v", len(overlays), overlays)
|
||||
}
|
||||
|
||||
// Overwrite
|
||||
if err := svc.SaveDeviceOverlays("dev1", []string{}); err != nil {
|
||||
t.Fatalf("SaveDeviceOverlays (empty): %v", err)
|
||||
}
|
||||
overlays, _ = svc.GetDeviceOverlays("dev1")
|
||||
if len(overlays) != 0 {
|
||||
t.Errorf("expected 0 overlays after clear, got %d", len(overlays))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDeviceOverlays_UnknownDevice(t *testing.T) {
|
||||
store, _ := storage.OpenSQLite(filepath.Join(t.TempDir(), "overlay2.db"))
|
||||
defer store.Close()
|
||||
svc := NewConfigPreviewService(&config.Config{}, storage.NewAssetsRepo(store.DB()))
|
||||
|
||||
overlays, err := svc.GetDeviceOverlays("nonexistent")
|
||||
if err != nil {
|
||||
t.Fatalf("GetDeviceOverlays: %v", err)
|
||||
}
|
||||
if len(overlays) != 0 {
|
||||
t.Errorf("expected empty for unknown device, got %v", overlays)
|
||||
}
|
||||
}
|
||||
@ -4219,3 +4219,132 @@ func TestUI_ConsoleSave_PipelineCreatesTask(t *testing.T) {
|
||||
t.Errorf("expected task in redirect, got %s", loc)
|
||||
}
|
||||
}
|
||||
|
||||
// ── P1 #5: Wizard Apply ──
|
||||
|
||||
func TestUI_ActionWizardApply(t *testing.T) {
|
||||
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "wizard.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("OpenSQLite: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
repo := storage.NewAssetsRepo(store.DB())
|
||||
svc := service.NewConfigPreviewService(&config.Config{}, repo)
|
||||
|
||||
// Seed template and video source
|
||||
repo.SaveTemplate("std_face_recognition_stream", "standard",
|
||||
`{"name":"std_face_recognition_stream","template":{"nodes":[{"id":"in","type":"input_rtsp"}],"edges":[]}}`)
|
||||
svc.SaveVideoSourceAsset(service.ConfigVideoSourceAsset{
|
||||
Name: "cam1", SourceType: "rtsp",
|
||||
Config: service.VideoSourceConfig{URL: "rtsp://10.0.0.1/live", Resolution: "1920x1080", FPS: "30"},
|
||||
}, "")
|
||||
|
||||
reg := service.NewFeatureRegistry()
|
||||
tasks := service.NewTaskService(&config.Config{}, nil, service.NewRegistryService(&config.Config{Concurrency: 1}, nil))
|
||||
autoCfg := service.NewAutoConfigService(svc, tasks, reg)
|
||||
|
||||
ui, err := NewUI(nil, nil, nil, tasks, nil, svc)
|
||||
if err != nil {
|
||||
t.Fatalf("NewUI: %v", err)
|
||||
}
|
||||
ui.SetAutoConfig(autoCfg)
|
||||
|
||||
body := `{"device_id":"wizard-dev-001","features":["face"],"source_names":["cam1"]}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/wizard/apply", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
ui.actionWizardApply(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var result service.AutoConfigResult
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &result); err != nil {
|
||||
t.Fatalf("unmarshal result: %v", err)
|
||||
}
|
||||
if result.Error != "" {
|
||||
t.Fatalf("wizard apply error: %s", result.Error)
|
||||
}
|
||||
// Verify features were persisted
|
||||
features, err := svc.GetDeviceFeatures("wizard-dev-001")
|
||||
if err != nil || len(features) == 0 {
|
||||
t.Error("expected device features to be persisted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUI_ActionWizardApply_InvalidJSON(t *testing.T) {
|
||||
// Without autoConfig, wizard returns 503 (service unavailable)
|
||||
ui, _ := NewUI(nil, nil, nil, nil, nil)
|
||||
req := httptest.NewRequest(http.MethodPost, "/wizard/apply", strings.NewReader(`not json`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rr := httptest.NewRecorder()
|
||||
ui.actionWizardApply(rr, req)
|
||||
if rr.Code != http.StatusServiceUnavailable {
|
||||
t.Errorf("expected 503, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Device Overlay Save ──
|
||||
|
||||
func TestUI_ActionDeviceOverlaysSave(t *testing.T) {
|
||||
store, _ := storage.OpenSQLite(filepath.Join(t.TempDir(), "devoverlay.db"))
|
||||
defer store.Close()
|
||||
repo := storage.NewAssetsRepo(store.DB())
|
||||
svc := service.NewConfigPreviewService(&config.Config{}, repo)
|
||||
|
||||
ui, _ := NewUI(nil, nil, nil, nil, nil, svc)
|
||||
|
||||
form := url.Values{}
|
||||
form.Add("overlay", "face_debug")
|
||||
form.Add("overlay", "production_quiet")
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/devices/dev1/overlays", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
// Set chi param
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("id", "dev1")
|
||||
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
||||
|
||||
ui.actionDeviceOverlaysSave(rr, req)
|
||||
|
||||
if rr.Code != http.StatusFound {
|
||||
t.Fatalf("expected 302, got %d", rr.Code)
|
||||
}
|
||||
|
||||
overlays, err := svc.GetDeviceOverlays("dev1")
|
||||
if err != nil {
|
||||
t.Fatalf("GetDeviceOverlays: %v", err)
|
||||
}
|
||||
if len(overlays) != 2 {
|
||||
t.Errorf("expected 2 overlays, got %d", len(overlays))
|
||||
}
|
||||
}
|
||||
|
||||
func TestUI_ActionDeviceOverlaysSave_Clears(t *testing.T) {
|
||||
store, _ := storage.OpenSQLite(filepath.Join(t.TempDir(), "devoverlay2.db"))
|
||||
defer store.Close()
|
||||
repo := storage.NewAssetsRepo(store.DB())
|
||||
svc := service.NewConfigPreviewService(&config.Config{}, repo)
|
||||
svc.SaveDeviceOverlays("dev1", []string{"old_overlay"})
|
||||
|
||||
ui, _ := NewUI(nil, nil, nil, nil, nil, svc)
|
||||
|
||||
// Submit with no overlays checked
|
||||
req := httptest.NewRequest(http.MethodPost, "/devices/dev1/overlays", strings.NewReader(""))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rr := httptest.NewRecorder()
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("id", "dev1")
|
||||
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
||||
|
||||
ui.actionDeviceOverlaysSave(rr, req)
|
||||
|
||||
overlays, _ := svc.GetDeviceOverlays("dev1")
|
||||
if len(overlays) != 0 {
|
||||
t.Errorf("expected 0 overlays after clear, got %d", len(overlays))
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user