mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-08-26 04:33:20 +00:00
test(search): keep one case per integration seam, not one per grammar spelling
whoosh-compat now owns the query grammar and asserts it directly, so the parallel grammar corpus this tree accumulated re-proves the library rather than paperless. Each surviving case was checked by mutation: break the seam it claims (index `added` at day precision, resolve queries in a fixed non-local timezone, tokenize on whitespace instead of separators, index the metadata counters off by one) and the case fails. - delete test_dash_prefix_negation.py: ported wholesale to the library, and the analyzer half it also covered is held by test_documented_syntax.py's documented leading-hyphen case, which the whitespace-tokenizer mutation fails. - delete test_comma_value_lists.py: end to end the value-list and literal readings of a comma agree, because the analyzer splits the literal value on the comma anyway. Declaring comma_values on correspondent, or removing it from tag, left every case in that file passing, so it proved the grammar and not the registry. The registry fact moves to test_registry.py, where the same mutation does fail. - trim the unit-abbreviation, compact-date and reversed-range files to one representative each. - drop the second quoted zero-width keyword from test_documented_syntax.py; which keyword sits inside the quotes is library grammar. Docstrings that justified a file by the absence of library coverage are rewritten: that premise expired when the library absorbed the assertions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
c56f910cc9
commit
cf1cce5f9d
@@ -1,231 +0,0 @@
|
||||
"""Comma-separated value lists, at result level.
|
||||
|
||||
``tag:foo,bar`` is a value list only for fields that opt into
|
||||
``comma_values`` (``tag`` is the only one today). Nothing in the tree runs
|
||||
a ``tag:`` fielded query against real documents; this covers foo-only,
|
||||
bar-only and both-tags documents against a real index, plus a decoy
|
||||
proving a non-comma_values field (``correspondent``) does NOT treat a
|
||||
comma as a list separator -- ``correspondent:foo,bar`` searches for the
|
||||
literal text "foo,bar", so a correspondent literally named that way
|
||||
matches while foo-only/bar-only correspondents do not.
|
||||
|
||||
The list semantics are AND (a document must carry every listed value),
|
||||
not OR: a foo-only or bar-only document does not match ``tag:foo,bar``.
|
||||
|
||||
A comma is only a value-list delimiter when what follows it is a value. A
|
||||
comma directly before another field name separates two clauses instead,
|
||||
on ``comma_values`` and ordinary fields alike -- ``tag:foo,added:2005-03-04``
|
||||
is "tagged foo AND added that day", not "tagged both foo and
|
||||
added:2005-03-04". That reading was asserted before the whoosh-compat
|
||||
migration and nowhere after it, so it is pinned here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
|
||||
from documents.models import Correspondent
|
||||
from documents.models import Document
|
||||
from documents.models import Tag
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from documents.search._backend import TantivyBackend
|
||||
|
||||
pytestmark = [pytest.mark.search, pytest.mark.django_db]
|
||||
|
||||
|
||||
def _matched_ids(backend: TantivyBackend, query: str) -> set[int]:
|
||||
return set(backend.search_ids(query, user=None))
|
||||
|
||||
|
||||
class TestTagCommaValueListIsConjunctive:
|
||||
@pytest.fixture
|
||||
def docs(self, backend: TantivyBackend) -> dict[str, int]:
|
||||
foo = Tag.objects.create(name="foo")
|
||||
bar = Tag.objects.create(name="bar")
|
||||
baz = Tag.objects.create(name="baz")
|
||||
|
||||
foo_only = Document.objects.create(
|
||||
title="Foo only",
|
||||
content="x",
|
||||
checksum="comma-tag-foo-only",
|
||||
)
|
||||
foo_only.tags.set([foo])
|
||||
backend.add_or_update(foo_only)
|
||||
|
||||
bar_only = Document.objects.create(
|
||||
title="Bar only",
|
||||
content="x",
|
||||
checksum="comma-tag-bar-only",
|
||||
)
|
||||
bar_only.tags.set([bar])
|
||||
backend.add_or_update(bar_only)
|
||||
|
||||
both = Document.objects.create(
|
||||
title="Both tags",
|
||||
content="x",
|
||||
checksum="comma-tag-both",
|
||||
)
|
||||
both.tags.set([foo, bar])
|
||||
backend.add_or_update(both)
|
||||
|
||||
neither = Document.objects.create(
|
||||
title="Neither tag",
|
||||
content="x",
|
||||
checksum="comma-tag-neither",
|
||||
)
|
||||
neither.tags.set([baz])
|
||||
backend.add_or_update(neither)
|
||||
|
||||
return {
|
||||
"foo_only": foo_only.pk,
|
||||
"bar_only": bar_only.pk,
|
||||
"both": both.pk,
|
||||
"neither": neither.pk,
|
||||
}
|
||||
|
||||
def test_only_the_document_carrying_both_tags_matches(
|
||||
self,
|
||||
backend: TantivyBackend,
|
||||
docs: dict[str, int],
|
||||
) -> None:
|
||||
assert _matched_ids(backend, "tag:foo,bar") == {docs["both"]}
|
||||
|
||||
def test_foo_only_document_does_not_match(
|
||||
self,
|
||||
backend: TantivyBackend,
|
||||
docs: dict[str, int],
|
||||
) -> None:
|
||||
assert docs["foo_only"] not in _matched_ids(backend, "tag:foo,bar")
|
||||
|
||||
def test_bar_only_document_does_not_match(
|
||||
self,
|
||||
backend: TantivyBackend,
|
||||
docs: dict[str, int],
|
||||
) -> None:
|
||||
assert docs["bar_only"] not in _matched_ids(backend, "tag:foo,bar")
|
||||
|
||||
|
||||
class TestNonCommaValuesFieldTreatsCommaAsLiteralText:
|
||||
def test_correspondent_comma_is_not_a_value_list(
|
||||
self,
|
||||
backend: TantivyBackend,
|
||||
) -> None:
|
||||
literal = Correspondent.objects.create(name="foo,bar")
|
||||
literal_doc = Document.objects.create(
|
||||
title="Literal correspondent",
|
||||
content="x",
|
||||
checksum="comma-correspondent-literal",
|
||||
correspondent=literal,
|
||||
)
|
||||
backend.add_or_update(literal_doc)
|
||||
|
||||
foo_correspondent = Correspondent.objects.create(name="foo")
|
||||
foo_doc = Document.objects.create(
|
||||
title="Foo correspondent",
|
||||
content="x",
|
||||
checksum="comma-correspondent-foo",
|
||||
correspondent=foo_correspondent,
|
||||
)
|
||||
backend.add_or_update(foo_doc)
|
||||
|
||||
bar_correspondent = Correspondent.objects.create(name="bar")
|
||||
bar_doc = Document.objects.create(
|
||||
title="Bar correspondent",
|
||||
content="x",
|
||||
checksum="comma-correspondent-bar",
|
||||
correspondent=bar_correspondent,
|
||||
)
|
||||
backend.add_or_update(bar_doc)
|
||||
|
||||
# A value-list reading would match foo_doc and/or bar_doc (an OR)
|
||||
# or neither (an AND, since no single correspondent carries both
|
||||
# values). Either way it would NOT match literal_doc alone.
|
||||
assert _matched_ids(backend, "correspondent:foo,bar") == {literal_doc.pk}
|
||||
|
||||
|
||||
class TestCommaBeforeAKnownFieldIsAClauseSeparator:
|
||||
"""The discriminating half: the value-list reading and the clause-separator
|
||||
reading disagree about every query below.
|
||||
|
||||
On ``tag`` (the one ``comma_values`` field) a value-list reading of
|
||||
``tag:foo,added:2005-03-04`` demands a tag literally named
|
||||
"added:2005-03-04", so it matches nothing at all. On ``title`` (not
|
||||
``comma_values``) the alternative is the literal reading proven in
|
||||
TestNonCommaValuesFieldTreatsCommaAsLiteralText, i.e. a title containing
|
||||
the text "Alpha,tag:foo", which matches nothing either. Both are exact-set
|
||||
assertions against a corpus that separates the readings, so neither
|
||||
alternative survives.
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
def docs(self, backend: TantivyBackend) -> dict[str, int]:
|
||||
foo = Tag.objects.create(name="foo")
|
||||
bar = Tag.objects.create(name="bar")
|
||||
|
||||
both = Document.objects.create(
|
||||
title="Alpha",
|
||||
content="x",
|
||||
checksum="comma-clause-both",
|
||||
added=datetime(2005, 3, 4, 15, 30, tzinfo=UTC),
|
||||
)
|
||||
both.tags.set([foo])
|
||||
backend.add_or_update(both)
|
||||
|
||||
tag_only = Document.objects.create(
|
||||
title="Alpha",
|
||||
content="x",
|
||||
checksum="comma-clause-tag-only",
|
||||
added=datetime(2010, 7, 1, 15, 30, tzinfo=UTC),
|
||||
)
|
||||
tag_only.tags.set([foo])
|
||||
backend.add_or_update(tag_only)
|
||||
|
||||
date_only = Document.objects.create(
|
||||
title="Beta",
|
||||
content="x",
|
||||
checksum="comma-clause-date-only",
|
||||
added=datetime(2005, 3, 4, 15, 30, tzinfo=UTC),
|
||||
)
|
||||
date_only.tags.set([bar])
|
||||
backend.add_or_update(date_only)
|
||||
|
||||
return {
|
||||
"both": both.pk,
|
||||
"tag_only": tag_only.pk,
|
||||
"date_only": date_only.pk,
|
||||
}
|
||||
|
||||
def test_comma_separates_a_comma_values_field_from_a_date_clause(
|
||||
self,
|
||||
backend: TantivyBackend,
|
||||
docs: dict[str, int],
|
||||
) -> None:
|
||||
assert _matched_ids(backend, "tag:foo,added:2005-03-04") == {docs["both"]}
|
||||
|
||||
def test_comma_separates_an_ordinary_field_from_a_tag_clause(
|
||||
self,
|
||||
backend: TantivyBackend,
|
||||
docs: dict[str, int],
|
||||
) -> None:
|
||||
assert _matched_ids(backend, "title:Alpha,tag:foo") == {
|
||||
docs["both"],
|
||||
docs["tag_only"],
|
||||
}
|
||||
|
||||
def test_each_clause_alone_matches_more_than_the_pair(
|
||||
self,
|
||||
backend: TantivyBackend,
|
||||
docs: dict[str, int],
|
||||
) -> None:
|
||||
"""Both clauses must be doing work: if the separator dropped either
|
||||
side, the pair would match whatever the surviving side matches."""
|
||||
assert _matched_ids(backend, "tag:foo") == {docs["both"], docs["tag_only"]}
|
||||
assert _matched_ids(backend, "added:2005-03-04") == {
|
||||
docs["both"],
|
||||
docs["date_only"],
|
||||
}
|
||||
@@ -1,18 +1,15 @@
|
||||
"""Whoosh's compact, separator-free date spellings: ``20050304`` and
|
||||
``20050304153000``.
|
||||
"""Whoosh's compact, separator-free date spelling, resolved end to end.
|
||||
|
||||
Both were asserted before the whoosh-compat migration (the old
|
||||
``test_8digit_created_date_field_always_uses_utc_midnight`` and
|
||||
``test_14digit_compact_datetime``) and by the deleted ``_translate.py``'s own
|
||||
suite. Afterwards the 8-digit form survived only incidentally, in one
|
||||
pre-existing API test, and the 14-digit form was asserted nowhere -- the
|
||||
regression class where a spelling silently stops matching with nothing to
|
||||
notice.
|
||||
|
||||
The two forms differ in width, not just in length: 8 digits is a calendar-day
|
||||
window, 14 digits a single instant. The corpus separates them, so a form that
|
||||
degrades into the other one -- or into a non-match -- fails rather than passing
|
||||
on the one document that would match either way.
|
||||
whoosh-compat owns both widths of this spelling and asserts them directly
|
||||
(``test_compact_numeric_datetime`` for the 8-digit calendar-day form and
|
||||
``test_compact_numeric_datetime_full_width_is_a_single_second_instant`` for
|
||||
the 14-digit instant). The 14-digit form is kept here as the single
|
||||
representative because it is the one that exercises paperless's ``added``
|
||||
DATETIME fast field at full precision: the corpus separates a document at
|
||||
the named instant from one on the same calendar day at another hour and one
|
||||
on the next day at the same hour, so a query that degrades into a whole-day
|
||||
window, or drops the time of day, matches the wrong set rather than passing
|
||||
on a corpus that could not tell the difference.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -68,40 +65,10 @@ def docs(backend: TantivyBackend) -> dict[str, int]:
|
||||
}
|
||||
|
||||
|
||||
class TestCompactDateForms:
|
||||
def test_eight_digits_is_a_calendar_day_window(
|
||||
self,
|
||||
backend: TantivyBackend,
|
||||
docs: dict[str, int],
|
||||
) -> None:
|
||||
assert _matched_ids(backend, "added:20050304") == {
|
||||
docs["instant"],
|
||||
docs["same_day"],
|
||||
}
|
||||
|
||||
def test_eight_digits_agrees_with_the_hyphenated_spelling(
|
||||
self,
|
||||
backend: TantivyBackend,
|
||||
docs: dict[str, int],
|
||||
) -> None:
|
||||
assert _matched_ids(backend, "added:20050304") == _matched_ids(
|
||||
backend,
|
||||
"added:2005-03-04",
|
||||
)
|
||||
|
||||
def test_fourteen_digits_is_a_single_instant(
|
||||
self,
|
||||
backend: TantivyBackend,
|
||||
docs: dict[str, int],
|
||||
) -> None:
|
||||
# same_day is what tells this apart from the 8-digit form, next_day
|
||||
# from a form that ignored the time altogether.
|
||||
assert _matched_ids(backend, "added:20050304153000") == {docs["instant"]}
|
||||
|
||||
def test_fourteen_digits_addresses_the_hour_it_names(
|
||||
self,
|
||||
backend: TantivyBackend,
|
||||
docs: dict[str, int],
|
||||
) -> None:
|
||||
assert _matched_ids(backend, "added:20050304090000") == {docs["same_day"]}
|
||||
assert _matched_ids(backend, "added:20050305153000") == {docs["next_day"]}
|
||||
def test_fourteen_digits_is_a_single_instant(
|
||||
backend: TantivyBackend,
|
||||
docs: dict[str, int],
|
||||
) -> None:
|
||||
# same_day is what tells this apart from the 8-digit day-window form,
|
||||
# next_day from a form that ignored the time altogether.
|
||||
assert _matched_ids(backend, "added:20050304153000") == {docs["instant"]}
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
"""Pins the current, deferred-negation behavior of a leading ``-``.
|
||||
|
||||
Negation via a bare ``-`` prefix (as opposed to the ``NOT`` keyword) is
|
||||
deferred to the companion plan's G1 item. Until G1 lands, a leading ``-``
|
||||
is not negation at all:
|
||||
|
||||
- Unfielded (``-taxes``): the separator is stripped at index time, so the
|
||||
term becomes an ordinary, *required* positive match on ``taxes`` -- the
|
||||
exact inverse of what a user typing ``-taxes`` to exclude a term intends.
|
||||
- Fielded (``-title:alpha``): the leading hyphen detaches from the field
|
||||
clause entirely (the parse tree emits it as its own token), and the
|
||||
field clause itself is left as an ordinary positive match. The
|
||||
negation is dropped, not inverted.
|
||||
|
||||
If G1 changes either of these, the corresponding test below inverts --
|
||||
that inversion is the intended, visible signal that G1 landed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
|
||||
from documents.models import Document
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from documents.search._backend import TantivyBackend
|
||||
|
||||
pytestmark = [pytest.mark.search, pytest.mark.django_db]
|
||||
|
||||
|
||||
def _matched_ids(backend: TantivyBackend, query: str) -> set[int]:
|
||||
return set(backend.search_ids(query, user=None))
|
||||
|
||||
|
||||
def _index(backend: TantivyBackend, **kwargs: object) -> Document:
|
||||
doc = Document.objects.create(**kwargs)
|
||||
backend.add_or_update(doc)
|
||||
return doc
|
||||
|
||||
|
||||
class TestUnfieldedDashBecomesARequiredTerm:
|
||||
@pytest.fixture
|
||||
def docs(self, backend: TantivyBackend) -> dict[str, int]:
|
||||
return {
|
||||
"with_taxes": _index(
|
||||
backend,
|
||||
title="Invoice one",
|
||||
content="invoice taxes included",
|
||||
checksum="dash-unfielded-with",
|
||||
).pk,
|
||||
"without_taxes": _index(
|
||||
backend,
|
||||
title="Invoice two",
|
||||
content="invoice ordinary contents",
|
||||
checksum="dash-unfielded-without",
|
||||
).pk,
|
||||
}
|
||||
|
||||
def test_dash_prefixed_term_matches_only_the_document_containing_it(
|
||||
self,
|
||||
backend: TantivyBackend,
|
||||
docs: dict[str, int],
|
||||
) -> None:
|
||||
# If this were real negation, it would match "without_taxes" (the
|
||||
# document that does NOT contain "taxes"). It instead matches
|
||||
# "with_taxes" -- the document that DOES.
|
||||
assert _matched_ids(backend, "invoice -taxes") == {docs["with_taxes"]}
|
||||
|
||||
def test_bare_dash_prefixed_term_alone_is_a_positive_search(
|
||||
self,
|
||||
backend: TantivyBackend,
|
||||
docs: dict[str, int],
|
||||
) -> None:
|
||||
assert _matched_ids(backend, "-taxes") == {docs["with_taxes"]}
|
||||
|
||||
|
||||
class TestFieldedDashDropsTheNegation:
|
||||
@pytest.fixture
|
||||
def docs(self, backend: TantivyBackend) -> dict[str, int]:
|
||||
return {
|
||||
"alpha": _index(
|
||||
backend,
|
||||
title="Alpha Title",
|
||||
content="alpha body",
|
||||
checksum="dash-fielded-alpha",
|
||||
).pk,
|
||||
"beta": _index(
|
||||
backend,
|
||||
title="Beta Title",
|
||||
content="beta body",
|
||||
checksum="dash-fielded-beta",
|
||||
).pk,
|
||||
}
|
||||
|
||||
def test_dash_fielded_term_matches_the_field_value_it_names(
|
||||
self,
|
||||
backend: TantivyBackend,
|
||||
docs: dict[str, int],
|
||||
) -> None:
|
||||
# Real negation would exclude the "beta" document. Dropped
|
||||
# negation instead matches it, identically to "title:beta".
|
||||
assert _matched_ids(backend, "-title:beta") == {docs["beta"]}
|
||||
assert _matched_ids(backend, "title:beta") == _matched_ids(
|
||||
backend,
|
||||
"-title:beta",
|
||||
)
|
||||
|
||||
def test_dash_fielded_term_conjoined_still_requires_both_sides(
|
||||
self,
|
||||
backend: TantivyBackend,
|
||||
docs: dict[str, int],
|
||||
) -> None:
|
||||
# Discriminating shape: if the dash-fielded clause were dropped
|
||||
# from the query entirely (rather than kept as a positive
|
||||
# requirement), this would match "alpha" alone. Because the
|
||||
# dropped negation still leaves an ordinary AND-ed requirement
|
||||
# behind, and no document has both titles, nothing matches.
|
||||
assert _matched_ids(backend, "title:alpha -title:beta") == set()
|
||||
@@ -267,8 +267,9 @@ class TestDocumentedDateForms:
|
||||
# Quoting is what rescues the other multi-word date expressions,
|
||||
# so pin that it does not rescue these: the problem is the width
|
||||
# of the resulting range, not the way the value is delimited.
|
||||
# One quoted spelling is enough for that; which keyword sits
|
||||
# inside the quotes is grammar whoosh-compat owns.
|
||||
'added:"now"',
|
||||
'added:"midnight"',
|
||||
# A relative offset, which the warning in the docs names by this
|
||||
# exact spelling. Standing alone it is an instant like the rest of
|
||||
# this list; the same offset used as a range bound is a real
|
||||
|
||||
@@ -88,6 +88,13 @@ class TestFieldRegistry:
|
||||
def test_tag_is_comma_values(self, registry: FieldRegistry) -> None:
|
||||
assert _resolve(registry, "tag").spec.comma_values is True
|
||||
|
||||
def test_correspondent_is_not_comma_values(self, registry: FieldRegistry) -> None:
|
||||
# "tag" is the only field that opts in. This is only observable here:
|
||||
# end to end the two readings of "correspondent:foo,bar" agree,
|
||||
# because the analyzer splits the literal value on the comma anyway,
|
||||
# so a result-level test cannot tell a value list from literal text.
|
||||
assert _resolve(registry, "correspondent").spec.comma_values is False
|
||||
|
||||
def test_created_is_date_kind(self, registry: FieldRegistry) -> None:
|
||||
resolved = _resolve(registry, "created")
|
||||
assert resolved.spec.kind is FieldKind.DATE
|
||||
|
||||
@@ -1,23 +1,19 @@
|
||||
"""Reversed date ranges swap their bounds back into order.
|
||||
"""A reversed relative range, resolved end to end against the index.
|
||||
|
||||
Measured end to end (FROZEN_NOW = 2026-06-15T12:00:00Z):
|
||||
whoosh-compat owns the bound swap itself and asserts it directly, for both
|
||||
the absolute and the relative spelling
|
||||
(``test_reversed_relative_range_swaps_like_the_absolute_case`` in
|
||||
``tests/test_parser_dates.py``, DIVERGENCES.md entry 53). Paperless does not
|
||||
rewrite reversed ranges anywhere; ``documents/search/_query.py`` passes the
|
||||
query through untouched.
|
||||
|
||||
added:[now-1h to now+1h] -> 2h window, correct order
|
||||
added:[now+1h to now-1h] -> the same 2h window, SWAPPED
|
||||
added:[2020-01-01 to 2019-01-01] -> 366 days, SWAPPED to the forward order
|
||||
added:[2019-01-01 to 2020-01-01] -> 366 days (same result either way)
|
||||
|
||||
Both range kinds behave the same way, matching whoosh's own behavior.
|
||||
Relative (``now±``) reversed ranges used to differ: whoosh-compat's date
|
||||
grammar added a day to the upper bound instead of swapping, producing a
|
||||
much wider window than either the forward or a swapped reading gives. That
|
||||
was a library-level inconsistency between the two range kinds, not an
|
||||
application-level rewrite paperless performs (there is still no
|
||||
reversed-range handling in documents/search/_query.py), and it was fixed in
|
||||
whoosh-compat's date grammar rather than in a pre-parse rewrite on this
|
||||
side (see also the CJK/pre-parse-rewrites docstrings elsewhere in this
|
||||
suite for the same discipline). Both cases are pinned below so a future
|
||||
divergence between them is visible again.
|
||||
What is kept here is the relative spelling only, because it is the one whose
|
||||
bounds depend on paperless's own plumbing: ``now±1h`` is resolved against the
|
||||
timezone paperless hands the parser, so the emitted window is paperless's
|
||||
result rather than the library's alone. The corpus separates a document
|
||||
inside the two-hour window from one that is outside it but inside the wider
|
||||
window a non-swapping reading produces, so a bound that resolves in the wrong
|
||||
timezone, or a swap that does not happen, matches the wrong set.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -49,91 +45,37 @@ def _index(backend: TantivyBackend, **kwargs: object) -> Document:
|
||||
return doc
|
||||
|
||||
|
||||
class TestRelativeReversedRangeSwaps:
|
||||
@pytest.fixture
|
||||
def docs(self, backend: TantivyBackend) -> dict[str, int]:
|
||||
with time_machine.travel(FROZEN_NOW, tick=False):
|
||||
return {
|
||||
# Inside the 2h window [11:00, 13:00] that both the forward
|
||||
# and the reversed spelling resolve to.
|
||||
"in_forward_window": _index(
|
||||
backend,
|
||||
title="Forward window doc",
|
||||
content="x",
|
||||
checksum="reversed-relative-forward",
|
||||
added=FROZEN_NOW,
|
||||
).pk,
|
||||
# Outside that window, but inside the wider window the
|
||||
# reversed spelling produced when it day-bumped instead of
|
||||
# swapping. It must not match either query now.
|
||||
"outside_the_window": _index(
|
||||
backend,
|
||||
title="Later same-week doc",
|
||||
content="x",
|
||||
checksum="reversed-relative-outside",
|
||||
added=datetime(2026, 6, 16, 8, 0, tzinfo=UTC),
|
||||
).pk,
|
||||
}
|
||||
|
||||
def test_forward_range_matches_only_the_two_hour_window(
|
||||
self,
|
||||
backend: TantivyBackend,
|
||||
docs: dict[str, int],
|
||||
) -> None:
|
||||
with time_machine.travel(FROZEN_NOW, tick=False):
|
||||
assert _matched_ids(backend, "added:[now-1h to now+1h]") == {
|
||||
docs["in_forward_window"],
|
||||
}
|
||||
|
||||
def test_reversed_range_swaps_to_the_same_window(
|
||||
self,
|
||||
backend: TantivyBackend,
|
||||
docs: dict[str, int],
|
||||
) -> None:
|
||||
# The same set as the forward query, and in particular not the
|
||||
# document that only a day-bumped upper bound would have reached.
|
||||
with time_machine.travel(FROZEN_NOW, tick=False):
|
||||
assert _matched_ids(backend, "added:[now+1h to now-1h]") == {
|
||||
docs["in_forward_window"],
|
||||
}
|
||||
|
||||
|
||||
class TestAbsoluteReversedRangeSwaps:
|
||||
@pytest.fixture
|
||||
def docs(self, backend: TantivyBackend) -> dict[str, int]:
|
||||
@pytest.fixture
|
||||
def docs(backend: TantivyBackend) -> dict[str, int]:
|
||||
with time_machine.travel(FROZEN_NOW, tick=False):
|
||||
return {
|
||||
"in_range": _index(
|
||||
# Inside the 2h window [11:00, 13:00] that both the forward and
|
||||
# the reversed spelling resolve to.
|
||||
"in_window": _index(
|
||||
backend,
|
||||
title="In-range doc",
|
||||
title="Forward window doc",
|
||||
content="x",
|
||||
checksum="reversed-absolute-in-range",
|
||||
added=datetime(2019, 6, 1, tzinfo=UTC),
|
||||
checksum="reversed-relative-forward",
|
||||
added=FROZEN_NOW,
|
||||
).pk,
|
||||
"out_of_range": _index(
|
||||
# Outside that window, but inside the wider window a reversed
|
||||
# range that day-bumped its upper bound instead of swapping
|
||||
# would reach.
|
||||
"outside_the_window": _index(
|
||||
backend,
|
||||
title="Out-of-range doc",
|
||||
title="Later same-week doc",
|
||||
content="x",
|
||||
checksum="reversed-absolute-out-of-range",
|
||||
added=datetime(2018, 6, 1, tzinfo=UTC),
|
||||
checksum="reversed-relative-outside",
|
||||
added=datetime(2026, 6, 16, 8, 0, tzinfo=UTC),
|
||||
).pk,
|
||||
}
|
||||
|
||||
def test_forward_range_matches_the_in_range_document(
|
||||
self,
|
||||
backend: TantivyBackend,
|
||||
docs: dict[str, int],
|
||||
) -> None:
|
||||
assert _matched_ids(
|
||||
backend,
|
||||
"added:[2019-01-01 to 2020-01-01]",
|
||||
) == {docs["in_range"]}
|
||||
|
||||
def test_reversed_range_swaps_to_match_the_same_document(
|
||||
self,
|
||||
backend: TantivyBackend,
|
||||
docs: dict[str, int],
|
||||
) -> None:
|
||||
assert _matched_ids(
|
||||
backend,
|
||||
"added:[2020-01-01 to 2019-01-01]",
|
||||
) == {docs["in_range"]}
|
||||
def test_reversed_relative_range_matches_the_two_hour_window(
|
||||
backend: TantivyBackend,
|
||||
docs: dict[str, int],
|
||||
) -> None:
|
||||
with time_machine.travel(FROZEN_NOW, tick=False):
|
||||
assert _matched_ids(backend, "added:[now+1h to now-1h]") == {
|
||||
docs["in_window"],
|
||||
}
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
"""Whoosh-style relative date unit abbreviations: yrs/mos/wks/hrs/mins/secs.
|
||||
"""One relative unit abbreviation, resolved end to end against the index.
|
||||
|
||||
Zero assertions existed anywhere in this tree before this file -- the old
|
||||
v2-era ``TestWhooshUnitAbbreviations`` is gone, and whoosh-compat's own
|
||||
test corpus only carries these spellings as allowlisted (assertion-
|
||||
inverted) lines, i.e. lines it knows do not fully match its own grammar's
|
||||
documented behavior yet accepts anyway. This is the exact class of
|
||||
regression #13482 was: a spelling silently stops parsing (or silently
|
||||
stops matching) with nothing anywhere to notice.
|
||||
whoosh-compat owns which unit words exist and what offset each resolves to,
|
||||
and asserts all six (yrs/mos/wks/hrs/mins/secs, quoted and bare) directly in
|
||||
``tests/test_relative_date_unit_abbreviations.py``. Repeating that matrix here
|
||||
would only re-prove the library's grammar; "hrs" is kept as a single
|
||||
representative so that the paperless-side path the library cannot see -- the
|
||||
``added`` DATETIME fast field, the timezone the query is resolved in and the
|
||||
range the backend emits -- is still exercised by an abbreviation-spelled
|
||||
offset rather than only by absolute dates.
|
||||
|
||||
Each abbreviation produces a relative, zero-width instant
|
||||
(``now - N<unit>`` .. same instant), not a span -- pinned separately in
|
||||
the date-keyword tests. What matters here is that each of the six
|
||||
spellings resolves to the *correct* instant, checked by indexing one
|
||||
document at exactly that instant per unit: a wrong offset (an "hrs" typo
|
||||
that resolves as minutes, say) lands on nothing, or on a sibling
|
||||
document's instant, rather than passing by accident.
|
||||
The offset is sub-day on purpose: a whole-day or whole-year offset survives a
|
||||
wrong timezone, five hours does not. The decoy document sits at a different
|
||||
offset from the same instant, so an offset that resolves to the wrong width
|
||||
lands on nothing or on the decoy instead of passing by accident.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -48,32 +46,11 @@ def _index(backend: TantivyBackend, **kwargs: object) -> Document:
|
||||
|
||||
@pytest.fixture
|
||||
def unit_documents(backend: TantivyBackend) -> dict[str, int]:
|
||||
"""One document at the exact instant each abbreviation should resolve
|
||||
to, all indexed together so a wrong offset lands on the wrong (or no)
|
||||
document rather than passing coincidentally."""
|
||||
"""One document five hours before the frozen instant, one ten minutes
|
||||
before it, both indexed together so a mis-resolved offset lands on the
|
||||
wrong document or on none rather than passing coincidentally."""
|
||||
with time_machine.travel(FROZEN_NOW, tick=False):
|
||||
return {
|
||||
"yrs": _index(
|
||||
backend,
|
||||
title="Years doc",
|
||||
content="x",
|
||||
checksum="unit-abbrev-yrs",
|
||||
added=datetime(2024, 6, 15, 12, 0, tzinfo=UTC),
|
||||
).pk,
|
||||
"mos": _index(
|
||||
backend,
|
||||
title="Months doc",
|
||||
content="x",
|
||||
checksum="unit-abbrev-mos",
|
||||
added=datetime(2026, 3, 15, 12, 0, tzinfo=UTC),
|
||||
).pk,
|
||||
"wks": _index(
|
||||
backend,
|
||||
title="Weeks doc",
|
||||
content="x",
|
||||
checksum="unit-abbrev-wks",
|
||||
added=datetime(2026, 6, 1, 12, 0, tzinfo=UTC),
|
||||
).pk,
|
||||
"hrs": _index(
|
||||
backend,
|
||||
title="Hours doc",
|
||||
@@ -88,55 +65,12 @@ def unit_documents(backend: TantivyBackend) -> dict[str, int]:
|
||||
checksum="unit-abbrev-mins",
|
||||
added=datetime(2026, 6, 15, 11, 50, tzinfo=UTC),
|
||||
).pk,
|
||||
"secs": _index(
|
||||
backend,
|
||||
title="Seconds doc",
|
||||
content="x",
|
||||
checksum="unit-abbrev-secs",
|
||||
added=datetime(2026, 6, 15, 11, 59, 30, tzinfo=UTC),
|
||||
).pk,
|
||||
}
|
||||
|
||||
|
||||
class TestUnitAbbreviationsResolveToTheCorrectInstant:
|
||||
@pytest.mark.parametrize(
|
||||
("query", "label"),
|
||||
[
|
||||
pytest.param('added:"-2yrs"', "yrs", id="years"),
|
||||
pytest.param('added:"-3mos"', "mos", id="months"),
|
||||
pytest.param('added:"-2wks"', "wks", id="weeks"),
|
||||
pytest.param('added:"-5hrs"', "hrs", id="hours"),
|
||||
pytest.param('added:"-10mins"', "mins", id="minutes"),
|
||||
pytest.param('added:"-30secs"', "secs", id="seconds"),
|
||||
],
|
||||
)
|
||||
def test_quoted_abbreviation_matches_only_its_own_instant(
|
||||
self,
|
||||
backend: TantivyBackend,
|
||||
unit_documents: dict[str, int],
|
||||
query: str,
|
||||
label: str,
|
||||
) -> None:
|
||||
with time_machine.travel(FROZEN_NOW, tick=False):
|
||||
assert _matched_ids(backend, query) == {unit_documents[label]}
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("query", "label"),
|
||||
[
|
||||
pytest.param("added:-2yrs", "yrs", id="years"),
|
||||
pytest.param("added:-3mos", "mos", id="months"),
|
||||
pytest.param("added:-2wks", "wks", id="weeks"),
|
||||
pytest.param("added:-5hrs", "hrs", id="hours"),
|
||||
pytest.param("added:-10mins", "mins", id="minutes"),
|
||||
pytest.param("added:-30secs", "secs", id="seconds"),
|
||||
],
|
||||
)
|
||||
def test_bare_unquoted_abbreviation_matches_only_its_own_instant(
|
||||
self,
|
||||
backend: TantivyBackend,
|
||||
unit_documents: dict[str, int],
|
||||
query: str,
|
||||
label: str,
|
||||
) -> None:
|
||||
with time_machine.travel(FROZEN_NOW, tick=False):
|
||||
assert _matched_ids(backend, query) == {unit_documents[label]}
|
||||
def test_hours_abbreviation_matches_only_its_own_instant(
|
||||
backend: TantivyBackend,
|
||||
unit_documents: dict[str, int],
|
||||
) -> None:
|
||||
with time_machine.travel(FROZEN_NOW, tick=False):
|
||||
assert _matched_ids(backend, 'added:"-5hrs"') == {unit_documents["hrs"]}
|
||||
|
||||
Reference in New Issue
Block a user