344 lines
12 KiB
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")
|
|
}
|
|
}
|