mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-10-21 07:18:13 +00:00
204 lines
6.3 KiB
Python
204 lines
6.3 KiB
Python
"""_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)})
|