Files
Maven/internal/config/config_test.go
T
kami 79893d646b kiwix: read the article, not the snippet, and state the persona as morphology
Two things that were each half-done.

The prompts handed the model copyable examples. chatSystemPrompt lost its
openers this morning and the "Я подумала, что" tic went with them, but "не
забыл ли я" appeared in its place: the removed example had been suppressing
the masculine self-reference by accident. Two predicatives are not enough
signal, so the rule is now stated as morphology (-ла) rather than as a pair of
words — a suffix rule generalises where an example only gets copied.
querySystemPrompt had the same defect and gets the same treatment; its "вот что
я нашла: " opener is deliberate and stays.

internal/kiwix had no caller. actions_query.go said "once internal/kiwix is
wired into this chain" and that never happened. It is wired now, between the
notes pass and the web source: everything of his answers first, and only what
is left over is looked up. Off unless a `kiwix` block names a server and a book.

Reading the search snippet does not work. Kiwix builds it from wherever the
keyword matched, which on Wikipedia is the navigation box at the foot of the
page — the first version of this answered "что такое фотосинтез?" by reciting
"Ecological economics Ecological footprint Ecological forecasting …". Client
grows an Article method; the head of the article is the lead paragraph, which
is the definition the snippet was meant to be. Verified on the box: the same
question now answers correctly off the ZIM.

Only the rewritten query leaves the process. A test asserts it: a turn carrying
a stored note must not put that note in the search string.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
2026-08-01 19:46:39 +04:00

416 lines
13 KiB
Go

