"""_widen_cjk_leaf, the rewrite_leaf hook that makes CJK runs matchable in QUERY mode. Unit tests against hand-built trees, no index needed: the hook on its own, then the tree wc.analyze() builds around it. Result-level proof against a real corpus lives in test_cjk_widening.py. """ from __future__ import annotations from typing import TYPE_CHECKING import pytest import whoosh_compat as wc import whoosh_compat.ast as wc_ast from documents.search._query import _DEFAULT_SEARCH_FIELDS from documents.search._query import _FIELD_BOOSTS from documents.search._query import _get_emit_field_registry from documents.search._query import _widen_leaf from documents.search._registry import get_field_registry from documents.tests.search._ast_helpers import BIGRAM_CONTENT from documents.tests.search._ast_helpers import BIGRAM_TITLE from documents.tests.search._ast_helpers import CONTENT from documents.tests.search._ast_helpers import NOTES from documents.tests.search._ast_helpers import TITLE from documents.tests.search._ast_helpers import bigram from documents.tests.search._ast_helpers import content if TYPE_CHECKING: from collections.abc import Callable pytestmark = pytest.mark.search def _widened(original: wc_ast.Node, bigram_side: wc_ast.Node) -> wc_ast.Or: return wc_ast.Or(children=(original, bigram_side)) def _widen_cjk_leaf(leaf: wc_ast.Term | wc_ast.Phrase) -> wc_ast.Node: """The hook as the CJK work used it: no fuzzy side, nothing negated.""" return _widen_leaf(leaf, fuzzy=False, negated=frozenset()) def _analyze(tree: wc_ast.Node) -> wc_ast.Node: return wc.analyze( tree, _get_emit_field_registry(None), rewrite_leaf=_widen_cjk_leaf, ) class TestTheHook: @pytest.mark.parametrize( "leaf", [ pytest.param(content("invoice"), id="latin_term"), pytest.param( wc_ast.Term(field=NOTES, text="東京"), id="non_default_field", ), pytest.param(wc_ast.Term(field=None, text="東京"), id="unfielded"), ], ) def test_leaf_is_returned_unchanged(self, leaf: wc_ast.Term) -> None: """ GIVEN: - A leaf with no CJK text, a CJK term on a field outside the default search fields, or an unfielded CJK term WHEN: - The hook is called with it THEN: - It returns the very same object, which tells analyze() to keep the leaf's ordinary analysis """ assert _widen_cjk_leaf(leaf) is leaf def test_a_leaf_the_tokenizer_drops_entirely_is_returned_unchanged(self) -> None: """ GIVEN: - A term whose text matches _CJK_RE (U+2E80, a CJK Radicals Supplement codepoint, so _has_cjk is True) but which the content analyzer's simple tokenizer yields no token for at all: a few hundred codepoints across the CJK blocks share this gap between the regex and the tokenizer WHEN: - The hook is called with it THEN: - Neither cjk_terms nor latin_terms gains a piece, and the leaf is returned unchanged rather than becoming an empty Or """ leaf = content("⺀") assert _widen_cjk_leaf(leaf) is leaf def test_the_original_leaf_object_is_the_first_alternative(self) -> None: """ GIVEN: - A CJK term on content WHEN: - The hook widens it THEN: - The Or's first child is the leaf object itself, not an equal copy: analyze() only keeps the leaf's own enclosing-group analysis for that exact object """ leaf = content("東京") widened = _widen_cjk_leaf(leaf) assert isinstance(widened, wc_ast.Or) assert widened.children[0] is leaf def test_a_cjk_term_gains_a_bigram_alternative_on_its_own_field(self) -> None: """ GIVEN: - A CJK term on title WHEN: - The hook widens it THEN: - The bigram side targets bigram_title, the leaf's own field's companion, so a fielded term stays fielded """ leaf = wc_ast.Term(field=TITLE, text="東京") assert _widen_cjk_leaf(leaf) == _widened( leaf, wc_ast.Term(field=BIGRAM_TITLE, text="東京"), ) def test_each_cjk_run_gets_its_own_bigram_leaf(self) -> None: """ GIVEN: - One term whose text holds two CJK runs split by an interpunct (東京・大阪) WHEN: - The hook widens it THEN: - The bigram side is Or(bigram 東京, bigram 大阪), one leaf per run. Joining the runs with a space into one leaf would produce bigrams spanning the space, which only exist when the two runs sit side by side in a document. The runs are alternatives, not requirements: a multi-run term matches on any one of its runs, which is what the separate bigram clause did before this change """ leaf = content("東京・大阪") assert _widen_cjk_leaf(leaf) == _widened( leaf, wc_ast.Or(children=(bigram("東京"), bigram("大阪"))), ) def test_each_run_keeps_its_own_bigrams_required(self) -> None: """ GIVEN: - A term whose two runs each produce more than one bigram (東京都・大阪府) WHEN: - The hook widens it and the tree is analyzed THEN: - Each run's bigrams stay And-combined inside the cross-run Or. The Or is between runs only; multitoken=AND on the bigram FieldSpec is what requires a run's own bigrams, and it does not inherit from the enclosing group. Without this, 東京都 would match a document holding only 京都 """ assert _analyze(content("東京都・大阪府")) == wc_ast.Or( children=( wc_ast.And(children=(content("東京都"), content("大阪府"))), wc_ast.And(children=(bigram("東京"), bigram("京都"))), wc_ast.And(children=(bigram("大阪"), bigram("阪府"))), ), ) def test_a_phrase_widens_to_a_bigram_term_not_a_bigram_phrase(self) -> None: """ GIVEN: - A quoted CJK phrase on content WHEN: - The hook widens it THEN: - The bigram side is a plain Term: the bigram analyzer puts every token at position 0, so a positional Phrase against it could never match """ leaf = wc_ast.Phrase(field=CONTENT, text="東京都") assert _widen_cjk_leaf(leaf) == _widened(leaf, bigram("東京都")) def test_a_multi_word_phrase_requires_every_run(self) -> None: """ GIVEN: - A quoted CJK phrase holding two words WHEN: - The hook widens it THEN: - The runs are And-combined, where a Term's runs are Or-combined (test_each_cjk_run_gets_its_own_bigram_leaf). The parser's default group is And, so an Or here would let the quoted phrase match strictly more than the same two words unquoted, and quoting must not widen a search """ leaf = wc_ast.Phrase(field=CONTENT, text="東京都 大阪府") assert _widen_cjk_leaf(leaf) == _widened( leaf, wc_ast.And(children=(bigram("東京都"), bigram("大阪府"))), ) @pytest.mark.parametrize( ("leaf", "alternative"), [ pytest.param( content("東京-report"), wc_ast.And(children=(bigram("東京"), content("report"))), id="separated_term", ), pytest.param( wc_ast.Phrase(field=CONTENT, text="東京 report"), wc_ast.And(children=(bigram("東京"), content("report"))), id="phrase", ), pytest.param(content("東京report"), bigram("東京"), id="glued"), ], ) def test_latin_is_required_only_where_the_analyzer_splits_it_off( self, leaf: wc_ast.Term | wc_ast.Phrase, alternative: wc_ast.Node, ) -> None: """ GIVEN: - A leaf mixing CJK and latin text, either split into separate tokens by the content analyzer (東京-report, "東京 report") or glued into one token (東京report) WHEN: - The hook widens it THEN: - A separate latin token is required on the leaf's own field beside the CJK run's bigrams. Glued latin is not required: the index only holds it inside the whole unspaced token, so requiring it would lose documents the CJK run finds """ assert _widen_cjk_leaf(leaf) == _widened(leaf, alternative) def test_only_latin_pieces_after_a_tokenizer_drop_still_widens(self) -> None: """ GIVEN: - A term combining a tokenizer-dropped CJK codepoint (U+2E80) with a latin word (⺀report): _has_cjk matches on the leading codepoint, but the tokenizer discards it and keeps only the "report" token, so no run ever reaches cjk_terms WHEN: - The hook widens it THEN: - The alternative is the lone latin term itself, not wrapped in And (a single piece collapses to itself), even though the CJK side contributed nothing """ leaf = content("⺀report") assert _widen_cjk_leaf(leaf) == _widened(leaf, content("report")) def test_new_nodes_carry_the_leaf_span(self) -> None: """ GIVEN: - A CJK term with a source span, split into two pieces WHEN: - The hook widens it THEN: - The Or, the alternative and each piece carry the leaf's span, so an emit-time diagnostic still points into the query text """ leaf = wc_ast.Term(field=CONTENT, text="東京・大阪", startchar=3, endchar=8) widened = _widen_cjk_leaf(leaf) assert isinstance(widened, wc_ast.Or) alternative = widened.children[1] assert isinstance(alternative, wc_ast.Or) spans = { (node.startchar, node.endchar) for node in (widened, alternative, *alternative.children) } assert spans == {(3, 8)} class TestAnalyzedTree: @pytest.mark.parametrize( "node", [ pytest.param(wc_ast.Prefix(field=CONTENT, text="東京"), id="cjk_prefix"), pytest.param( wc_ast.Wildcard(field=CONTENT, pattern="東*"), id="cjk_wildcard", ), ], ) def test_patterns_are_never_widened(self, node: wc_ast.Node) -> None: """ GIVEN: - A CJK prefix or wildcard pattern on content WHEN: - The tree is analyzed with the hook THEN: - It comes back unchanged: analyze() only offers Term and Phrase leaves to the hook """ assert _analyze(node) == node @pytest.mark.parametrize( "build", [ pytest.param(lambda node: node, id="bare"), pytest.param(lambda node: wc_ast.Not(child=node), id="negated"), ], ) def test_a_run_past_the_length_limit_keeps_its_bigram_side( self, build: Callable[[wc_ast.Node], wc_ast.Node], long_cjk_run: str, ) -> None: """ GIVEN: - A CJK run longer than the content analyzer's remove_long limit, bare or under NOT WHEN: - The tree is analyzed with the hook THEN: - The content side drops out and the bigram side stands alone, still negated under NOT. A NOT left holding nothing would turn into "match everything" instead """ resolved = _get_emit_field_registry(None).resolve(BIGRAM_CONTENT) assert resolved is not None bigrams = resolved.spec.analyzer(long_cjk_run) assert _analyze(build(content(long_cjk_run))) == build( wc_ast.And( children=tuple(bigram(token) for token in dict.fromkeys(bigrams)), ), ) def test_a_one_character_run_drops_out_of_the_alternative(self) -> None: """ GIVEN: - A term whose runs are 東 (one character) and 大阪 WHEN: - The tree is analyzed with the hook THEN: - Only 大阪 is required on the bigram side: a one-character run has no bigram. An accepted gap, the same on the positive and the negated side """ analyzed = _analyze(content("東・大阪")) assert analyzed == _widened( wc_ast.And(children=(content("東"), content("大阪"))), bigram("大阪"), ) def test_the_title_boost_wraps_the_widened_title_leaf(self) -> None: """ GIVEN: - An unfielded CJK query parsed the way parse_user_query parses it: copied onto every default field, title boosted WHEN: - The tree is analyzed with the hook THEN: - Each field copy is widened on its own bigram field, and the title boost wraps the title copy's whole widened Or, so a title bigram match scores like a title match """ parsed = wc.parse( "東京", registry=get_field_registry(None), default_fields=_DEFAULT_SEARCH_FIELDS, field_boosts=_FIELD_BOOSTS, ).ast analyzed = _analyze(parsed) assert isinstance(analyzed, wc_ast.Or) assert ( wc_ast.Boosted( child=_widened( wc_ast.Term(field=TITLE, text="東京"), wc_ast.Term(field=BIGRAM_TITLE, text="東京"), ), boost=_FIELD_BOOSTS["title"], ) in analyzed.children ) assert {BIGRAM_CONTENT, wc.FieldRef("bigram_tag")} <= { child.field for child in analyzed.children if isinstance(child, wc_ast.Term) } def test_under_and_the_original_side_keeps_and(self) -> None: """ GIVEN: - A term the content analyzer splits into two tokens (東京・大阪), inside an And group WHEN: - The tree is analyzed with the hook THEN: - The original side still ANDs its two tokens. Wrapping the leaf in the hook's Or must not turn that into an Or of them. The cross-run Or flattens into the widening Or, so the alternative is not a single child here and _widened() does not fit; the tree is written out literally """ tree = wc_ast.And(children=(content("東京・大阪"), content("report"))) assert _analyze(tree) == wc_ast.And( children=( wc_ast.Or( children=( wc_ast.And(children=(content("東京"), content("大阪"))), bigram("東京"), bigram("大阪"), ), ), content("report"), ), ) def test_under_or_the_original_side_keeps_or(self) -> None: """ GIVEN: - The same two-token term inside an Or group WHEN: - The tree is analyzed with the hook THEN: - The original side ORs its tokens, as the group says, and the hook's Or flattens into the group """ tree = wc_ast.Or(children=(content("東京・大阪"), content("report"))) assert _analyze(tree) == wc_ast.Or( children=( content("東京"), content("大阪"), bigram("東京"), bigram("大阪"), content("report"), ), ) @pytest.mark.parametrize( "build", [ pytest.param(lambda leaf: wc_ast.Not(child=leaf), id="not"), pytest.param( lambda leaf: wc_ast.AndNot(positive=content("invoice"), negative=leaf), id="andnot", ), pytest.param( lambda leaf: wc_ast.AndMaybe( required=content("invoice"), optional=leaf, ), id="andmaybe", ), pytest.param( lambda leaf: wc_ast.Require( scored=leaf, filter_only=content("invoice"), ), id="require", ), pytest.param( lambda leaf: wc_ast.Boosted(child=leaf, boost=2.0), id="boosted", ), ], ) def test_the_widened_leaf_stays_where_it_was( self, build: Callable[[wc_ast.Node], wc_ast.Node], long_cjk_run: str, ) -> None: """ GIVEN: - A CJK term under Not, AndNot, AndMaybe, Require or Boosted, beside a latin term WHEN: - The tree is analyzed with the hook THEN: - The node keeps its type, its latin operand is untouched, and only the CJK leaf is replaced by its widened Or, in the same position. A negated CJK leaf is widened too, so a negation excludes exactly what the positive search would match """ assert _analyze(build(content("東京"))) == build( _widened(content("東京"), bigram("東京")), )