package config import ( "encoding/json" "testing" "time" ) // Absent blocks must read as off on a nil receiver: the daemon calls these // helpers before it knows whether the operator configured anything. func TestSensesOffByDefault(t *testing.T) { var cfg Config if cfg.Media.StoreDir() != "" { t.Error("media store dir is set with no media block") } if cfg.Vision.LooksAtImages() { t.Error("vision is on with no vision block") } if cfg.Capture.Records() { t.Error("the recorder is on with no capture block") } if cfg.Capture.MaxDuration() != 0 { t.Error("a nil capture block invented a duration") } if cfg.Speaker.Recognizes() { t.Error("speaker recognition is on with no speaker block") } } // The recorder is the capability that most needs its default to be off, so it // gets its own test rather than a line in the one above. func TestCaptureIsOffUntilExplicitlyEnabled(t *testing.T) { cases := []struct { name string c *CaptureConfig want bool }{ {"absent", nil, false}, {"present but not enabled", &CaptureConfig{MaxMinutes: 60}, false}, {"enabled", &CaptureConfig{Enabled: true}, true}, } for _, c := range cases { if got := c.c.Records(); got != c.want { t.Errorf("%s: Records() = %v, want %v", c.name, got, c.want) } } } func TestCaptureBlockParsesFromJSON(t *testing.T) { raw := `{"capture":{"enabled":true,"max_minutes":45,"stt_window":"2m", "chunk_runes":2000,"max_chunks":10,"save_transcript":true}}` var cfg Config if err := json.Unmarshal([]byte(raw), &cfg); err != nil { t.Fatalf("unmarshal: %v", err) } if !cfg.Capture.Records() { t.Fatal("capture did not parse as enabled") } if cfg.Capture.MaxDuration() != 45*time.Minute { t.Errorf("max duration = %v", cfg.Capture.MaxDuration()) } if time.Duration(cfg.Capture.STTWindow) != 2*time.Minute { t.Errorf("stt window = %v", time.Duration(cfg.Capture.STTWindow)) } if cfg.Capture.ChunkRunes != 2000 || cfg.Capture.MaxChunks != 10 { t.Errorf("summariser limits = %+v", cfg.Capture) } if !cfg.Capture.SaveTranscript { t.Error("save_transcript did not parse") } } // Keeping the verbatim record of what other people said is the heavier act, so // it is separately opt-in from recording at all. func TestTranscriptIsNotSavedByDefault(t *testing.T) { var cfg Config if err := json.Unmarshal([]byte(`{"capture":{"enabled":true}}`), &cfg); err != nil { t.Fatal(err) } if cfg.Capture.SaveTranscript { t.Error("transcripts are saved without anyone asking") } if cfg.Capture.MaxDuration() != 0 { t.Error("max_minutes defaulted in config instead of in the package") } } // enabled with nothing to talk to is a misconfiguration, not a capability. func TestVisionNeedsBothEnabledAndEndpoint(t *testing.T) { cases := []struct { name string v *VisionConfig want bool }{ {"absent", nil, false}, {"endpoint but not enabled", &VisionConfig{Endpoint: "http://127.0.0.1:8081"}, false}, {"enabled but no endpoint", &VisionConfig{Enabled: true}, false}, {"enabled, blank endpoint", &VisionConfig{Enabled: true, Endpoint: " "}, false}, {"both", &VisionConfig{Enabled: true, Endpoint: "http://127.0.0.1:8081"}, true}, } for _, c := range cases { if got := c.v.LooksAtImages(); got != c.want { t.Errorf("%s: LooksAtImages() = %v, want %v", c.name, got, c.want) } } } func TestSensesBlocksParseFromJSON(t *testing.T) { raw := `{ "db_path": "/tmp/x.db", "socket_path": "/tmp/x.sock", "media": {"dir": "media", "retention": "48h", "max_bytes": 1048576}, "vision": { "enabled": true, "endpoint": "http://127.0.0.1:8081", "model": "qwen2.5-vl", "max_dim": 640, "max_tokens": 200, "timeout": "45s", "prompt": "Что тут?" } }` var cfg Config if err := json.Unmarshal([]byte(raw), &cfg); err != nil { t.Fatalf("unmarshal: %v", err) } if cfg.Media.StoreDir() != "media" { t.Errorf("media dir = %q", cfg.Media.StoreDir()) } if time.Duration(cfg.Media.Retention) != 48*time.Hour { t.Errorf("retention = %v", time.Duration(cfg.Media.Retention)) } if cfg.Media.MaxBytes != 1<<20 { t.Errorf("max_bytes = %d", cfg.Media.MaxBytes) } if !cfg.Vision.LooksAtImages() { t.Fatal("vision did not parse as enabled") } if cfg.Vision.MaxDim != 640 || cfg.Vision.MaxTokens != 200 { t.Errorf("vision limits = %+v", cfg.Vision) } if time.Duration(cfg.Vision.Timeout) != 45*time.Second { t.Errorf("vision timeout = %v", time.Duration(cfg.Vision.Timeout)) } if cfg.Vision.Prompt != "Что тут?" { t.Errorf("prompt = %q", cfg.Vision.Prompt) } } // A media dir set with no vision block is a valid state, and the useful one on a // box with no vision model: images can be kept, they just cannot be described. func TestMediaWithoutVisionIsValid(t *testing.T) { var cfg Config if err := json.Unmarshal([]byte(`{"media":{"dir":"/srv/media"}}`), &cfg); err != nil { t.Fatal(err) } if cfg.Media.StoreDir() != "/srv/media" { t.Errorf("dir = %q", cfg.Media.StoreDir()) } if cfg.Vision.LooksAtImages() { t.Error("vision came on by itself") } } // A voiceprint is a biometric of a named person. Nothing about it turns on by // itself: no speaker block means no recognition, and no enrolment either. func TestSpeakerIsOffUntilExplicitlyEnabled(t *testing.T) { var cfg Config if err := json.Unmarshal([]byte(`{}`), &cfg); err != nil { t.Fatal(err) } if cfg.Speaker.Recognizes() { t.Error("speaker recognition came on with no config at all") } var empty Config if err := json.Unmarshal([]byte(`{"speaker":{}}`), &empty); err != nil { t.Fatal(err) } if empty.Speaker.Recognizes() { t.Error("an empty speaker block enabled recognition") } } // Enabled alone is not enough: recognition needs a model, and on this box there // is none. Recognizes() must stay false so the daemon reports the honest state // instead of claiming a capability it cannot perform. func TestSpeakerNeedsBothEnabledAndAModel(t *testing.T) { var cfg Config if err := json.Unmarshal([]byte(`{"speaker":{"enabled":true}}`), &cfg); err != nil { t.Fatal(err) } if cfg.Speaker.Recognizes() { t.Error("enabled with no model_path claimed to recognise") } var only Config if err := json.Unmarshal([]byte(`{"speaker":{"model_path":"/opt/x.onnx"}}`), &only); err != nil { t.Fatal(err) } if only.Speaker.Recognizes() { t.Error("a model_path alone enabled recognition") } } func TestSpeakerBlockParsesFromJSON(t *testing.T) { const raw = `{"speaker":{"enabled":true,"model_path":"/opt/maven/models/spk/ecapa.onnx",` + `"lib_path":"/opt/maven/lib","threshold":0.62,"min_seconds":1.5}}` var cfg Config if err := json.Unmarshal([]byte(raw), &cfg); err != nil { t.Fatalf("unmarshal: %v", err) } if !cfg.Speaker.Recognizes() { t.Fatal("speaker did not parse as enabled") } if cfg.Speaker.ModelPath != "/opt/maven/models/spk/ecapa.onnx" { t.Errorf("model_path = %q", cfg.Speaker.ModelPath) } if cfg.Speaker.LibPath != "/opt/maven/lib" { t.Errorf("lib_path = %q", cfg.Speaker.LibPath) } if cfg.Speaker.Threshold != 0.62 || cfg.Speaker.MinSeconds != 1.5 { t.Errorf("thresholds = %+v", cfg.Speaker) } } // A typo in the vision endpoint, or a media block with no dir, used to be // logged once at wiring time and the capability just stayed off. A capability // that silently does not exist is the hardest misconfiguration to notice, so // both fail at startup now. func TestSensesBlocksAreValidatedAtStartup(t *testing.T) { bad := map[string]string{ "media with no dir": `{"media":{"retention":"48h"}}`, "negative budget": `{"media":{"dir":"/srv/media","max_total_bytes":-1}}`, "blob over the budget": `{"media":{"dir":"/srv/media","max_bytes":100,"max_total_bytes":10}}`, "vision with no media dir": `{"vision":{"enabled":true,"endpoint":"http://127.0.0.1:8081"}}`, "vision endpoint typo": `{"media":{"dir":"/srv/media"},"vision":{"enabled":true,"endpoint":"127.0.0.1:8081"}}`, "vision on the wan": `{"media":{"dir":"/srv/media"},"vision":{"enabled":true,"endpoint":"http://8.8.8.8:8081"}}`, "vision, empty endpoint": `{"media":{"dir":"/srv/media"},"vision":{"enabled":true}}`, "capture with no store": `{"capture":{"enabled":true}}`, } for name, body := range bad { t.Run(name, func(t *testing.T) { if _, err := Load(writeConfig(t, body)); err == nil { t.Fatal("want a startup error") } }) } good := map[string]string{ "media alone": `{"media":{"dir":"/srv/media"}}`, "media + vision": `{"media":{"dir":"/srv/media"},"vision":{"enabled":true,"endpoint":"http://127.0.0.1:8081"}}`, "media + capture": `{"media":{"dir":"/srv/media"},"capture":{"enabled":true}}`, "vision off": `{"vision":{"endpoint":"http://8.8.8.8:8081"}}`, } for name, body := range good { t.Run(name, func(t *testing.T) { if _, err := Load(writeConfig(t, body)); err != nil { t.Fatalf("valid config refused: %v", err) } }) } }