package main
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/kami/maven/internal/config"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/kiwix"
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/voice"
)
// searchRSS is what kiwix-serve answers a /search with, trimmed to the fields
// ParseSearchRSS reads.
func searchRSS(items ...string) string {
return `
the lead paragraph
")) return } s.lastPattern = r.URL.Query().Get("pattern") s.lastBook = r.URL.Query().Get("books.name") w.Header().Set("Content-Type", "application/xml") _, _ = w.Write([]byte(body)) })) t.Cleanup(s.Close) return s } // buildKiwixHandler wires the source with no rewriter: the question is searched // verbatim, which keeps the assertion about what was sent unambiguous. func buildKiwixHandler(base string) *reactiveHandler { return &reactiveHandler{ replier: voice.NewStubReplier(), phraser: phraser.NewStub(), kiwix: &kiwixWiring{ client: kiwix.New(base), book: "wikipedia_en_all_maxi", max: config.DefaultKiwixResults, runes: config.DefaultKiwixSnippetRunes, }, } } func askKiwix(h *reactiveHandler, q string) (string, bool) { return h.queryKiwix(context.Background(), &queryTurn{ dec: router.Decision{Intent: router.IntentQuery, Utterance: q}, }) } // The default daemon has no `kiwix` block, and a source that is off must not // claim the turn — the model answers next, exactly as it did before. func TestQueryKiwixOffPassesThrough(t *testing.T) { h := &reactiveHandler{replier: voice.NewStubReplier(), phraser: phraser.NewStub()} if reply, ok := askKiwix(h, "почему небо синее?"); ok { t.Errorf("an unconfigured kiwix claimed the turn: %q", reply) } } func TestQueryKiwixAnswersFromSnippets(t *testing.T) { s := newStubKiwix(t, searchRSS(rssItem("Rayleigh scattering", "shorter wavelengths scatter more")), 0) h := buildKiwixHandler(s.URL) reply, ok := askKiwix(h, "почему небо синее?") if !ok { t.Fatal("kiwix found a hit and did not claim the turn") } if reply == "" { t.Error("claimed the turn with an empty reply") } if s.lastBook != "wikipedia_en_all_maxi" { t.Errorf("books.name = %q, want the configured book", s.lastBook) } } // No hit is not a failure worth announcing: the ZIM does not cover it, and the // model answering next beats "ничего не нашла". func TestQueryKiwixNoHitsPassesThrough(t *testing.T) { s := newStubKiwix(t, searchRSS(), 0) if reply, ok := askKiwix(buildKiwixHandler(s.URL), "почему небо синее?"); ok { t.Errorf("an empty result set claimed the turn: %q", reply) } } // A dead or misconfigured server must degrade to the model, not to an error // spoken out loud. A turn never breaks on a capability. func TestQueryKiwixServerErrorPassesThrough(t *testing.T) { s := newStubKiwix(t, "", http.StatusBadRequest) if reply, ok := askKiwix(buildKiwixHandler(s.URL), "почему небо синее?"); ok { t.Errorf("a 400 claimed the turn: %q", reply) } } // The privacy rule in CLAUDE.md, asserted rather than assumed: only the // utterance is searched. No note, no fact, no persona block travels with it. func TestQueryKiwixSendsOnlyTheQuestion(t *testing.T) { s := newStubKiwix(t, searchRSS(rssItem("X", "y")), 0) h := buildKiwixHandler(s.URL) // A turn carrying notes an earlier source already pulled. They must not // reach the query string. _, _ = h.queryKiwix(context.Background(), &queryTurn{ dec: router.Decision{Intent: router.IntentQuery, Utterance: "почему небо синее?"}, notes: []ipc.Note{{Text: "пароль от роутера hunter2"}}, }) if strings.Contains(s.lastPattern, "hunter2") { t.Fatalf("a stored note leaked into the search query: %q", s.lastPattern) } if s.lastPattern != "почему небо синее?" { t.Errorf("pattern = %q, want the utterance verbatim", s.lastPattern) } } func TestWireKiwixOffWithoutABlock(t *testing.T) { if w := wireKiwix(&config.Config{}, nil); w != nil { t.Error("wireKiwix built a source with no config block") } } // No llama-server means no rewriter, but the source still works: searching the // question verbatim is the honest degraded mode, not a reason to stay dark. func TestWireKiwixWithoutAnLLMHasNoRewriter(t *testing.T) { w := wireKiwix(&config.Config{Kiwix: &config.KiwixConfig{ URL: "http://kiwix:8080", Book: "b", MaxResults: 5, SnippetRunes: 1500, }}, nil) if w == nil { t.Fatal("wireKiwix returned nil for a configured block") } if w.rewriter != nil { t.Error("built a rewriter with no llm client") } if w.book != "b" { t.Errorf("book = %q", w.book) } } // The whole point of reading the article: kiwix's own snippet is usually the // navigation box at the foot of the page, so the lead paragraph must be what // reaches the phraser. func TestQueryKiwixReadsTheArticleNotTheSnippet(t *testing.T) { junk := "Ecological economics Ecological footprint Ecological forecasting" s := newStubKiwix(t, searchRSS(rssItem("Photosynthesis", junk)), 0) h := buildKiwixHandler(s.URL) h.phraser = nil // no phraser ⇒ the fallback reads back what it was given reply, ok := askKiwix(h, "что такое фотосинтез?") if !ok { t.Fatal("did not claim the turn") } if !strings.Contains(reply, "the lead paragraph") { t.Errorf("reply did not come from the article: %q", reply) } if strings.Contains(reply, "Ecological economics") { t.Errorf("recited the navigation-box snippet: %q", reply) } }