package cmd import ( "math" "os" "path/filepath" "testing" ) func writeFixture(t *testing.T, content string) string { t.Helper() path := filepath.Join(t.TempDir(), "fixture.json") if err := os.WriteFile(path, []byte(content), 0o644); err != nil { t.Fatal(err) } return path } func TestLoadFixture_Valid(t *testing.T) { path := writeFixture(t, `{ "description": "test", "top": 5, "queries": [ {"id": "q1", "query": "hello", "relevant": [{"document_id": 7}]} ] }`) fx, err := loadFixture(path) if err != nil { t.Fatalf("unexpected error: %v", err) } if fx.Top != 5 || len(fx.Queries) != 1 || fx.Queries[0].Relevant[0].DocumentID != 7 { t.Errorf("fixture parsed incorrectly: %+v", fx) } } func TestLoadFixture_RejectsEmptyRelevant(t *testing.T) { path := writeFixture(t, `{"queries": [{"query": "hello", "relevant": []}]}`) if _, err := loadFixture(path); err == nil { t.Error("expected error for query with no relevant selectors") } } func TestLoadFixture_RejectsMultiFieldSelector(t *testing.T) { path := writeFixture(t, `{"queries": [ {"query": "hello", "relevant": [{"document_id": 1, "source_path": "/x"}]} ]}`) if _, err := loadFixture(path); err == nil { t.Error("expected error for selector with two fields set") } } func TestSelectorMatching(t *testing.T) { doc := benchDoc{DocumentID: 42, Title: "M38T Owner's Manual", SourcePath: "/data/m38t.pdf"} cases := []struct { name string sel benchSelector want bool }{ {"document_id match", benchSelector{DocumentID: 42}, true}, {"document_id miss", benchSelector{DocumentID: 43}, false}, {"source_path match", benchSelector{SourcePath: "/data/m38t.pdf"}, true}, {"source_path miss", benchSelector{SourcePath: "/data/other.pdf"}, false}, {"title_contains case-insensitive", benchSelector{TitleContains: "m38t owner"}, true}, {"title_contains miss", benchSelector{TitleContains: "workshop"}, false}, } for _, tc := range cases { if got := tc.sel.matches(doc); got != tc.want { t.Errorf("%s: got %v, want %v", tc.name, got, tc.want) } } } func TestDedupeByDocument(t *testing.T) { docs := []benchDoc{ {DocumentID: 1, Title: "a"}, {DocumentID: 2, Title: "b"}, {DocumentID: 1, Title: "a-again"}, {DocumentID: 3, Title: "c"}, } out := dedupeByDocument(docs) if len(out) != 3 || out[0].DocumentID != 1 || out[1].DocumentID != 2 || out[2].DocumentID != 3 { t.Errorf("dedupe failed: %+v", out) } if out[0].Title != "a" { t.Errorf("dedupe must keep first (best-ranked) occurrence, got %q", out[0].Title) } } func approxEqual(a, b float64) bool { return math.Abs(a-b) < 1e-9 } func TestScoreQuery_HandComputed(t *testing.T) { // Ranked docs: 10, 20, 30, 40. Relevant: 20 and 40. ranked := []benchDoc{ {DocumentID: 10}, {DocumentID: 20}, {DocumentID: 30}, {DocumentID: 40}, } relevant := []benchSelector{{DocumentID: 20}, {DocumentID: 40}} p, r, m := scoreQuery(ranked, relevant) if !approxEqual(p, 0.5) { // 2 of 4 returned docs are relevant t.Errorf("precision: got %v, want 0.5", p) } if !approxEqual(r, 1.0) { // both relevant docs found t.Errorf("recall: got %v, want 1.0", r) } if !approxEqual(m, 0.5) { // first relevant doc at rank 2 t.Errorf("mrr: got %v, want 0.5", m) } } func TestScoreQuery_NoMatches(t *testing.T) { ranked := []benchDoc{{DocumentID: 1}} relevant := []benchSelector{{DocumentID: 99}} p, r, m := scoreQuery(ranked, relevant) if p != 0 || r != 0 || m != 0 { t.Errorf("expected all-zero metrics, got p=%v r=%v mrr=%v", p, r, m) } } func TestScoreQuery_PartialRecall(t *testing.T) { // Only one of three relevant docs returned, at rank 1. ranked := []benchDoc{{DocumentID: 5}, {DocumentID: 6}} relevant := []benchSelector{{DocumentID: 5}, {DocumentID: 7}, {DocumentID: 8}} p, r, m := scoreQuery(ranked, relevant) if !approxEqual(p, 0.5) { t.Errorf("precision: got %v, want 0.5", p) } if !approxEqual(r, 1.0/3.0) { t.Errorf("recall: got %v, want 1/3", r) } if !approxEqual(m, 1.0) { t.Errorf("mrr: got %v, want 1.0", m) } } func TestScoreQuery_EmptyResults(t *testing.T) { p, r, m := scoreQuery(nil, []benchSelector{{DocumentID: 1}}) if p != 0 || r != 0 || m != 0 { t.Errorf("expected zeros for empty results, got p=%v r=%v mrr=%v", p, r, m) } }