package search
import (
"context"
"path/filepath"
"testing"
"github.com/blevesearch/bleve/v2"
"github.com/blevesearch/bleve/v2/analysis/lang/en"
"github.com/blevesearch/bleve/v2/analysis/lang/ru"
"github.com/blevesearch/bleve/v2/registry"
"github.com/stretchr/testify/require"
"sourcecraft.dev/bigbes/sr-ht-spec/core"
)
// The design leaves "mixed Russian/English search" open, with warren's
// per-index analyzer choice as the starting point. These tests are the evidence
// the decision was made on. They run in three steps:
//
// 1. what one analyzer actually does to the other language's words,
// 2. what that costs a genuinely mixed document under per-document routing,
// 3. that per-line routing removes the cost.
const (
enSpecBody = "The approved revision is the one a bot reads. Every read resolves a git tree\n" +
"and reads blobs from it. Proposals live on their own branch and are merged\n" +
"only after a human approves them.\n"
ruSpecBody = "Одобренная ревизия — это та, которую читают агенты. Каждое чтение разрешает\n" +
"дерево гита и читает из него блобы. Предложения живут в отдельной ветке и\n" +
"объединяются только после проверки человеком.\n"
// A real shape for this corpus: Russian prose around English requirements
// quoted verbatim from the upstream document they came from.
mixedSpecBody = "Этот документ описывает требования к вложениям и двоичным файлам в спецификациях.\n" +
"The requirement is quoted verbatim from upstream: attachments larger than the\n" +
"configured cap are rejected by the daemon before the push is accepted.\n" +
"Ограничение размера вложений обсуждается отдельно и пока не выбрано.\n"
)
func analyze(t *testing.T, analyzer, text string) []string {
t.Helper()
a, err := registry.NewCache().AnalyzerNamed(analyzer)
require.NoError(t, err)
var out []string
for _, tok := range a.Analyze([]byte(text)) {
out = append(out, string(tok.Term))
}
return out
}
// TestOneAnalyzerCannotStemBothLanguages records the premise: bleve's per-index
// analyzer choice does not break the other language, it merely stops stemming
// it. Foreign-script terms survive as literals — which is why warren's
// single-analyzer index was usable — but they survive unstemmed, so singular
// and plural stop being the same word.
func TestOneAnalyzerCannotStemBothLanguages(t *testing.T) {
// Its own language: stemmed, stop words dropped.
require.Equal(t, []string{"index", "rebuild", "document"},
analyze(t, en.AnalyzerName, "indexes rebuild all the documents"))
require.Equal(t, []string{"индекс", "перестраива", "документ"},
analyze(t, ru.AnalyzerName, "индексы перестраивает все документы"))
// The other language: passed through whole, stop words and all.
require.Equal(t, []string{"indexes", "rebuild", "all", "the", "documents"},
analyze(t, ru.AnalyzerName, "indexes rebuild all the documents"))
require.Equal(t, []string{"индексы", "перестраивает", "все", "документы"},
analyze(t, en.AnalyzerName, "индексы перестраивает все документы"))
}
// TestPerDocumentRoutingLosesTheMinorityLanguage is the negative result that
// rules out the obvious reading of the design's open item — "detect the
// dominant language of the document and write the matching field".
//
// The document below is dominantly Russian, so per-document routing files all
// of it, English requirements included, under the Russian analyzer. The Russian
// half then searches correctly and the English half is reachable only by exact
// word form: "attachments" is found, "attachment" is not.
func TestPerDocumentRoutingLosesTheMinorityLanguage(t *testing.T) {
idx, err := bleve.New(filepath.Join(t.TempDir(), "per-document.bleve"), buildMapping())
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, idx.Close()) })
// Exactly what per-document routing produces: one field, chosen by the
// document's dominant language.
lang := DetectIn(mixedSpecBody, DefaultLang)
require.Equal(t, LangRU, lang, "the fixture is dominantly Russian")
require.NoError(t, idx.Index("mixed", map[string]any{
fieldSpace: "~bigbes/specs",
fieldTitle: "Вложения",
fieldBodyRU: mixedSpecBody,
}))
found := func(term, field string) bool {
q := bleve.NewMatchQuery(term)
q.SetField(field)
res, err := idx.Search(bleve.NewSearchRequest(q))
require.NoError(t, err)
return res.Total > 0
}
// The dominant half is fine.
require.True(t, found("вложение", fieldBodyRU), "singular Russian finds the plural in the text")
// The minority half is not: it is in the index, but only as a literal.
require.True(t, found("attachments", fieldBodyRU), "the exact English word form is still there")
require.False(t, found("attachment", fieldBodyRU),
"per-document routing leaves the English half unstemmed: singular misses the plural")
}
// TestPerLineRoutingFindsBothHalvesOfAMixedDocument is the same document
// through the real path. Each line lands in the field whose analyzer stems it,
// and both halves answer stemmed queries.
func TestPerLineRoutingFindsBothHalvesOfAMixedDocument(t *testing.T) {
c := newCorpus(t, "~bigbes/specs", "rev1").
add("specs/storage.md", "---\nid: SPEC-0001\ntitle: Storage model\nstatus: draft\n---\n\n"+enSpecBody).
add("specs/hranenie.md", "---\nid: SPEC-0002\ntitle: Модель хранения\nstatus: draft\n---\n\n"+ruSpecBody).
add("specs/attachments.md", "---\nid: SPEC-0003\ntitle: Вложения и двоичные файлы\nstatus: draft\n---\n\n"+mixedSpecBody)
idx := indexCorpus(t, c)
// Singular English query, plural in the text: only the stemmer bridges it.
require.Equal(t, []string{"SPEC-0003"}, hitIDs(t, idx, Query{Text: "attachment", Spaces: core.EverythingFilter()}),
"the English half of the mixed document is stemmed")
// Singular Russian query, plural in the text.
require.Equal(t, []string{"SPEC-0003"}, hitIDs(t, idx, Query{Text: "вложение", Spaces: core.EverythingFilter()}),
"the Russian half of the mixed document is stemmed")
// The single-language documents are unaffected.
require.Equal(t, []string{"SPEC-0001"}, hitIDs(t, idx, Query{Text: "proposal", Spaces: core.EverythingFilter()}))
require.Equal(t, []string{"SPEC-0002"}, hitIDs(t, idx, Query{Text: "предложение", Spaces: core.EverythingFilter()}))
}
// TestMixedDocumentIsLabelledByItsDominantLanguage checks the label a hit
// carries. The document is routed per line, but it is still one document and
// reports one language — which is what picks the snippet's half.
func TestMixedDocumentIsLabelledByItsDominantLanguage(t *testing.T) {
c := newCorpus(t, "~bigbes/specs", "rev1").
add("specs/storage.md", "---\nid: SPEC-0001\ntitle: Storage model\n---\n\n"+enSpecBody).
add("specs/attachments.md", "---\nid: SPEC-0003\ntitle: Вложения и двоичные файлы\n---\n\n"+mixedSpecBody)
idx := indexCorpus(t, c)
res, err := idx.Search(context.Background(), Query{Text: "attachment", Spaces: core.EverythingFilter()})
require.NoError(t, err)
require.Len(t, res.Hits, 1)
require.Equal(t, LangRU, res.Hits[0].Lang)
require.Contains(t, res.Hits[0].Snippet, "<mark>attachments</mark>",
"the snippet comes from the half that matched, even though it is not the document's language")
res, err = idx.Search(context.Background(), Query{Text: "proposal", Spaces: core.EverythingFilter()})
require.NoError(t, err)
require.Len(t, res.Hits, 1)
require.Equal(t, LangEN, res.Hits[0].Lang)
}
// TestCyrillicTitleIsSearchableAndBoosted checks that titles are routed too: a
// Russian title lands in the Russian title field, so a query naming a document
// returns it above documents that merely mention it.
func TestCyrillicTitleIsSearchableAndBoosted(t *testing.T) {
c := newCorpus(t, "~bigbes/specs", "rev1").
add("specs/hranenie.md", "---\nid: SPEC-0002\ntitle: Модель хранения данных\n---\n\n"+ruSpecBody).
add("specs/mention.md", "---\nid: SPEC-0004\ntitle: Прочее\n---\n\n"+
"Здесь упоминается модель хранения данных, но документ совсем о другом предмете.\n")
idx := indexCorpus(t, c)
require.Equal(t, []string{"SPEC-0002", "SPEC-0004"}, hitIDs(t, idx, Query{Text: "модель хранения", Spaces: core.EverythingFilter()}),
"the document named by the query outranks the one that mentions it")
}