package config
import (
"path/filepath"
"testing"
"time"
)
func writeConfig(t *testing.T, body string) string {
t.Helper()
p := filepath.Join(t.TempDir(), "mavend.json")
if err := writeFile(t, p, body); err != nil {
t.Fatalf("write config: %v", err)
}
return p
}
func TestLoadDefaults(t *testing.T) {
p := writeConfig(t, `{}`)
c, err := Load(p)
if err != nil {
t.Fatalf("Load: %v", err)
}
if time.Duration(c.TickInterval) != DefaultTickInterval {
t.Errorf("TickInterval default = %v, want %v", c.TickInterval, DefaultTickInterval)
}
if time.Duration(c.RepeatInterval) != DefaultRepeatInterval {
t.Errorf("RepeatInterval default = %v, want %v", c.RepeatInterval, DefaultRepeatInterval)
}
if c.DBPath == "" {
t.Error("DBPath default not applied")
}
if c.SocketPath == "" {
t.Error("SocketPath default not applied")
}
}
// Nudges come from templates unless the config says otherwise.
func TestPhraserLLMNudgesDefaultsOff(t *testing.T) {
p := writeConfig(t, `{"phraser":{"model_path":"/tmp/m.gguf"}}`)
c, err := Load(p)
if err != nil {
t.Fatalf("Load: %v", err)
}
if c.Phraser.LLMNudges {
t.Error("llm_nudges defaults on; templates must be the default")
}
p = writeConfig(t, `{"phraser":{"model_path":"/tmp/m.gguf","llm_nudges":true}}`)
c, err = Load(p)
if err != nil {
t.Fatalf("Load: %v", err)
}
if !c.Phraser.LLMNudges {
t.Error("llm_nudges:true did not parse")
}
}
func TestLoadDurationsParse(t *testing.T) {
p := writeConfig(t, `{"tick_interval":"90s","repeat_interval":"10m"}`)
c, err := Load(p)
if err != nil {
t.Fatalf("Load: %v", err)
}
if time.Duration(c.TickInterval) != 90*time.Second {
t.Errorf("TickInterval = %v, want 90s", c.TickInterval)
}
if time.Duration(c.RepeatInterval) != 10*time.Minute {
t.Errorf("RepeatInterval = %v, want 10m", c.RepeatInterval)
}
}
func TestLoadBadDurationRejected(t *testing.T) {
p := writeConfig(t, `{"tick_interval":"not-a-duration"}`)
if _, err := Load(p); err == nil {
t.Fatal("Load succeeded for a bad duration; want error")
}
}
func TestLoadMissingFile(t *testing.T) {
p := filepath.Join(t.TempDir(), "nonexistent.json")
if _, err := Load(p); err == nil {
t.Fatal("Load succeeded for a missing file; want error")
}
}
func TestVoiceEnabledRequiresBind(t *testing.T) {
// enabled=true without bind is refused — the voice surface can't
// default a bind (127.0.0.1 too relaxed for production, a wg addr is
// the user's). surfacing the gap explicitly beats an idle listener.
p := writeConfig(t, `{"voice":{"enabled":true}}`)
if _, err := Load(p); err == nil {
t.Fatal("Load succeeded for voice.enabled=true with no bind; want error")
}
}
func TestVoiceEnabledWithBindOK(t *testing.T) {
// voice surface fully configured — accepted (the daemon wires Stub tts +
// voicesink; no models on disk required).
p := writeConfig(t, `{"voice":{"enabled":true,"bind":"127.0.0.1:9100"}}`)
if _, err := Load(p); err != nil {
t.Fatalf("Load: %v", err)
}
}
func TestEmbedderAllPathsSetOK(t *testing.T) {
p := writeConfig(t, `{
"voice": {
"enabled": true, "bind": "127.0.0.1:9100",
"embedder": {
"model_path": "m.onnx",
"tokenizer_path": "t.json",
"lib_path": "l.so"
}
}
}`)
if _, err := Load(p); err != nil {
t.Fatalf("Load: %v", err)
}
}
func TestEmbedderPartialConfigRejected(t *testing.T) {
tests := []struct {
name string
json string
}{
{"missing model_path", `{"voice":{"enabled":true,"bind":"127.0.0.1:9100","embedder":{"tokenizer_path":"t.json","lib_path":"l.so"}}}`},
{"missing tokenizer_path", `{"voice":{"enabled":true,"bind":"127.0.0.1:9100","embedder":{"model_path":"m.onnx","lib_path":"l.so"}}}`},
{"missing lib_path", `{"voice":{"enabled":true,"bind":"127.0.0.1:9100","embedder":{"model_path":"m.onnx","tokenizer_path":"t.json"}}}`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
p := writeConfig(t, tt.json)
if _, err := Load(p); err == nil {
t.Fatal("Load succeeded for partial embedder; want error")
}
})
}
}
func TestEmbedderNilOK(t *testing.T) {
// voice block without embedder → ok (HashEmbedder floor)
p := writeConfig(t, `{"voice":{"enabled":true,"bind":"127.0.0.1:9100"}}`)
if _, err := Load(p); err != nil {
t.Fatalf("Load: %v", err)
}
}
func TestVoicePresentDisabledOK(t *testing.T) {
// voice block present but not enabled — acceptable (the listener stays
// down; the routing table's ChannelVoice selections drop).
p := writeConfig(t, `{"voice":{"enabled":false}}`)
if _, err := Load(p); err != nil {
t.Fatalf("Load: %v", err)
}
}
func TestVoiceSttTtsConfigParsed(t *testing.T) {
// the stt/tts worker sub-blocks parse + remember socket/lang.
p := writeConfig(t, `{
"voice": {
"enabled": true, "bind": "127.0.0.1:9100",
"stt": {"socket": "/tmp/stt.sock", "lang": "ru"},
"tts": {"socket": "/tmp/tts.sock", "lang": "ru", "voice": "natasha"}
}
}`)
c, err := Load(p)
if err != nil {
t.Fatalf("Load: %v", err)
}
if c.Voice.Stt == nil || c.Voice.Stt.Socket != "/tmp/stt.sock" {
t.Fatalf("Stt config not parsed: %+v", c.Voice)
}
if c.Voice.Tts == nil || c.Voice.Tts.Socket != "/tmp/tts.sock" || c.Voice.Tts.Voice != "natasha" {
t.Fatalf("Tts config not parsed: %+v", c.Voice)
}
}
func TestWeatherConfig(t *testing.T) {
// Weather block with provider + default location → OK
p := writeConfig(t, `{"voice":{"enabled":true,"bind":"127.0.0.1:9100","weather":{"provider":"open-meteo","default_location":"Moscow"}}}`)
if _, err := Load(p); err != nil {
t.Fatalf("Load with weather config: %v", err)
}
}
func TestWeatherConfigNilOK(t *testing.T) {
// No weather block → OK (stub)
p := writeConfig(t, `{"voice":{"enabled":true,"bind":"127.0.0.1:9100"}}`)
if _, err := Load(p); err != nil {
t.Fatalf("Load without weather config: %v", err)
}
}
func TestLLMRouterDefaultsOn(t *testing.T) {
p := writeConfig(t, `{"voice":{"enabled":true,"bind":"127.0.0.1:9100"}}`)
c, err := Load(p)
if err != nil {
t.Fatalf("Load: %v", err)
}
if !c.Voice.UseLLMRouter() {
t.Error("voice.llm_router absent should mean on")
}
}
// Missing and explicitly false must not mean the same thing.
func TestLLMRouterExplicitFalseTurnsItOff(t *testing.T) {
p := writeConfig(t, `{"voice":{"enabled":true,"bind":"127.0.0.1:9100","llm_router":false}}`)
c, err := Load(p)
if err != nil {
t.Fatalf("Load: %v", err)
}
if c.Voice.UseLLMRouter() {
t.Error("voice.llm_router false should turn it off")
}
}
func TestLLMRouterRead(t *testing.T) {
p := writeConfig(t, `{"voice":{"enabled":true,"bind":"127.0.0.1:9100","llm_router":true}}`)
c, err := Load(p)
if err != nil {
t.Fatalf("Load: %v", err)
}
if !c.Voice.UseLLMRouter() {
t.Error("voice.llm_router true was not read")
}
}
func TestDurationRoundTrip(t *testing.T) {
d := Duration(15 * time.Minute)
b, err := d.MarshalJSON()
if err != nil {
t.Fatalf("MarshalJSON: %v", err)
}
if got, want := string(b), `"15m0s"`; got != want {
t.Errorf("MarshalJSON = %s, want %s", got, want)
}
var d2 Duration
if err := d2.UnmarshalJSON(b); err != nil {
t.Fatalf("UnmarshalJSON: %v", err)
}
if d2 != d {
t.Errorf("round-trip = %v, want %v", d2, d)
}
}
// Both new opt-in capabilities follow the same rule: absent block ⇒ nil ⇒ the
// behaviour does not exist. Presence is the enable act, so a bare `{}` block is
// valid and gets the defaults filled in.
func TestOptInBlocksAbsentStayNil(t *testing.T) {
c, err := Load(writeConfig(t, `{}`))
if err != nil {
t.Fatalf("Load: %v", err)
}
if c.PatternProposals != nil {
t.Errorf("pattern_proposals absent but got %+v", c.PatternProposals)
}
if c.PatternProposals.AnnounceProposals() {
t.Error("AnnounceProposals() true with no config block")
}
if c.MemoryEval != nil {
t.Errorf("memory_eval absent but got %+v", c.MemoryEval)
}
}
func TestOptInBlocksGetDefaultsWhenPresent(t *testing.T) {
c, err := Load(writeConfig(t, `{"pattern_proposals":{"notify":true},"memory_eval":{}}`))
if err != nil {
t.Fatalf("Load: %v", err)
}
if !c.PatternProposals.AnnounceProposals() {
t.Error("notify:true did not enable announcements")
}
if time.Duration(c.PatternProposals.Cooldown) != DefaultProposalCooldown {
t.Errorf("proposal cooldown = %v, want %v", c.PatternProposals.Cooldown, DefaultProposalCooldown)
}
if time.Duration(c.MemoryEval.Interval) != DefaultMemoryEvalInterval {
t.Errorf("memory eval interval = %v, want %v", c.MemoryEval.Interval, DefaultMemoryEvalInterval)
}
}
// Notify is off even when the block exists — the block is where you tune it,
// notify:true is the act that lets her speak.
func TestPatternProposalNotifyDefaultsOff(t *testing.T) {
c, err := Load(writeConfig(t, `{"pattern_proposals":{"cooldown":"6h"}}`))
if err != nil {
t.Fatalf("Load: %v", err)
}
if c.PatternProposals.AnnounceProposals() {
t.Error("notify defaulted to on")
}
if time.Duration(c.PatternProposals.Cooldown) != 6*time.Hour {
t.Errorf("cooldown = %v, want 6h", c.PatternProposals.Cooldown)
}
}
// TestSwapModelsAbsentMeansOff — the swap capability does not exist unless the
// operator lists the models he allows (Vikunja #250).
func TestSwapModelsAbsentMeansOff(t *testing.T) {
c, err := Load(writeConfig(t, `{"phraser": {"model_path": "/m/qwen.gguf"}}`))
if err != nil {
t.Fatalf("Load: %v", err)
}
if len(c.Phraser.SwapModels) != 0 {
t.Errorf("swap_models = %v; want empty when unconfigured", c.Phraser.SwapModels)
}
}
func TestSwapModelsParsedAndMustBeAbsolute(t *testing.T) {
c, err := Load(writeConfig(t, `{"phraser": {
"model_path": "/m/qwen.gguf",
"swap_models": ["/m/qwen.gguf", "/m/qwen-cpt.gguf"]
}}`))
if err != nil {
t.Fatalf("Load: %v", err)
}
if len(c.Phraser.SwapModels) != 2 {
t.Fatalf("swap_models = %v; want 2 entries", c.Phraser.SwapModels)
}
// A relative entry would resolve against the daemon's cwd, not the operator's.
if _, err := Load(writeConfig(t, `{"phraser": {
"model_path": "/m/qwen.gguf",
"swap_models": ["models/llm/qwen.gguf"]
}}`)); err == nil {
t.Error("Load accepted a relative swap_models entry; want a startup failure")
}
}
// TestUpdateBlockAbsentMeansOff — mavend never updates itself; the block only
// exists so cmd/mavupdate can find the deployment it is asked to update
// (Vikunja #249). Absent is the normal state.
func TestUpdateBlockAbsentMeansOff(t *testing.T) {
c, err := Load(writeConfig(t, `{}`))
if err != nil {
t.Fatalf("Load: %v", err)
}
if c.Update != nil {
t.Errorf("update = %+v; want nil when unconfigured", c.Update)
}
}
func TestUpdateBlockValidatedAtStartup(t *testing.T) {
good := `{"update": {
"source_dir": "/srv/maven",
"install_dir": "/srv/maven",
"snapshot_dir": "/var/lib/maven/snapshots",
"binaries": ["mavend", "mavweb"],
"restart_cmd": ["docker", "compose", "up", "-d", "--build", "mavend"],
"health_socket": "/run/maven/mavend.sock",
"source_rollback": "git"
}}`
c, err := Load(writeConfig(t, good))
if err != nil {
t.Fatalf("Load: %v", err)
}
if c.Update == nil || len(c.Update.Binaries) != 2 {
t.Fatalf("update block = %+v; want it parsed", c.Update)
}
// A block with no health check cannot detect its own failure, so it cannot
// roll back — refused at load, not halfway through a deploy.
noHealth := `{"update": {
"source_dir": "/srv/maven", "install_dir": "/srv/maven",
"snapshot_dir": "/var/lib/maven/snapshots",
"binaries": ["mavend"], "restart_cmd": ["true"]
}}`
if _, err := Load(writeConfig(t, noHealth)); err == nil {
t.Error("Load accepted an update block with no health_socket")
}
}
// A kiwix block with no address, or no book, has nothing to search.
// kiwix-serve answers 400 to an empty books.name, so the block is dropped here
// rather than left to fail one query at a time.
func TestNormaliseDropsIncompleteKiwix(t *testing.T) {
for _, tc := range []struct {
name string
in *KiwixConfig
}{
{"no url", &KiwixConfig{Book: "wikipedia_en_all_maxi"}},
{"no book", &KiwixConfig{URL: "http://kiwix:8080"}},
{"blank url", &KiwixConfig{URL: " ", Book: "b"}},
} {
t.Run(tc.name, func(t *testing.T) {
c := &Config{Kiwix: tc.in}
c.applyDefaults()
if c.Kiwix != nil {
t.Errorf("kept an unusable kiwix block: %+v", c.Kiwix)
}
})
}
}
func TestNormaliseFillsKiwixDefaults(t *testing.T) {
c := &Config{Kiwix: &KiwixConfig{URL: "http://kiwix:8080", Book: "b"}}
c.applyDefaults()
if c.Kiwix == nil {
t.Fatal("dropped a complete kiwix block")
}
if c.Kiwix.MaxResults != DefaultKiwixResults {
t.Errorf("MaxResults = %d, want %d", c.Kiwix.MaxResults, DefaultKiwixResults)
}
if c.Kiwix.SnippetRunes != DefaultKiwixSnippetRunes {
t.Errorf("SnippetRunes = %d, want %d", c.Kiwix.SnippetRunes, DefaultKiwixSnippetRunes)
}
// Rewriting is on unless it is turned off: an English ZIM searched with a
// Russian sentence matches nothing, so the useful default is the on one.
if !c.Kiwix.RewriteEnabled() {
t.Error("rewriting defaulted to off")
}
off := false
c.Kiwix.Rewrite = &off
if c.Kiwix.RewriteEnabled() {
t.Error("rewrite: false was not honoured")
}
}