package main import ( "context" "net/http" "net/http/httptest" "net/url" "strings" "testing" "github.com/kami/maven/internal/router" "github.com/kami/maven/internal/websearch" ) func searchHandler(t *testing.T, body string, status int) (*reactiveHandler, *string) { t.Helper() var seen string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { seen = r.URL.RawQuery if status != http.StatusOK { http.Error(w, "no", status) return } w.Write([]byte(body)) })) t.Cleanup(srv.Close) return &reactiveHandler{ // No phraser: querySearch then reads back the best evidence, which is // what makes the claim visible without a llama-server in the test. search: &searchWiring{client: websearch.New(srv.URL, websearch.Options{}), max: 3, runes: 1500}, }, &seen } const searchBody = `{"answers":["Небо голубое из-за рэлеевского рассеяния."], "results":[{"title":"Рэлеевское рассеяние","url":"https://ru.wikipedia.org/x","content":"Рассеяние света."}]}` func TestQuerySearchClaimsAndReadsBack(t *testing.T) { h, _ := searchHandler(t, searchBody, http.StatusOK) reply, ok := h.querySearch(context.Background(), &queryTurn{ dec: router.Decision{Intent: router.IntentQuery, Utterance: "почему небо голубое"}, }) if !ok { t.Fatal("querySearch passed on a search with hits") } if !strings.Contains(reply, "рэлеевского рассеяния") { t.Fatalf("reply = %q", reply) } } // No rewriter in front of this source: SearXNG ranks by meaning, and reducing // the question to English keywords would throw away the language he asked in. func TestQuerySearchSendsTheQuestionVerbatim(t *testing.T) { h, seen := searchHandler(t, searchBody, http.StatusOK) h.querySearch(context.Background(), &queryTurn{ dec: router.Decision{Intent: router.IntentQuery, Utterance: "почему небо голубое"}, }) if !strings.Contains(*seen, "q="+url.QueryEscape("почему небо голубое")) { t.Fatalf("query string = %q", *seen) } } // The whole reason the ordering is safe: an unreachable or empty instance // passes the turn to Kiwix instead of claiming it with an apology. func TestQuerySearchFallsThroughWhenItFails(t *testing.T) { for _, tc := range []struct { name string body string status int }{ {"http error", "", http.StatusForbidden}, {"no hits", `{"answers":[],"results":[]}`, http.StatusOK}, } { t.Run(tc.name, func(t *testing.T) { h, _ := searchHandler(t, tc.body, tc.status) if _, ok := h.querySearch(context.Background(), &queryTurn{ dec: router.Decision{Intent: router.IntentQuery, Utterance: "почему небо голубое"}, }); ok { t.Fatal("querySearch claimed the turn; Kiwix never got its fallback") } }) } } // Off unless configured, and silent about it: he never asked for a capability // he did not enable. func TestQuerySearchOffWithoutConfig(t *testing.T) { h := &reactiveHandler{} if _, ok := h.querySearch(context.Background(), &queryTurn{ dec: router.Decision{Intent: router.IntentQuery, Utterance: "почему небо голубое"}, }); ok { t.Fatal("querySearch claimed a turn with no search block") } } // The owner's ruling of 2026-08-02: the live search asks first, the ZIM is the // fallback for a box with no line out. func TestSearchRunsBeforeKiwix(t *testing.T) { idx := map[string]int{} for i, s := range querySources { idx[s.name] = i } if idx["search"] > idx["kiwix"] { t.Fatalf("search at %d, kiwix at %d: the ZIM is the fallback, not the first read", idx["search"], idx["kiwix"]) } }