safesight-control/internal/service/auto_config_test.go

344 lines
12 KiB
Go

package service
import (
"path/filepath"
"strings"
"testing"
"safesight-control/internal/config"
"safesight-control/internal/storage"
)
func TestFeatureRegistry_ResolveTemplate_SingleFeature(t *testing.T) {
reg := NewFeatureRegistry()
result, err := reg.ResolveTemplate([]string{"face"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Composed {
t.Errorf("expected single template, got composed=true")
}
// Most specific template for face-only: std_face_recognition_stream (covers 1 feature)
// std_workshop_face_recognition_shoe_alarm covers 2 features, less specific.
if result.TemplateName != "std_face_recognition_stream" {
t.Errorf("expected std_face_recognition_stream (most specific), got %s", result.TemplateName)
}
}
func TestFeatureRegistry_ResolveTemplate_TwoFeaturesWithCommonTemplate(t *testing.T) {
reg := NewFeatureRegistry()
// face + shoe both have std_workshop_face_recognition_shoe_alarm in common
result, err := reg.ResolveTemplate([]string{"face", "shoe"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Composed {
t.Errorf("expected single template, got composed=true")
}
if result.TemplateName != "std_workshop_face_recognition_shoe_alarm" {
t.Errorf("expected std_workshop_face_recognition_shoe_alarm, got %s", result.TemplateName)
}
}
func TestFeatureRegistry_ResolveTemplate_ComposedNeeded(t *testing.T) {
reg := NewFeatureRegistry()
// Add a feature with a separate template so intersection is empty.
// We temporarily add intrusion with its own template for testing.
reg.features["intrusion"] = &FeatureDef{
Key: "intrusion",
Label: "区域入侵",
Templates: []string{"std_intrusion_detection"},
}
// face → [std_workshop..., std_face_recog]
// intrusion → [std_intrusion_detection]
// Intersection = empty → Composed = true
result, err := reg.ResolveTemplate([]string{"face", "intrusion"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !result.Composed {
t.Errorf("expected composed=true, got false")
}
if !strings.HasPrefix(result.TemplateName, "auto_") {
t.Errorf("composed template should start with auto_, got %s", result.TemplateName)
}
// composePrimary is the first template from the first feature (face)
if result.composePrimary != "std_workshop_face_recognition_shoe_alarm" {
t.Errorf("expected compose primary std_workshop_face_recognition_shoe_alarm, got %s", result.composePrimary)
}
}
func TestFeatureRegistry_ResolveTemplate_UnavailableFeature(t *testing.T) {
reg := NewFeatureRegistry()
// helmet has no templates yet
_, err := reg.ResolveTemplate([]string{"helmet"})
if err == nil {
t.Fatal("expected error for unavailable feature, got nil")
}
if !strings.Contains(err.Error(), "尚未就绪") {
t.Errorf("error should mention 尚未就绪, got: %v", err)
}
}
func TestFeatureRegistry_ResolveTemplate_UnknownFeature(t *testing.T) {
reg := NewFeatureRegistry()
_, err := reg.ResolveTemplate([]string{"face", "nonexistent"})
if err == nil {
t.Fatal("expected error for unknown feature, got nil")
}
if !strings.Contains(err.Error(), "未知") {
t.Errorf("error should mention 未知, got: %v", err)
}
}
func TestFeatureRegistry_ResolveTemplate_Empty(t *testing.T) {
reg := NewFeatureRegistry()
_, err := reg.ResolveTemplate(nil)
if err == nil {
t.Fatal("expected error for nil, got nil")
}
}
func TestFeatureRegistry_ResolveTemplate_Dedup(t *testing.T) {
reg := NewFeatureRegistry()
// Duplicate keys should be deduplicated
result, err := reg.ResolveTemplate([]string{"face", "face", "shoe"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Composed {
t.Errorf("expected single template, got composed=true")
}
}
func TestIntersectTemplateLists(t *testing.T) {
tests := []struct {
name string
lists [][]string
expect []string
}{
{
name: "empty",
lists: nil,
expect: nil,
},
{
name: "single list returns same list",
lists: [][]string{{"a", "b", "c"}},
expect: []string{"a", "b", "c"},
},
{
name: "partial intersection",
lists: [][]string{{"a", "b", "c"}, {"b", "c", "d"}},
expect: []string{"b", "c"},
},
{
name: "no intersection",
lists: [][]string{{"a", "b"}, {"c", "d"}},
expect: nil,
},
{
name: "three lists",
lists: [][]string{{"a", "b", "c"}, {"b", "c"}, {"c", "b", "a"}},
expect: []string{"b", "c"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := intersectTemplateLists(tt.lists)
if len(got) != len(tt.expect) {
t.Errorf("len = %d, want %d (got %v)", len(got), len(tt.expect), got)
return
}
for i := range got {
if got[i] != tt.expect[i] {
t.Errorf("at %d: got %q, want %q", i, got[i], tt.expect[i])
}
}
})
}
}
func TestFeatureRegistry_AllFeatures(t *testing.T) {
reg := NewFeatureRegistry()
all := reg.AllFeatures()
if len(all) < 3 {
t.Errorf("expected at least 3 features, got %d", len(all))
}
// Verify sorted by key
for i := 1; i < len(all); i++ {
if all[i].Key < all[i-1].Key {
t.Errorf("features not sorted: %q before %q", all[i-1].Key, all[i].Key)
}
}
}
func TestDeriveSafeInstanceName_ASCII(t *testing.T) {
tests := []struct {
input, expect string
}{
{"cam1", "cam1"},
{"CAM_1", "CAM_1"},
{"a-b.c_d", "a-b.c_d"},
{"123", "123"},
}
for _, tt := range tests {
got := deriveSafeInstanceName(tt.input)
if got != tt.expect {
t.Errorf("deriveSafeInstanceName(%q) = %q, want %q", tt.input, got, tt.expect)
}
}
}
func TestDeriveSafeInstanceName_Chinese(t *testing.T) {
// Chinese names must produce unique hash-based names, not collide on extracted digits
a := deriveSafeInstanceName("5跨主通道")
b := deriveSafeInstanceName("5跨焙烧区")
if a == b {
t.Errorf("Chinese names '5跨主通道' and '5跨焙烧区' derived to same name %q", a)
}
// Must start with src_ prefix for hash-based names
if a[:4] != "src_" || b[:4] != "src_" {
t.Errorf("Chinese names should derive to src_ prefix, got %q, %q", a, b)
}
// Must be consistent
if a != deriveSafeInstanceName("5跨主通道") {
t.Errorf("deriveSafeInstanceName not deterministic")
}
}
func TestCleanSourceNames(t *testing.T) {
tests := []struct {
input, expect []string
}{
{[]string{"a", "b", "c"}, []string{"a", "b", "c"}},
{[]string{"a", "a", "b"}, []string{"a", "b"}},
{[]string{" a ", "a", "b"}, []string{"a", "b"}},
{[]string{"", "a", ""}, []string{"a"}},
{[]string{"c", "a", "b"}, []string{"a", "b", "c"}},
}
for _, tt := range tests {
got := cleanSourceNames(tt.input)
if len(got) != len(tt.expect) {
t.Errorf("cleanSourceNames(%v) = %v (len=%d), want %v (len=%d)", tt.input, got, len(got), tt.expect, len(tt.expect))
continue
}
for i := range got {
if got[i] != tt.expect[i] {
t.Errorf("cleanSourceNames(%v)[%d] = %q, want %q", tt.input, i, got[i], tt.expect[i])
}
}
}
}
func TestCleanSourceNames_DuplicatesWithSpaces(t *testing.T) {
// Real-world scenario: wizard JS sends ["1", "1"] or ["1 ", "1"]
got := cleanSourceNames([]string{"1", "1"})
if len(got) != 1 || got[0] != "1" {
t.Errorf("duplicate '1' not deduped: %v", got)
}
got = cleanSourceNames([]string{"1 ", "1"})
if len(got) != 1 || got[0] != "1" {
t.Errorf("space-padded duplicate not deduped: %v", got)
}
}
func TestBuildPipeline_CreatesRecognitionUnits(t *testing.T) {
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "pipeline.db"))
if err != nil {
t.Fatalf("OpenSQLite: %v", err)
}
defer store.Close()
repo := storage.NewAssetsRepo(store.DB())
svc := NewConfigPreviewService(&config.Config{}, repo)
// Seed a template with minimal valid format
tplBody := `{"name":"std_face_recognition_stream","template":{"nodes":[{"id":"in","type":"input_rtsp"}],"edges":[]}}`
if err := repo.SaveTemplate("std_face_recognition_stream", "test", tplBody); err != nil {
t.Fatalf("SaveTemplate: %v", err)
}
// Seed video sources
for _, name := range []string{"cam1", "cam2"} {
svc.SaveVideoSourceAsset(ConfigVideoSourceAsset{
Name: name, 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-device-001",
Features: []string{"face"},
SourceNames: []string{"cam1", "cam2"},
}, false)
if err != nil {
t.Fatalf("BuildPipeline: %v", err)
}
if result == nil {
t.Fatal("expected result, got nil")
}
if result.Error != "" {
t.Fatalf("BuildPipeline error: %s", result.Error)
}
units, _ := svc.ListRecognitionUnits()
if len(units) != 2 {
t.Errorf("expected 2 recognition units, got %d", len(units))
}
assignment, err := svc.GetDeviceAssignment("test-device-001")
if err != nil || assignment == nil {
t.Errorf("expected device assignment, got err=%v", err)
} else if len(assignment.RecognitionUnits) != 2 {
t.Errorf("expected 2 units in assignment, got %d", len(assignment.RecognitionUnits))
}
}
func TestBuildPipeline_DeduplicatesSources(t *testing.T) {
store, _ := storage.OpenSQLite(filepath.Join(t.TempDir(), "pipeline2.db"))
defer store.Close()
repo := storage.NewAssetsRepo(store.DB())
svc := NewConfigPreviewService(&config.Config{}, repo)
repo.SaveTemplate("std_face_recognition_stream", "test", `{"name":"std_face_recognition_stream","template":{"nodes":[{"id":"in","type":"input_rtsp"}],"edges":[]}}`)
svc.SaveVideoSourceAsset(ConfigVideoSourceAsset{
Name: "cam1", SourceType: "rtsp",
Config: VideoSourceConfig{URL: "rtsp://10.0.0.1/live", Resolution: "1920x1080", FPS: "30"},
}, "")
auto := NewAutoConfigService(svc, &TaskService{}, NewFeatureRegistry())
result, err := auto.BuildPipeline(AutoConfigRequest{
DeviceID: "test-device-002", Features: []string{"face"},
SourceNames: []string{"cam1", "cam1"},
}, false)
if err != nil || result == nil || result.Error != "" {
t.Fatalf("BuildPipeline: err=%v result=%v", err, result)
}
units, _ := svc.ListRecognitionUnits()
if len(units) != 1 {
t.Errorf("expected 1 unit after dedup, got %d", len(units))
}
}
func TestBuildPipeline_EmptySources(t *testing.T) {
store, _ := storage.OpenSQLite(filepath.Join(t.TempDir(), "pipeline3.db"))
defer store.Close()
svc := NewConfigPreviewService(&config.Config{}, storage.NewAssetsRepo(store.DB()))
auto := NewAutoConfigService(svc, &TaskService{}, NewFeatureRegistry())
_, err := auto.BuildPipeline(AutoConfigRequest{
DeviceID: "test-device-003", Features: []string{"face"},
SourceNames: []string{},
}, false)
if err == nil {
t.Error("expected error for empty sources")
}
}