safesight-control/internal/service/tuning_test.go

246 lines
7.5 KiB
Go

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