"""_negated_leaf_ids, the pre-scan that tells the widening hook which leaves sit under a negation. A negated leaf keeps its CJK alternative but gets no fuzzy one: with prefix matching, NOT tax would otherwise exclude "taxi" and "taxonomy", which is not what excluding a word means. The hook cannot see a leaf's context, so the tree is walked once up front and the hook compares by identity. """ from __future__ import annotations import pytest import whoosh_compat.ast as wc_ast from documents.search._query import _negated_leaf_ids from documents.tests.search._ast_helpers import CONTENT from documents.tests.search._ast_helpers import content pytestmark = pytest.mark.search class TestCollection: def test_a_bare_tree_has_no_negated_leaves(self) -> None: """ GIVEN: - A tree with no negation in it WHEN: - It is pre-scanned THEN: - The set is empty """ tree = wc_ast.And(children=(content("invoice"), content("report"))) assert _negated_leaf_ids(tree) == frozenset() def test_a_leaf_under_not_is_collected(self) -> None: """ GIVEN: - A term under a Not WHEN: - The tree is pre-scanned THEN: - That leaf's id is collected, and the positive one is not """ positive = content("invoice") negated = content("secret") tree = wc_ast.And(children=(positive, wc_ast.Not(child=negated))) ids = _negated_leaf_ids(tree) assert id(negated) in ids assert id(positive) not in ids def test_the_negative_side_of_andnot_is_collected(self) -> None: """ GIVEN: - An AndNot, whose positive and negative sides are both terms WHEN: - The tree is pre-scanned THEN: - Only the negative side is collected """ positive = content("invoice") negated = content("secret") tree = wc_ast.AndNot(positive=positive, negative=negated) ids = _negated_leaf_ids(tree) assert id(negated) in ids assert id(positive) not in ids def test_every_leaf_beneath_a_negation_is_collected(self) -> None: """ GIVEN: - A Not whose child is a group of several leaves, one of them a Phrase WHEN: - The tree is pre-scanned THEN: - All of them are collected, at any depth and whatever the leaf type """ a = content("alpha") b = content("beta") phrase = wc_ast.Phrase(field=CONTENT, text="gamma delta") tree = wc_ast.Not( child=wc_ast.Or( children=(a, wc_ast.And(children=(b, phrase))), ), ) assert _negated_leaf_ids(tree) == frozenset({id(a), id(b), id(phrase)}) def test_a_double_negation_stays_collected(self) -> None: """ GIVEN: - A term under two nested Nots WHEN: - The tree is pre-scanned THEN: - It is still collected. The rule is parity-blind on purpose: the cost of over-collecting is a lost widening, while under-collecting would make a negation exclude far more than the user asked """ leaf = content("tax") tree = wc_ast.Not(child=wc_ast.Not(child=leaf)) assert _negated_leaf_ids(tree) == frozenset({id(leaf)}) @pytest.mark.parametrize( "build", [ pytest.param( lambda leaf: wc_ast.Boosted(child=leaf, boost=2.0), id="boosted", ), pytest.param( lambda leaf: wc_ast.Require(scored=leaf, filter_only=leaf), id="require", ), pytest.param( lambda leaf: wc_ast.AndMaybe(required=leaf, optional=leaf), id="andmaybe", ), ], ) def test_containers_that_are_not_negations_collect_nothing( self, build, ) -> None: """ GIVEN: - A leaf inside a container that is not a negation WHEN: - The tree is pre-scanned THEN: - Nothing is collected. Only Not.child and AndNot.negative are negative positions """ leaf = content("invoice") assert _negated_leaf_ids(build(leaf)) == frozenset() def test_a_bare_leaf_is_accepted(self) -> None: """ GIVEN: - A tree that is a single leaf, with no container at all WHEN: - It is pre-scanned THEN: - The set is empty and nothing raises """ assert _negated_leaf_ids(content("invoice")) == frozenset() class TestTotality: @pytest.mark.parametrize( "node", [ pytest.param(wc_ast.Every(), id="every"), pytest.param(wc_ast.Nothing(), id="nothing"), pytest.param( wc_ast.Wildcard(field=CONTENT, pattern="inv*"), id="wildcard", ), pytest.param( wc_ast.Fuzzy(field=CONTENT, text="invoce", distance=1, prefix=True), id="fuzzy", ), ], ) def test_an_unrecognised_node_does_not_raise(self, node: wc_ast.Node) -> None: """ GIVEN: - A node type the walk does not collect from, including one (Fuzzy) that this code will later build itself WHEN: - It is pre-scanned, bare and under a Not THEN: - Nothing raises. This function runs outside emit()'s error conversion, so an exception here is an unconverted 500 """ assert _negated_leaf_ids(node) == frozenset() assert _negated_leaf_ids(wc_ast.Not(child=node)) == frozenset() def test_a_deep_tree_does_not_exhaust_the_stack(self) -> None: """ GIVEN: - A tree nested far deeper than the parser's 200-group cap WHEN: - It is pre-scanned THEN: - It completes. The walk is iterative, so depth costs heap rather than Python stack frames """ leaf = content("invoice") node: wc_ast.Node = leaf for _ in range(5000): node = wc_ast.Not(child=node) assert _negated_leaf_ids(node) == frozenset({id(leaf)})