From d7f5c13fde5b4c98c0f294c0a4cdfd0ce3813c4b Mon Sep 17 00:00:00 2001 From: Niko Maroulis Date: Sat, 13 Jun 2026 21:32:32 -0400 Subject: [PATCH 1/2] test: cover search_with_snippets/5 and search_prefix/4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These two public Searcher functions had no direct tests — only their fuzzy variants (search_fuzzy_with_snippets, search_fuzzy_prefix) were covered. Add dedicated test files following the existing per-feature convention: - snippets_test.exs: snippet map shape, highlighting, multiple snippet fields, max_snippet_chars truncation, limit (total_hits reflects the returned/capped count) - prefix_test.exs: prefix matching, typeahead narrowing, limit, no-match Full suite: 245 passed. --- test/muninn/prefix_test.exs | 76 ++++++++++++++++++++++ test/muninn/snippets_test.exs | 115 ++++++++++++++++++++++++++++++++++ 2 files changed, 191 insertions(+) create mode 100644 test/muninn/prefix_test.exs create mode 100644 test/muninn/snippets_test.exs diff --git a/test/muninn/prefix_test.exs b/test/muninn/prefix_test.exs new file mode 100644 index 0000000..12ccb5b --- /dev/null +++ b/test/muninn/prefix_test.exs @@ -0,0 +1,76 @@ +defmodule Muninn.PrefixTest do + use ExUnit.Case, async: true + + alias Muninn.{Index, IndexWriter, IndexReader, Searcher, Schema} + + setup do + test_path = "/tmp/muninn_prefix_#{:erlang.unique_integer([:positive])}" + on_exit(fn -> Muninn.TestHelpers.safe_rm_rf(test_path) end) + {:ok, test_path: test_path} + end + + defp searcher_for(test_path, docs) do + schema = Schema.new() |> Schema.add_text_field("title", stored: true, indexed: true) + {:ok, index} = Index.create(test_path, schema) + Enum.each(docs, &IndexWriter.add_document(index, &1)) + IndexWriter.commit(index) + {:ok, reader} = IndexReader.new(index) + {:ok, searcher} = Searcher.new(reader) + searcher + end + + describe "search_prefix/4" do + test "matches documents whose term starts with the prefix", %{test_path: test_path} do + searcher = + searcher_for(test_path, [ + %{"title" => "Phoenix Framework"}, + %{"title" => "Photography Tips"}, + %{"title" => "Elixir Guide"} + ]) + + {:ok, results} = Searcher.search_prefix(searcher, "title", "pho", limit: 10) + + assert results["total_hits"] == 2 + titles = Enum.map(results["hits"], & &1["doc"]["title"]) + assert "Phoenix Framework" in titles + assert "Photography Tips" in titles + refute "Elixir Guide" in titles + end + + test "narrows results as the prefix grows (typeahead)", %{test_path: test_path} do + searcher = + searcher_for(test_path, [ + %{"title" => "program"}, + %{"title" => "programming"}, + %{"title" => "progress"}, + %{"title" => "project"} + ]) + + {:ok, r1} = Searcher.search_prefix(searcher, "title", "pro", limit: 10) + {:ok, r2} = Searcher.search_prefix(searcher, "title", "progr", limit: 10) + {:ok, r3} = Searcher.search_prefix(searcher, "title", "program", limit: 10) + + assert r1["total_hits"] == 4 + assert r2["total_hits"] == 3 + assert r3["total_hits"] == 2 + end + + test "respects the limit option", %{test_path: test_path} do + docs = for i <- 1..10, do: %{"title" => "testitem#{i}"} + searcher = searcher_for(test_path, docs) + + {:ok, results} = Searcher.search_prefix(searcher, "title", "testitem", limit: 5) + + assert length(results["hits"]) <= 5 + end + + test "returns no hits for a non-matching prefix", %{test_path: test_path} do + searcher = searcher_for(test_path, [%{"title" => "Elixir"}, %{"title" => "Phoenix"}]) + + {:ok, results} = Searcher.search_prefix(searcher, "title", "zzz", limit: 10) + + assert results["total_hits"] == 0 + assert results["hits"] == [] + end + end +end diff --git a/test/muninn/snippets_test.exs b/test/muninn/snippets_test.exs new file mode 100644 index 0000000..0bfb69d --- /dev/null +++ b/test/muninn/snippets_test.exs @@ -0,0 +1,115 @@ +defmodule Muninn.SnippetsTest do + use ExUnit.Case, async: true + + alias Muninn.{Index, IndexWriter, IndexReader, Searcher, Schema} + + setup do + test_path = "/tmp/muninn_snippets_#{:erlang.unique_integer([:positive])}" + on_exit(fn -> Muninn.TestHelpers.safe_rm_rf(test_path) end) + {:ok, test_path: test_path} + end + + defp searcher_for(test_path, schema, docs) do + {:ok, index} = Index.create(test_path, schema) + Enum.each(docs, &IndexWriter.add_document(index, &1)) + IndexWriter.commit(index) + {:ok, reader} = IndexReader.new(index) + {:ok, searcher} = Searcher.new(reader) + searcher + end + + describe "search_with_snippets/5" do + test "returns a snippet map for the requested field", %{test_path: test_path} do + schema = + Schema.new() + |> Schema.add_text_field("title", stored: true, indexed: true) + |> Schema.add_text_field("content", stored: true, indexed: true) + + searcher = + searcher_for(test_path, schema, [ + %{"title" => "Elixir Guide", "content" => "learn elixir programming with this guide"} + ]) + + {:ok, results} = + Searcher.search_with_snippets(searcher, "elixir", ["title", "content"], ["content"]) + + assert results["total_hits"] == 1 + hit = List.first(results["hits"]) + assert is_map(hit["snippets"]) + assert Map.has_key?(hit["snippets"], "content") + assert is_binary(hit["snippets"]["content"]) + end + + test "highlights the matched term with tags", %{test_path: test_path} do + schema = Schema.new() |> Schema.add_text_field("content", stored: true, indexed: true) + + searcher = + searcher_for(test_path, schema, [%{"content" => "learn elixir programming today"}]) + + {:ok, results} = + Searcher.search_with_snippets(searcher, "elixir", ["content"], ["content"]) + + snippet = results["hits"] |> List.first() |> get_in(["snippets", "content"]) + assert snippet =~ "elixir" + end + + test "supports multiple snippet fields", %{test_path: test_path} do + schema = + Schema.new() + |> Schema.add_text_field("title", stored: true, indexed: true) + |> Schema.add_text_field("content", stored: true, indexed: true) + + searcher = + searcher_for(test_path, schema, [ + %{"title" => "elixir basics", "content" => "an elixir tutorial"} + ]) + + {:ok, results} = + Searcher.search_with_snippets( + searcher, + "elixir", + ["title", "content"], + ["title", "content"] + ) + + snippets = results["hits"] |> List.first() |> Map.get("snippets") + assert snippets["title"] =~ "elixir" + assert snippets["content"] =~ "elixir" + end + + test "max_snippet_chars truncates long content", %{test_path: test_path} do + schema = Schema.new() |> Schema.add_text_field("content", stored: true, indexed: true) + + long = + String.duplicate("padding words here ", 40) <> + "elixir " <> String.duplicate("more padding text ", 40) + + searcher = searcher_for(test_path, schema, [%{"content" => long}]) + + {:ok, results} = + Searcher.search_with_snippets(searcher, "elixir", ["content"], ["content"], + max_snippet_chars: 40 + ) + + snippet = results["hits"] |> List.first() |> get_in(["snippets", "content"]) + stripped = String.replace(snippet, ~r{}, "") + + assert snippet =~ "elixir" + assert String.length(stripped) < String.length(long) + end + + test "respects the limit option", %{test_path: test_path} do + schema = Schema.new() |> Schema.add_text_field("content", stored: true, indexed: true) + + docs = for i <- 1..5, do: %{"content" => "elixir document number #{i}"} + searcher = searcher_for(test_path, schema, docs) + + {:ok, results} = + Searcher.search_with_snippets(searcher, "elixir", ["content"], ["content"], limit: 2) + + # total_hits reflects the number of hits actually returned (capped by limit) + assert results["total_hits"] == 2 + assert length(results["hits"]) == 2 + end + end +end From ab960b8de46b3ddd198ef3539c26c35d92a918e4 Mon Sep 17 00:00:00 2001 From: Niko Maroulis Date: Sat, 13 Jun 2026 21:37:07 -0400 Subject: [PATCH 2/2] docs: clarify total_hits is the returned (limit-capped) count total_hits equals length(hits) and is capped by :limit across all search functions (every collector is TopDocs::with_limit). Add a note to search_query/4, search_with_snippets/5, and search_prefix/4 pointing to count/3 for the full match count regardless of limit. --- lib/muninn/searcher.ex | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/lib/muninn/searcher.ex b/lib/muninn/searcher.ex index fa70731..7e3f2ea 100644 --- a/lib/muninn/searcher.ex +++ b/lib/muninn/searcher.ex @@ -114,6 +114,10 @@ defmodule Muninn.Searcher do * `{:ok, results}` - Search results with total_hits and hits * `{:error, reason}` - Search or parse failed + > Note: `total_hits` is the number of hits returned in this response — capped by + > `:limit` and equal to `length(results["hits"])`, not the total number of + > matching documents. Use `count/3` for the full match count regardless of limit. + ## Examples # Search for "elixir" in title and content fields @@ -184,6 +188,10 @@ defmodule Muninn.Searcher do * `{:ok, results}` - Search results with snippets * `{:error, reason}` - Search or parse failed + > Note: `total_hits` is the number of hits returned in this response — capped by + > `:limit` and equal to `length(results["hits"])`, not the total number of + > matching documents. Use `count/3` for the full match count regardless of limit. + Result format includes an additional `"snippets"` map with HTML-highlighted snippets: %{ @@ -275,6 +283,10 @@ defmodule Muninn.Searcher do * `{:ok, results}` - Search results with total_hits and hits * `{:error, reason}` - Search failed + > Note: `total_hits` is the number of hits returned in this response — capped by + > `:limit` and equal to `length(results["hits"])`, not the total number of + > matching documents. Use `count/3` for the full match count regardless of limit. + ## Examples # Search for titles starting with "eli"