test: add 18 tests - injectIntegrations, device overlays, tuning, template clone, mergeRuntimeInstancePatch, wizard, ResolveChannelToSource, SaveVideoSourceAsset

This commit is contained in:
tian 2026-07-27 19:43:59 +08:00
parent 1ae06ec1fb
commit d1f85ee794
4 changed files with 687 additions and 0 deletions

View File

@ -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)
}
}

View File

@ -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")
}
}

View 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)
}
}

View File

@ -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))
}
}