diff --git a/pkg/generate/writer_test.go b/pkg/generate/writer_test.go new file mode 100644 index 0000000..ae9a24c --- /dev/null +++ b/pkg/generate/writer_test.go @@ -0,0 +1,877 @@ +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 + } +}