397f9dd78b
Covers every writer in pkg/generate/writer.go through the public Generate entry-point: inputs/roles, needMP4 (bubble), ffmpeg, live, detect (including the Objects auto-enable branch), objects, motion, record (retain, alerts, detections, fractional days), snapshots, audio, birdseye, onvif (host-gated emission, default port, credentials, autotracking, required zones), ptz (only inside onvif), notifications, ui, Frigate/Go2RTC overrides, Name override, extractIP fallbacks, Added line numbers, and top-level/camera section ordering.
878 lines
29 KiB
Go
878 lines
29 KiB
Go
package generate
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// End-to-end tests for writer.go: every writeX function is exercised through
|
|
// the public Generate entry-point so the tests survive internal refactoring.
|
|
// Shared helpers (mustGen / assertContains / assertNotContains / countOccurrences)
|
|
// are defined in xiaomi_test.go.
|
|
|
|
// baseRTSP is a neutral main-stream URL that does not trigger any extractor
|
|
// (registry), does not trigger needMP4, and has a stable IP for name derivation.
|
|
const baseRTSP = "rtsp://admin:pw@10.0.20.10:554/Streaming/Channels/101"
|
|
const baseSubRTSP = "rtsp://admin:pw@10.0.20.10:554/Streaming/Channels/102"
|
|
|
|
// --- writeInput (roles) -------------------------------------------------------
|
|
|
|
// Without sub: a single input carries both detect and record roles.
|
|
func TestWriter_Input_SingleRoleCombined(t *testing.T) {
|
|
cfg := mustGen(t, &Request{MainStream: baseRTSP})
|
|
|
|
assertContains(t, cfg, " inputs:\n")
|
|
assertContains(t, cfg, " - path: rtsp://127.0.0.1:8554/10_0_20_10_main\n")
|
|
assertContains(t, cfg, " input_args: preset-rtsp-restream\n")
|
|
assertContains(t, cfg, " roles:\n - detect\n - record\n")
|
|
|
|
if n := countOccurrences(cfg, "- path:"); n != 1 {
|
|
t.Errorf("expected 1 input, got %d\n%s", n, cfg)
|
|
}
|
|
}
|
|
|
|
// With sub: sub gets detect, main gets record -- sub must come FIRST in inputs list.
|
|
func TestWriter_Input_SubGetsDetectMainGetsRecord(t *testing.T) {
|
|
cfg := mustGen(t, &Request{
|
|
MainStream: baseRTSP,
|
|
SubStream: baseSubRTSP,
|
|
})
|
|
|
|
assertContains(t, cfg, " - path: rtsp://127.0.0.1:8554/10_0_20_10_sub\n")
|
|
assertContains(t, cfg, " - path: rtsp://127.0.0.1:8554/10_0_20_10_main\n")
|
|
|
|
subIdx := strings.Index(cfg, "rtsp://127.0.0.1:8554/10_0_20_10_sub")
|
|
mainIdx := strings.Index(cfg, "rtsp://127.0.0.1:8554/10_0_20_10_main\n input_args")
|
|
if !(subIdx > 0 && mainIdx > 0 && subIdx < mainIdx) {
|
|
t.Errorf("expected sub path to appear before main path in inputs:\n%s", cfg)
|
|
}
|
|
|
|
// sub has only detect role, main has only record
|
|
detectBlock := " - path: rtsp://127.0.0.1:8554/10_0_20_10_sub\n" +
|
|
" input_args: preset-rtsp-restream\n" +
|
|
" roles:\n - detect\n"
|
|
recordBlock := " - path: rtsp://127.0.0.1:8554/10_0_20_10_main\n" +
|
|
" input_args: preset-rtsp-restream\n" +
|
|
" roles:\n - record\n"
|
|
assertContains(t, cfg, detectBlock)
|
|
assertContains(t, cfg, recordBlock)
|
|
}
|
|
|
|
// --- needMP4 ------------------------------------------------------------------
|
|
|
|
// bubble:// MUST produce a restream path ending in ?mp4 -- Frigate bubble bug.
|
|
func TestWriter_NeedMP4_Bubble(t *testing.T) {
|
|
cfg := mustGen(t, &Request{
|
|
MainStream: "bubble://admin:pw@10.0.20.50:80/bubble/live?ch=0&stream=0",
|
|
})
|
|
assertContains(t, cfg, "- path: rtsp://127.0.0.1:8554/10_0_20_50_main?mp4\n")
|
|
}
|
|
|
|
// Same for sub stream.
|
|
func TestWriter_NeedMP4_BubbleSub(t *testing.T) {
|
|
cfg := mustGen(t, &Request{
|
|
MainStream: baseRTSP,
|
|
SubStream: "bubble://admin:pw@10.0.20.10:80/bubble/live?ch=0&stream=1",
|
|
})
|
|
assertContains(t, cfg, "- path: rtsp://127.0.0.1:8554/10_0_20_10_sub?mp4\n")
|
|
// main is rtsp, MUST NOT have ?mp4
|
|
assertContains(t, cfg, "- path: rtsp://127.0.0.1:8554/10_0_20_10_main\n")
|
|
assertNotContains(t, cfg, "10_0_20_10_main?mp4")
|
|
}
|
|
|
|
// rtsp:// and other non-listed schemes MUST NOT append ?mp4.
|
|
func TestWriter_NeedMP4_RTSPNotAppended(t *testing.T) {
|
|
cfg := mustGen(t, &Request{MainStream: baseRTSP})
|
|
assertNotContains(t, cfg, "?mp4")
|
|
}
|
|
|
|
// --- writeFFmpegGlobal --------------------------------------------------------
|
|
|
|
// FFmpeg == nil -> no hwaccel_args, no gpu.
|
|
func TestWriter_FFmpeg_Nil(t *testing.T) {
|
|
cfg := mustGen(t, &Request{MainStream: baseRTSP})
|
|
assertNotContains(t, cfg, "hwaccel_args:")
|
|
assertNotContains(t, cfg, "gpu:")
|
|
}
|
|
|
|
// HWAccel="auto" is a sentinel -- it means "let Frigate decide", don't emit.
|
|
func TestWriter_FFmpeg_HWAccelAutoSkipped(t *testing.T) {
|
|
cfg := mustGen(t, &Request{
|
|
MainStream: baseRTSP,
|
|
FFmpeg: &FFmpegConfig{HWAccel: "auto"},
|
|
})
|
|
assertNotContains(t, cfg, "hwaccel_args:")
|
|
}
|
|
|
|
// Explicit preset is written verbatim.
|
|
func TestWriter_FFmpeg_HWAccelExplicit(t *testing.T) {
|
|
cfg := mustGen(t, &Request{
|
|
MainStream: baseRTSP,
|
|
FFmpeg: &FFmpegConfig{HWAccel: "preset-vaapi", GPU: 1},
|
|
})
|
|
assertContains(t, cfg, " hwaccel_args: preset-vaapi\n")
|
|
assertContains(t, cfg, " gpu: 1\n")
|
|
}
|
|
|
|
// GPU 0 (default) must not be written; >0 is.
|
|
func TestWriter_FFmpeg_GPUZeroSkipped(t *testing.T) {
|
|
cfg := mustGen(t, &Request{
|
|
MainStream: baseRTSP,
|
|
FFmpeg: &FFmpegConfig{HWAccel: "preset-vaapi"},
|
|
})
|
|
assertContains(t, cfg, "hwaccel_args: preset-vaapi")
|
|
assertNotContains(t, cfg, "gpu:")
|
|
}
|
|
|
|
// --- writeLive ----------------------------------------------------------------
|
|
|
|
// No sub + no Live config -> no live: block at all.
|
|
func TestWriter_Live_NoSubNoLive_Absent(t *testing.T) {
|
|
cfg := mustGen(t, &Request{MainStream: baseRTSP})
|
|
assertNotContains(t, cfg, " live:\n")
|
|
}
|
|
|
|
// Sub present -> live.streams with Main + Sub stream labels.
|
|
func TestWriter_Live_WithSub_StreamsMap(t *testing.T) {
|
|
cfg := mustGen(t, &Request{
|
|
MainStream: baseRTSP,
|
|
SubStream: baseSubRTSP,
|
|
})
|
|
assertContains(t, cfg, " live:\n streams:\n")
|
|
assertContains(t, cfg, " Main Stream: 10_0_20_10_main\n")
|
|
assertContains(t, cfg, " Sub Stream: 10_0_20_10_sub\n")
|
|
}
|
|
|
|
// Live.Height / Live.Quality override defaults.
|
|
func TestWriter_Live_HeightAndQuality(t *testing.T) {
|
|
cfg := mustGen(t, &Request{
|
|
MainStream: baseRTSP,
|
|
Live: &LiveConfig{Height: 720, Quality: 6},
|
|
})
|
|
assertContains(t, cfg, " live:\n")
|
|
assertContains(t, cfg, " height: 720\n")
|
|
assertContains(t, cfg, " quality: 6\n")
|
|
}
|
|
|
|
// Live with zero values omits those fields.
|
|
func TestWriter_Live_ZeroValuesOmitted(t *testing.T) {
|
|
cfg := mustGen(t, &Request{
|
|
MainStream: baseRTSP,
|
|
Live: &LiveConfig{},
|
|
})
|
|
assertNotContains(t, cfg, " height: 0\n")
|
|
assertNotContains(t, cfg, " quality: 0\n")
|
|
}
|
|
|
|
// --- writeDetect --------------------------------------------------------------
|
|
|
|
// Detect == nil -> default is enabled: true (Frigate needs explicit detect block).
|
|
func TestWriter_Detect_NilDefaultsToEnabled(t *testing.T) {
|
|
cfg := mustGen(t, &Request{MainStream: baseRTSP})
|
|
assertContains(t, cfg, " detect:\n enabled: true\n")
|
|
}
|
|
|
|
// Explicit enabled: false is written.
|
|
func TestWriter_Detect_ExplicitDisabled(t *testing.T) {
|
|
cfg := mustGen(t, &Request{
|
|
MainStream: baseRTSP,
|
|
Detect: &DetectConfig{Enabled: false},
|
|
})
|
|
assertContains(t, cfg, " detect:\n enabled: false\n")
|
|
}
|
|
|
|
// FPS/Width/Height > 0 are written.
|
|
func TestWriter_Detect_CustomFPSWidthHeight(t *testing.T) {
|
|
cfg := mustGen(t, &Request{
|
|
MainStream: baseRTSP,
|
|
Detect: &DetectConfig{Enabled: true, FPS: 10, Width: 1920, Height: 1080},
|
|
})
|
|
assertContains(t, cfg, " detect:\n enabled: true\n")
|
|
assertContains(t, cfg, " fps: 10\n")
|
|
assertContains(t, cfg, " width: 1920\n")
|
|
assertContains(t, cfg, " height: 1080\n")
|
|
}
|
|
|
|
// Zero values inside Detect are omitted (not written as "fps: 0" etc).
|
|
func TestWriter_Detect_ZeroValuesOmitted(t *testing.T) {
|
|
cfg := mustGen(t, &Request{
|
|
MainStream: baseRTSP,
|
|
Detect: &DetectConfig{Enabled: true},
|
|
})
|
|
assertNotContains(t, cfg, " fps: 0\n")
|
|
assertNotContains(t, cfg, " width: 0\n")
|
|
assertNotContains(t, cfg, " height: 0\n")
|
|
}
|
|
|
|
// Setting Objects auto-enables detect even if Detect was nil (see Generate).
|
|
func TestWriter_Detect_ObjectsAutoEnable(t *testing.T) {
|
|
cfg := mustGen(t, &Request{
|
|
MainStream: baseRTSP,
|
|
Objects: []string{"car"},
|
|
})
|
|
assertContains(t, cfg, " detect:\n enabled: true\n")
|
|
assertContains(t, cfg, " objects:\n track:\n - car\n")
|
|
}
|
|
|
|
// Objects + Detect{Enabled:false} -> Generate flips Enabled to true.
|
|
func TestWriter_Detect_ObjectsOverridesDisabledDetect(t *testing.T) {
|
|
cfg := mustGen(t, &Request{
|
|
MainStream: baseRTSP,
|
|
Objects: []string{"dog"},
|
|
Detect: &DetectConfig{Enabled: false},
|
|
})
|
|
assertContains(t, cfg, " detect:\n enabled: true\n")
|
|
}
|
|
|
|
// --- writeObjects -------------------------------------------------------------
|
|
|
|
// Empty Objects list -> default ["person"].
|
|
func TestWriter_Objects_DefaultPerson(t *testing.T) {
|
|
cfg := mustGen(t, &Request{MainStream: baseRTSP})
|
|
assertContains(t, cfg, " objects:\n track:\n - person\n")
|
|
}
|
|
|
|
// Explicit list preserves order.
|
|
func TestWriter_Objects_ExplicitListPreservesOrder(t *testing.T) {
|
|
cfg := mustGen(t, &Request{
|
|
MainStream: baseRTSP,
|
|
Objects: []string{"car", "person", "cat"},
|
|
})
|
|
assertContains(t, cfg, " objects:\n track:\n - car\n - person\n - cat\n")
|
|
}
|
|
|
|
// --- writeMotion --------------------------------------------------------------
|
|
|
|
// Motion == nil -> block absent.
|
|
func TestWriter_Motion_NilAbsent(t *testing.T) {
|
|
cfg := mustGen(t, &Request{MainStream: baseRTSP})
|
|
assertNotContains(t, cfg, " motion:\n")
|
|
}
|
|
|
|
// Motion{} -> block with enabled: false.
|
|
func TestWriter_Motion_DisabledStillWritesBlock(t *testing.T) {
|
|
cfg := mustGen(t, &Request{
|
|
MainStream: baseRTSP,
|
|
Motion: &MotionConfig{Enabled: false},
|
|
})
|
|
assertContains(t, cfg, " motion:\n enabled: false\n")
|
|
}
|
|
|
|
// Threshold / ContourArea > 0 are written.
|
|
func TestWriter_Motion_WithThresholdAndContour(t *testing.T) {
|
|
cfg := mustGen(t, &Request{
|
|
MainStream: baseRTSP,
|
|
Motion: &MotionConfig{Enabled: true, Threshold: 30, ContourArea: 10},
|
|
})
|
|
assertContains(t, cfg, " motion:\n enabled: true\n")
|
|
assertContains(t, cfg, " threshold: 30\n")
|
|
assertContains(t, cfg, " contour_area: 10\n")
|
|
}
|
|
|
|
// Zero values inside Motion are omitted.
|
|
func TestWriter_Motion_ZeroOmitted(t *testing.T) {
|
|
cfg := mustGen(t, &Request{
|
|
MainStream: baseRTSP,
|
|
Motion: &MotionConfig{Enabled: true},
|
|
})
|
|
assertNotContains(t, cfg, "threshold: 0")
|
|
assertNotContains(t, cfg, "contour_area: 0")
|
|
}
|
|
|
|
// --- writeRecord --------------------------------------------------------------
|
|
|
|
// Record == nil -> default enabled: true.
|
|
func TestWriter_Record_NilDefaultEnabled(t *testing.T) {
|
|
cfg := mustGen(t, &Request{MainStream: baseRTSP})
|
|
// per-camera record block (top-level "record:\n enabled: true" also exists -- that's global)
|
|
assertContains(t, cfg, " record:\n enabled: true\n")
|
|
}
|
|
|
|
// Record.Enabled: false is written.
|
|
func TestWriter_Record_Disabled(t *testing.T) {
|
|
cfg := mustGen(t, &Request{
|
|
MainStream: baseRTSP,
|
|
Record: &RecordConfig{Enabled: false},
|
|
})
|
|
assertContains(t, cfg, " record:\n enabled: false\n")
|
|
}
|
|
|
|
// Only retain days -> retain block with days only.
|
|
func TestWriter_Record_RetainDaysOnly(t *testing.T) {
|
|
cfg := mustGen(t, &Request{
|
|
MainStream: baseRTSP,
|
|
Record: &RecordConfig{Enabled: true, RetainDays: 7},
|
|
})
|
|
assertContains(t, cfg, " record:\n enabled: true\n retain:\n days: 7\n")
|
|
assertNotContains(t, cfg, " mode:")
|
|
}
|
|
|
|
// Retain mode only (no days).
|
|
func TestWriter_Record_RetainModeOnly(t *testing.T) {
|
|
cfg := mustGen(t, &Request{
|
|
MainStream: baseRTSP,
|
|
Record: &RecordConfig{Enabled: true, Mode: "motion"},
|
|
})
|
|
assertContains(t, cfg, " retain:\n mode: motion\n")
|
|
assertNotContains(t, cfg, " days:")
|
|
}
|
|
|
|
// Fractional retain days -> %g formatting (0.5, not 5e-01).
|
|
func TestWriter_Record_FractionalRetainDays(t *testing.T) {
|
|
cfg := mustGen(t, &Request{
|
|
MainStream: baseRTSP,
|
|
Record: &RecordConfig{Enabled: true, RetainDays: 0.5},
|
|
})
|
|
assertContains(t, cfg, " days: 0.5\n")
|
|
}
|
|
|
|
// Alerts block: AlertsDays + PreCapture + PostCapture.
|
|
func TestWriter_Record_AlertsBlock(t *testing.T) {
|
|
cfg := mustGen(t, &Request{
|
|
MainStream: baseRTSP,
|
|
Record: &RecordConfig{Enabled: true, AlertsDays: 14, PreCapture: 5, PostCapture: 10},
|
|
})
|
|
assertContains(t, cfg, " alerts:\n")
|
|
assertContains(t, cfg, " retain:\n days: 14\n")
|
|
assertContains(t, cfg, " pre_capture: 5\n")
|
|
assertContains(t, cfg, " post_capture: 10\n")
|
|
}
|
|
|
|
// Only PreCapture -> alerts block appears with only pre_capture.
|
|
func TestWriter_Record_OnlyPreCaptureStillEmitsAlerts(t *testing.T) {
|
|
cfg := mustGen(t, &Request{
|
|
MainStream: baseRTSP,
|
|
Record: &RecordConfig{Enabled: true, PreCapture: 5},
|
|
})
|
|
assertContains(t, cfg, " alerts:\n")
|
|
assertContains(t, cfg, " pre_capture: 5\n")
|
|
assertNotContains(t, cfg, " days:")
|
|
}
|
|
|
|
// DetectionDays writes a separate detections block.
|
|
func TestWriter_Record_DetectionsDays(t *testing.T) {
|
|
cfg := mustGen(t, &Request{
|
|
MainStream: baseRTSP,
|
|
Record: &RecordConfig{Enabled: true, DetectionDays: 30},
|
|
})
|
|
assertContains(t, cfg, " detections:\n retain:\n days: 30\n")
|
|
}
|
|
|
|
// All fields combined -- retain, alerts, detections all present.
|
|
func TestWriter_Record_AllFieldsCombined(t *testing.T) {
|
|
cfg := mustGen(t, &Request{
|
|
MainStream: baseRTSP,
|
|
Record: &RecordConfig{
|
|
Enabled: true, RetainDays: 7, Mode: "all",
|
|
AlertsDays: 14, PreCapture: 5, PostCapture: 10,
|
|
DetectionDays: 30,
|
|
},
|
|
})
|
|
assertContains(t, cfg, " retain:\n days: 7\n mode: all\n")
|
|
assertContains(t, cfg, " alerts:\n retain:\n days: 14\n pre_capture: 5\n post_capture: 10\n")
|
|
assertContains(t, cfg, " detections:\n retain:\n days: 30\n")
|
|
}
|
|
|
|
// --- writeSnapshots -----------------------------------------------------------
|
|
|
|
func TestWriter_Snapshots_NilAbsent(t *testing.T) {
|
|
cfg := mustGen(t, &Request{MainStream: baseRTSP})
|
|
assertNotContains(t, cfg, " snapshots:\n")
|
|
}
|
|
|
|
func TestWriter_Snapshots_DisabledAbsent(t *testing.T) {
|
|
cfg := mustGen(t, &Request{
|
|
MainStream: baseRTSP,
|
|
Snapshots: &BoolConfig{Enabled: false},
|
|
})
|
|
assertNotContains(t, cfg, " snapshots:\n")
|
|
}
|
|
|
|
func TestWriter_Snapshots_Enabled(t *testing.T) {
|
|
cfg := mustGen(t, &Request{
|
|
MainStream: baseRTSP,
|
|
Snapshots: &BoolConfig{Enabled: true},
|
|
})
|
|
assertContains(t, cfg, " snapshots:\n enabled: true\n")
|
|
}
|
|
|
|
// --- writeAudio ---------------------------------------------------------------
|
|
|
|
func TestWriter_Audio_NilAbsent(t *testing.T) {
|
|
cfg := mustGen(t, &Request{MainStream: baseRTSP})
|
|
assertNotContains(t, cfg, " audio:\n")
|
|
}
|
|
|
|
func TestWriter_Audio_DisabledAbsent(t *testing.T) {
|
|
cfg := mustGen(t, &Request{
|
|
MainStream: baseRTSP,
|
|
Audio: &AudioConfig{Enabled: false},
|
|
})
|
|
assertNotContains(t, cfg, " audio:\n")
|
|
}
|
|
|
|
func TestWriter_Audio_EnabledNoFilters(t *testing.T) {
|
|
cfg := mustGen(t, &Request{
|
|
MainStream: baseRTSP,
|
|
Audio: &AudioConfig{Enabled: true},
|
|
})
|
|
assertContains(t, cfg, " audio:\n enabled: true\n")
|
|
assertNotContains(t, cfg, " filters:\n")
|
|
}
|
|
|
|
func TestWriter_Audio_WithFilters(t *testing.T) {
|
|
cfg := mustGen(t, &Request{
|
|
MainStream: baseRTSP,
|
|
Audio: &AudioConfig{Enabled: true, Filters: []string{"speech", "bark"}},
|
|
})
|
|
assertContains(t, cfg, " filters:\n - speech\n - bark\n")
|
|
}
|
|
|
|
// --- writeBirdseye ------------------------------------------------------------
|
|
|
|
func TestWriter_Birdseye_NilAbsent(t *testing.T) {
|
|
cfg := mustGen(t, &Request{MainStream: baseRTSP})
|
|
assertNotContains(t, cfg, " birdseye:\n")
|
|
}
|
|
|
|
// Enabled:false is still written (birdseye may be globally on, per-camera off).
|
|
func TestWriter_Birdseye_DisabledExplicit(t *testing.T) {
|
|
cfg := mustGen(t, &Request{
|
|
MainStream: baseRTSP,
|
|
Birdseye: &BirdseyeConfig{Enabled: false},
|
|
})
|
|
assertContains(t, cfg, " birdseye:\n enabled: false\n")
|
|
}
|
|
|
|
func TestWriter_Birdseye_EnabledWithMode(t *testing.T) {
|
|
cfg := mustGen(t, &Request{
|
|
MainStream: baseRTSP,
|
|
Birdseye: &BirdseyeConfig{Enabled: true, Mode: "motion"},
|
|
})
|
|
assertContains(t, cfg, " birdseye:\n enabled: true\n mode: motion\n")
|
|
}
|
|
|
|
func TestWriter_Birdseye_EmptyModeOmitted(t *testing.T) {
|
|
cfg := mustGen(t, &Request{
|
|
MainStream: baseRTSP,
|
|
Birdseye: &BirdseyeConfig{Enabled: true},
|
|
})
|
|
assertContains(t, cfg, " birdseye:\n enabled: true\n")
|
|
assertNotContains(t, cfg, " mode:")
|
|
}
|
|
|
|
// --- writeONVIF ---------------------------------------------------------------
|
|
|
|
// THIS IS THE BUG THE USER JUST FIXED: no ONVIF in request -> no block in config.
|
|
// (host comes from the frontend; if empty, block must not appear.)
|
|
func TestWriter_ONVIF_NilAbsent(t *testing.T) {
|
|
cfg := mustGen(t, &Request{MainStream: baseRTSP})
|
|
assertNotContains(t, cfg, " onvif:\n")
|
|
}
|
|
|
|
// Empty host -> block skipped even if ONVIFConfig is non-nil.
|
|
func TestWriter_ONVIF_EmptyHostAbsent(t *testing.T) {
|
|
cfg := mustGen(t, &Request{
|
|
MainStream: baseRTSP,
|
|
ONVIF: &ONVIFConfig{User: "admin"},
|
|
})
|
|
assertNotContains(t, cfg, " onvif:\n")
|
|
}
|
|
|
|
// Host without port -> default port 80.
|
|
func TestWriter_ONVIF_DefaultPort80(t *testing.T) {
|
|
cfg := mustGen(t, &Request{
|
|
MainStream: baseRTSP,
|
|
ONVIF: &ONVIFConfig{Host: "10.0.20.10"},
|
|
})
|
|
assertContains(t, cfg, " onvif:\n host: 10.0.20.10\n port: 80\n")
|
|
}
|
|
|
|
// Explicit port overrides default.
|
|
func TestWriter_ONVIF_ExplicitPort(t *testing.T) {
|
|
cfg := mustGen(t, &Request{
|
|
MainStream: baseRTSP,
|
|
ONVIF: &ONVIFConfig{Host: "10.0.20.10", Port: 2020},
|
|
})
|
|
assertContains(t, cfg, " port: 2020\n")
|
|
assertNotContains(t, cfg, " port: 80\n")
|
|
}
|
|
|
|
// User set -> user + password lines (password lands even if empty -- by design).
|
|
func TestWriter_ONVIF_UserPassword(t *testing.T) {
|
|
cfg := mustGen(t, &Request{
|
|
MainStream: baseRTSP,
|
|
ONVIF: &ONVIFConfig{Host: "10.0.20.10", User: "admin", Password: "s3cret"},
|
|
})
|
|
assertContains(t, cfg, " user: admin\n")
|
|
assertContains(t, cfg, " password: s3cret\n")
|
|
}
|
|
|
|
// No user -> no user/password lines.
|
|
func TestWriter_ONVIF_NoUserNoCredentials(t *testing.T) {
|
|
cfg := mustGen(t, &Request{
|
|
MainStream: baseRTSP,
|
|
ONVIF: &ONVIFConfig{Host: "10.0.20.10"},
|
|
})
|
|
assertNotContains(t, cfg, " user:")
|
|
assertNotContains(t, cfg, " password:")
|
|
}
|
|
|
|
// Autotracking enabled -> autotracking.enabled: true.
|
|
func TestWriter_ONVIF_Autotracking(t *testing.T) {
|
|
cfg := mustGen(t, &Request{
|
|
MainStream: baseRTSP,
|
|
ONVIF: &ONVIFConfig{Host: "10.0.20.10", AutoTracking: true},
|
|
})
|
|
assertContains(t, cfg, " autotracking:\n enabled: true\n")
|
|
}
|
|
|
|
// Autotracking + required_zones -> nested list.
|
|
func TestWriter_ONVIF_AutotrackingRequiredZones(t *testing.T) {
|
|
cfg := mustGen(t, &Request{
|
|
MainStream: baseRTSP,
|
|
ONVIF: &ONVIFConfig{
|
|
Host: "10.0.20.10", AutoTracking: true,
|
|
RequiredZones: []string{"driveway", "yard"},
|
|
},
|
|
})
|
|
assertContains(t, cfg, " required_zones:\n - driveway\n - yard\n")
|
|
}
|
|
|
|
// required_zones without autotracking -> NOT written.
|
|
func TestWriter_ONVIF_RequiredZonesWithoutAutotracking(t *testing.T) {
|
|
cfg := mustGen(t, &Request{
|
|
MainStream: baseRTSP,
|
|
ONVIF: &ONVIFConfig{
|
|
Host: "10.0.20.10",
|
|
RequiredZones: []string{"driveway"},
|
|
},
|
|
})
|
|
assertNotContains(t, cfg, "required_zones:")
|
|
}
|
|
|
|
// --- writePTZ (only written inside onvif block) -------------------------------
|
|
|
|
// PTZ without ONVIF -> nothing written (writer is nested inside writeONVIF).
|
|
func TestWriter_PTZ_WithoutONVIFNotWritten(t *testing.T) {
|
|
cfg := mustGen(t, &Request{
|
|
MainStream: baseRTSP,
|
|
PTZ: &PTZConfig{Enabled: true, Presets: map[string]string{"home": "TOKEN1"}},
|
|
})
|
|
assertNotContains(t, cfg, " onvif:\n")
|
|
assertNotContains(t, cfg, " presets:\n")
|
|
}
|
|
|
|
// PTZ with ONVIF -> ptz.presets nested under onvif.
|
|
func TestWriter_PTZ_WithONVIF(t *testing.T) {
|
|
cfg := mustGen(t, &Request{
|
|
MainStream: baseRTSP,
|
|
ONVIF: &ONVIFConfig{Host: "10.0.20.10"},
|
|
PTZ: &PTZConfig{Enabled: true, Presets: map[string]string{"home": "TOKEN1"}},
|
|
})
|
|
assertContains(t, cfg, " ptz:\n presets:\n home: TOKEN1\n")
|
|
}
|
|
|
|
// Empty PTZ.Presets -> no ptz block even if ONVIF present.
|
|
func TestWriter_PTZ_EmptyPresetsNoBlock(t *testing.T) {
|
|
cfg := mustGen(t, &Request{
|
|
MainStream: baseRTSP,
|
|
ONVIF: &ONVIFConfig{Host: "10.0.20.10"},
|
|
PTZ: &PTZConfig{Enabled: true},
|
|
})
|
|
assertNotContains(t, cfg, " ptz:")
|
|
}
|
|
|
|
// --- writeNotifications -------------------------------------------------------
|
|
|
|
func TestWriter_Notifications_NilAbsent(t *testing.T) {
|
|
cfg := mustGen(t, &Request{MainStream: baseRTSP})
|
|
assertNotContains(t, cfg, " notifications:\n")
|
|
}
|
|
|
|
func TestWriter_Notifications_DisabledAbsent(t *testing.T) {
|
|
cfg := mustGen(t, &Request{
|
|
MainStream: baseRTSP,
|
|
Notifications: &BoolConfig{Enabled: false},
|
|
})
|
|
assertNotContains(t, cfg, " notifications:\n")
|
|
}
|
|
|
|
func TestWriter_Notifications_Enabled(t *testing.T) {
|
|
cfg := mustGen(t, &Request{
|
|
MainStream: baseRTSP,
|
|
Notifications: &BoolConfig{Enabled: true},
|
|
})
|
|
assertContains(t, cfg, " notifications:\n enabled: true\n")
|
|
}
|
|
|
|
// --- writeUI ------------------------------------------------------------------
|
|
|
|
func TestWriter_UI_NilAbsent(t *testing.T) {
|
|
cfg := mustGen(t, &Request{MainStream: baseRTSP})
|
|
assertNotContains(t, cfg, " ui:\n")
|
|
}
|
|
|
|
// Dashboard:true is the default -> block emitted (because req.UI != nil) but no `dashboard: false`.
|
|
func TestWriter_UI_DashboardTrueDefault(t *testing.T) {
|
|
cfg := mustGen(t, &Request{
|
|
MainStream: baseRTSP,
|
|
UI: &UIConfig{Dashboard: true},
|
|
})
|
|
assertContains(t, cfg, " ui:\n")
|
|
assertNotContains(t, cfg, " dashboard:")
|
|
}
|
|
|
|
// Dashboard:false is written (hide from dashboard).
|
|
func TestWriter_UI_DashboardFalse(t *testing.T) {
|
|
cfg := mustGen(t, &Request{
|
|
MainStream: baseRTSP,
|
|
UI: &UIConfig{Dashboard: false},
|
|
})
|
|
assertContains(t, cfg, " ui:\n dashboard: false\n")
|
|
}
|
|
|
|
// Order > 0 written; Order 0 skipped.
|
|
func TestWriter_UI_OrderWritten(t *testing.T) {
|
|
cfg := mustGen(t, &Request{
|
|
MainStream: baseRTSP,
|
|
UI: &UIConfig{Order: 5, Dashboard: true},
|
|
})
|
|
assertContains(t, cfg, " order: 5\n")
|
|
}
|
|
|
|
func TestWriter_UI_OrderZeroSkipped(t *testing.T) {
|
|
cfg := mustGen(t, &Request{
|
|
MainStream: baseRTSP,
|
|
UI: &UIConfig{Dashboard: true},
|
|
})
|
|
assertNotContains(t, cfg, " order:")
|
|
}
|
|
|
|
// --- Frigate overrides --------------------------------------------------------
|
|
|
|
func TestWriter_FrigateOverride_MainStreamPath(t *testing.T) {
|
|
cfg := mustGen(t, &Request{
|
|
MainStream: baseRTSP,
|
|
Frigate: &FrigateOverride{
|
|
MainStreamPath: "rtsp://10.0.0.5:8554/custom_main",
|
|
},
|
|
})
|
|
assertContains(t, cfg, "- path: rtsp://10.0.0.5:8554/custom_main\n")
|
|
assertNotContains(t, cfg, "- path: rtsp://127.0.0.1:8554/10_0_20_10_main\n")
|
|
}
|
|
|
|
func TestWriter_FrigateOverride_MainStreamInputArgs(t *testing.T) {
|
|
cfg := mustGen(t, &Request{
|
|
MainStream: baseRTSP,
|
|
Frigate: &FrigateOverride{MainStreamInputArgs: "-rtsp_transport tcp -timeout 5000000"},
|
|
})
|
|
assertContains(t, cfg, " input_args: -rtsp_transport tcp -timeout 5000000\n")
|
|
assertNotContains(t, cfg, " input_args: preset-rtsp-restream\n")
|
|
}
|
|
|
|
func TestWriter_FrigateOverride_SubStreamPathAndArgs(t *testing.T) {
|
|
cfg := mustGen(t, &Request{
|
|
MainStream: baseRTSP,
|
|
SubStream: baseSubRTSP,
|
|
Frigate: &FrigateOverride{
|
|
SubStreamPath: "rtsp://10.0.0.5:8554/custom_sub",
|
|
SubStreamInputArgs: "preset-rtsp-udp",
|
|
},
|
|
})
|
|
assertContains(t, cfg, "- path: rtsp://10.0.0.5:8554/custom_sub\n")
|
|
assertContains(t, cfg, " input_args: preset-rtsp-udp\n")
|
|
}
|
|
|
|
// --- Go2RTC overrides ---------------------------------------------------------
|
|
|
|
func TestWriter_Go2RTCOverride_MainStreamName(t *testing.T) {
|
|
cfg := mustGen(t, &Request{
|
|
MainStream: baseRTSP,
|
|
Go2RTC: &Go2RTCOverride{MainStreamName: "front_door"},
|
|
})
|
|
assertContains(t, cfg, " 'front_door':\n - rtsp://admin:pw@10.0.20.10:554/Streaming/Channels/101\n")
|
|
// Frigate input path must follow the renamed stream.
|
|
assertContains(t, cfg, "- path: rtsp://127.0.0.1:8554/front_door\n")
|
|
}
|
|
|
|
func TestWriter_Go2RTCOverride_MainStreamSource(t *testing.T) {
|
|
cfg := mustGen(t, &Request{
|
|
MainStream: baseRTSP,
|
|
Go2RTC: &Go2RTCOverride{MainStreamSource: "ffmpeg:file.mp4#video=h264"},
|
|
})
|
|
assertContains(t, cfg, " - ffmpeg:file.mp4#video=h264\n")
|
|
assertNotContains(t, cfg, " - rtsp://admin:pw@10.0.20.10:554/Streaming/Channels/101\n")
|
|
}
|
|
|
|
func TestWriter_Go2RTCOverride_SubStreamName(t *testing.T) {
|
|
cfg := mustGen(t, &Request{
|
|
MainStream: baseRTSP,
|
|
SubStream: baseSubRTSP,
|
|
Go2RTC: &Go2RTCOverride{SubStreamName: "front_door_low"},
|
|
})
|
|
assertContains(t, cfg, " 'front_door_low':\n")
|
|
assertContains(t, cfg, "- path: rtsp://127.0.0.1:8554/front_door_low\n")
|
|
// live.streams must use the renamed sub
|
|
assertContains(t, cfg, " Sub Stream: front_door_low\n")
|
|
}
|
|
|
|
// --- Name override ------------------------------------------------------------
|
|
|
|
func TestWriter_Name_OverrideChangesCameraAndStreamNames(t *testing.T) {
|
|
cfg := mustGen(t, &Request{
|
|
MainStream: baseRTSP,
|
|
SubStream: baseSubRTSP,
|
|
Name: "porch",
|
|
})
|
|
assertContains(t, cfg, " porch:\n")
|
|
assertContains(t, cfg, " 'porch_main':\n")
|
|
assertContains(t, cfg, " 'porch_sub':\n")
|
|
assertContains(t, cfg, "- path: rtsp://127.0.0.1:8554/porch_main\n")
|
|
assertContains(t, cfg, "- path: rtsp://127.0.0.1:8554/porch_sub\n")
|
|
}
|
|
|
|
// --- extractIP / buildInfo fallbacks ------------------------------------------
|
|
|
|
// URL without any parseable host -> camera/stream default names.
|
|
func TestWriter_BuildInfo_NoHostFallback(t *testing.T) {
|
|
cfg := mustGen(t, &Request{MainStream: "rtsp:///nohost/stream"})
|
|
assertContains(t, cfg, " camera:\n")
|
|
assertContains(t, cfg, " 'stream_main':\n")
|
|
}
|
|
|
|
// Hostname (non-IP) is used as-is without dot-sanitization via reIPv4.
|
|
func TestWriter_BuildInfo_HostnameUsed(t *testing.T) {
|
|
cfg := mustGen(t, &Request{MainStream: "rtsp://cam.local:554/stream"})
|
|
// hostname = "cam.local" -> sanitized "cam_local"
|
|
assertContains(t, cfg, " camera_cam_local:\n")
|
|
assertContains(t, cfg, " 'cam_local_main':\n")
|
|
}
|
|
|
|
// --- Generate entry-point errors ----------------------------------------------
|
|
|
|
func TestGenerate_EmptyMainStreamErrors(t *testing.T) {
|
|
_, err := Generate(&Request{})
|
|
if err == nil {
|
|
t.Fatal("expected error for empty MainStream")
|
|
}
|
|
if !strings.Contains(err.Error(), "mainStream required") {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
// Response.Added: fresh config -> all lines are new (1..N).
|
|
func TestGenerate_Added_FreshConfigAllLines(t *testing.T) {
|
|
resp, err := Generate(&Request{MainStream: baseRTSP})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
totalLines := strings.Count(resp.Config, "\n") + 1
|
|
if len(resp.Added) != totalLines {
|
|
t.Errorf("expected Added to cover all %d lines, got %d", totalLines, len(resp.Added))
|
|
}
|
|
// strictly increasing 1..N
|
|
for i, n := range resp.Added {
|
|
if n != i+1 {
|
|
t.Errorf("Added[%d] = %d, want %d", i, n, i+1)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// Response.Added: adding to existing config -> only new lines are flagged,
|
|
// and their indices (1-based) point to lines actually present in Config.
|
|
func TestGenerate_Added_IncrementalConfigOnlyNewLines(t *testing.T) {
|
|
c1, err := Generate(&Request{MainStream: baseRTSP})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
c2, err := Generate(&Request{
|
|
MainStream: "rtsp://admin:pw@10.0.20.20:554/stream",
|
|
ExistingConfig: c1.Config,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(c2.Added) == 0 {
|
|
t.Fatal("expected some Added lines")
|
|
}
|
|
resultLines := strings.Split(c2.Config, "\n")
|
|
for _, n := range c2.Added {
|
|
if n < 1 || n > len(resultLines) {
|
|
t.Errorf("Added line %d out of bounds (1..%d)", n, len(resultLines))
|
|
}
|
|
}
|
|
// must be less than total (otherwise nothing was preserved)
|
|
if len(c2.Added) >= len(resultLines) {
|
|
t.Errorf("Added covers %d of %d lines -- expected partial", len(c2.Added), len(resultLines))
|
|
}
|
|
}
|
|
|
|
// --- top-level structure stability --------------------------------------------
|
|
|
|
// Required top-level headers + order (mqtt -> record -> go2rtc -> cameras -> version).
|
|
func TestWriter_TopLevel_Order(t *testing.T) {
|
|
cfg := mustGen(t, &Request{MainStream: baseRTSP})
|
|
|
|
iMQTT := strings.Index(cfg, "mqtt:")
|
|
iGlobalRec := strings.Index(cfg, "\nrecord:\n enabled: true")
|
|
iGo2rtc := strings.Index(cfg, "\ngo2rtc:")
|
|
iCameras := strings.Index(cfg, "\ncameras:")
|
|
iVersion := strings.Index(cfg, "\nversion:")
|
|
|
|
if iMQTT < 0 || iGlobalRec < 0 || iGo2rtc < 0 || iCameras < 0 || iVersion < 0 {
|
|
t.Fatalf("missing top-level section:\n%s", cfg)
|
|
}
|
|
if !(iMQTT < iGlobalRec && iGlobalRec < iGo2rtc && iGo2rtc < iCameras && iCameras < iVersion) {
|
|
t.Errorf("wrong top-level order: mqtt=%d record=%d go2rtc=%d cameras=%d version=%d",
|
|
iMQTT, iGlobalRec, iGo2rtc, iCameras, iVersion)
|
|
}
|
|
}
|
|
|
|
// Section order inside a camera block (writer.go sequence).
|
|
func TestWriter_CameraBlock_SectionOrder(t *testing.T) {
|
|
cfg := mustGen(t, &Request{
|
|
MainStream: baseRTSP,
|
|
SubStream: baseSubRTSP,
|
|
Live: &LiveConfig{Height: 720},
|
|
Detect: &DetectConfig{Enabled: true},
|
|
Objects: []string{"person"},
|
|
Motion: &MotionConfig{Enabled: true},
|
|
Record: &RecordConfig{Enabled: true},
|
|
Snapshots: &BoolConfig{Enabled: true},
|
|
Audio: &AudioConfig{Enabled: true},
|
|
Birdseye: &BirdseyeConfig{Enabled: true},
|
|
ONVIF: &ONVIFConfig{Host: "10.0.20.10"},
|
|
Notifications: &BoolConfig{Enabled: true},
|
|
UI: &UIConfig{Dashboard: false},
|
|
})
|
|
|
|
order := []string{
|
|
" ffmpeg:\n",
|
|
" live:\n",
|
|
" detect:\n",
|
|
" objects:\n",
|
|
" motion:\n",
|
|
" record:\n enabled:",
|
|
" snapshots:\n",
|
|
" audio:\n",
|
|
" birdseye:\n",
|
|
" onvif:\n",
|
|
" notifications:\n",
|
|
" ui:\n",
|
|
}
|
|
prev := -1
|
|
for _, s := range order {
|
|
idx := strings.Index(cfg, s)
|
|
if idx < 0 {
|
|
t.Errorf("missing section %q\n%s", s, cfg)
|
|
continue
|
|
}
|
|
if idx < prev {
|
|
t.Errorf("section %q out of order\n%s", s, cfg)
|
|
}
|
|
prev = idx
|
|
}
|
|
}
|