543aefde4b
Saving a description writes recall corpus. writeNote embeds it under media:image:<id>, a source no enrollment owns, and the method sits at AuthRead, so any enrolled module could put a small VLM's guess into what Maven knows and have it come back in a later turn as something she believes. The describing half stays a read; save_note is now held to the same source-scope rule WriteFact is, and the stored text carries a marker saying it came off a picture. Three doc comments said the method exists only when vision is enabled and the code says otherwise. The code is right, and storing without describing is the state this box is in, so the comments were corrected rather than the behaviour. A request carrying both data and id used to take the id branch and drop the bytes without a word; it is refused. A media dir that cannot be created and a vision endpoint that is a typo were logged at wiring time and the capability just stayed off, which is the hardest kind of misconfiguration to notice. Both fail at startup. runPrune was the one loop started with a bare go and not in the daemon's WaitGroup, so shutdown did not wait for a prune that was deleting files. Found in review of #72.
261 lines
8.7 KiB
Go
261 lines
8.7 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|