Files
shelfmark/tests/irc/test_search_query.py
T
CaliBrain 1b17fe179a fix(irc): rank a surname-only result as partial, not wrong (#1332) (#1334)
"David Petrie" as "D. Petrie", then ranked the answer by the full name
to
recover the precision the surname gave up. The two halves disagreed.

author_affinity needs two agreeing tokens before it calls a name the
same
person, so "Petrie" - the name on the filenames a surname search exists
to
reach - matched one and came back AUTHOR_MISMATCH. It therefore sorted
below
"Unknown" and level with "Gordon Petrie", a different author who merely
shares
the surname. The widened query pulled those rows in and the ranker
buried them.

Falling short of agreement is now separated from disagreeing with it. A
name
whose every token fits the one asked for is an abbreviation of it and
ranks
AUTHOR_PARTIAL, between agreement and "no author reported"; a name
carrying a
token that fits nothing still ranks AUTHOR_MISMATCH. Nothing that agreed
before
changes tier - "Homer"/"Homer Simpson" is still a match, since the extra
token
must not demote a mononym that already met its one-token requirement -
so
Prowlarr's #1293 ordering is unchanged except that a tracker listing a
bare
surname stops being read as the wrong author.

Measured on the issue's own case, wanted "David Petrie":

  before: D Petrie, Unknown, Petrie, Gordon Petrie
  after:  D Petrie, Petrie, Unknown, Gordon Petrie

Second fix, same release: a book with no title posted the surname on its
own.
_build_query fell back to book.search_title or book.title, which is
empty on
exactly the path where the plan has no title variants, so the line
reaching the
channel was "@search Petrie" - not a search for anything, and the kind
of bare
over-broad post is_available refuses unaddressed queries to avoid. It
now
returns "" and the existing "No search query could be built" guard takes
it.

Tested with make python-lint, python-format, python-dead-code,
python-typecheck
and python-test.
2026-09-11 22:26:28 -04:00

221 lines
8.4 KiB
Python

"""What the IRC source posts to the channel, and how it orders the answer.
A search bot ANDs every term against a filename, so a given name the channel
spells differently returns nothing at all. Measured against irchighway's
#ebooks: "Revelations David Petrie" was answered "no results", while
"Revelations Petrie" returned 9 matches.
"""
import pytest
from shelfmark.core.author_match import search_surname
from shelfmark.core.search_plan import build_release_search_plan
from shelfmark.metadata_providers import BookMetadata
from shelfmark.release_sources import Release
from shelfmark.release_sources.irc.source import IRCReleaseSource
def _release(author, server="OnlineBot"):
return Release(
source="irc",
source_id=f"line-{author}-{server}",
title="Revelations",
extra={"author": author, "server": server},
)
class TestSearchSurname:
@pytest.mark.parametrize(
("author", "expected"),
[
# The bug: the channel abbreviates the given name, so only the surname
# is common to both spellings.
("David Petrie", "Petrie"),
("D. Petrie", "Petrie"),
("Kurt Vonnegut Jr.", "Vonnegut"),
("Homer", "Homer"),
("", ""),
],
)
def test_trims_to_the_token_both_spellings_share(self, author, expected):
assert search_surname(author) == expected
def test_a_particle_is_dropped_with_the_given_names(self):
# "Le Guin" loses its particle, which costs precision, not matches: the
# remaining token is still a substring of the filename that the full name
# would have matched, and _rank_by_author restores the precision.
assert search_surname("Ursula K. Le Guin") == "Guin"
class TestQueryPostedToChannel:
def test_query_carries_the_surname_not_the_given_name(self):
# Through the real plan builder, so the query cannot drift from what
# build_release_search_plan actually produces.
book = BookMetadata(
provider="hardcover", provider_id="1", title="Revelations", authors=["David Petrie"]
)
query = IRCReleaseSource()._build_query(book, build_release_search_plan(book))
assert query == "Revelations Petrie"
def test_manual_query_is_posted_verbatim(self):
book = BookMetadata(
provider="hardcover", provider_id="1", title="Revelations", authors=["David Petrie"]
)
plan = build_release_search_plan(book, manual_query="revelations petrie epub")
query = IRCReleaseSource()._build_query(book, plan)
assert query == "revelations petrie epub"
def test_an_isbn_fallback_query_stays_authorless(self):
# The ISBN variant carries author="" on purpose; appending a surname would
# make an already-narrow query require the author in the filename too.
book = BookMetadata(
provider="hardcover",
provider_id="1",
title="",
isbn_13="9780306406157",
authors=["David Petrie"],
)
query = IRCReleaseSource()._build_query(book, build_release_search_plan(book))
assert query == "9780306406157"
def test_title_alone_when_no_author_is_known(self):
book = BookMetadata(provider="hardcover", provider_id="1", title="Beowulf")
assert IRCReleaseSource()._build_query(book, build_release_search_plan(book)) == "Beowulf"
def test_a_book_with_no_title_is_not_searched_by_surname_alone(self):
# "@search Petrie" asks the bot for every Petrie it holds. It is not a
# search for anything, and a bare over-broad line posted to a public
# channel is what gets the nick banned - so no query is built at all.
book = BookMetadata(provider="hardcover", provider_id="1", title="", authors=["D. Petrie"])
plan = build_release_search_plan(book)
assert plan.title_variants == []
assert plan.author == "D. Petrie"
assert IRCReleaseSource()._build_query(book, plan) == ""
class TestAuthorOrdersTheAnswer:
def test_requested_author_leads_and_the_rest_stay_visible(self):
source = IRCReleaseSource()
source._online_servers = {"OnlineBot"}
releases = [
_release("Gordon Petrie"),
# What the parser writes when a filename has no " - " separator; it
# must not sort below a result that named a different author.
_release("Unknown"),
# The shape the surname query exists to reach: the channel filed this
# under the surname alone, so it agrees with everything known about
# "David Petrie" and contradicts none of it.
_release("Petrie"),
_release("D Petrie"),
]
ranked = source._rank_by_author(releases, "David Petrie")
assert [release.extra["author"] for release in ranked] == [
"D Petrie",
"Petrie",
"Unknown",
"Gordon Petrie",
]
def test_an_offline_server_does_not_outrank_one_that_can_answer(self):
# Downloading addresses one named bot and waits 120s for it, so a matching
# author on a bot that left the channel is worse than a mismatch that is there.
source = IRCReleaseSource()
source._online_servers = {"OnlineBot"}
releases = [
_release("Gordon Petrie", server="OnlineBot"),
_release("D Petrie", server="DeadBot"),
]
ranked = source._rank_by_author(releases, "David Petrie")
assert [release.extra["server"] for release in ranked] == ["OnlineBot", "DeadBot"]
def test_no_requested_author_leaves_the_order_the_bot_sent(self):
source = IRCReleaseSource()
releases = [_release("Gordon Petrie"), _release("D Petrie")]
assert source._rank_by_author(releases, "") == releases
def test_search_posts_the_surname_and_ranks_the_cached_answer(monkeypatch):
"""End to end: the line reaching the channel, and ordering on the cached path.
The cached path matters because one cache entry is keyed by query alone, so it
is shared by every book that produced that query - the order has to follow the
author being asked for now, not the one that filled the cache.
"""
import shelfmark.release_sources.irc.source as irc_source
source = IRCReleaseSource()
sent: list[str] = []
class FakeClient:
online_servers: set[str] = {"OnlineBot"}
def send_message(self, _channel: str, message: str) -> None:
sent.append(message)
def wait_for_dcc(self, **_kwargs: object) -> None:
return None
monkeypatch.setattr(
irc_source,
"_config_text",
lambda key: {
"IRC_SERVER": "irc.example.net",
"IRC_CHANNEL": "ebooks",
"IRC_NICK": "tester",
"IRC_SEARCH_BOT": "search",
}.get(key, ""),
)
irc_source._recent_message_sends.clear()
monkeypatch.setattr(source, "is_available", lambda: True)
monkeypatch.setattr(irc_source, "_enforce_rate_limit", lambda: None)
monkeypatch.setattr(irc_source, "_emit_status", lambda *_args, **_kwargs: None)
monkeypatch.setattr(
"shelfmark.release_sources.irc.cache.cache_results",
lambda *_args, **_kwargs: None,
)
monkeypatch.setattr(
"shelfmark.release_sources.irc.connection_manager.connection_manager.get_connection",
lambda **_kwargs: FakeClient(),
)
monkeypatch.setattr(
"shelfmark.release_sources.irc.connection_manager.connection_manager.release_connection",
lambda _client: None,
)
monkeypatch.setattr(
"shelfmark.release_sources.irc.cache.get_cached_results",
lambda *_args, **_kwargs: None,
)
book = BookMetadata(
provider="hardcover", provider_id="1", title="Revelations", authors=["David Petrie"]
)
source.search(book, build_release_search_plan(book), expand_search=True)
assert sent == ["@search Revelations Petrie"]
# Same query, now served from cache for the same author: the match leads.
monkeypatch.setattr(
"shelfmark.release_sources.irc.cache.get_cached_results",
lambda *_args, **_kwargs: {
"releases": [_release("Gordon Petrie"), _release("D Petrie")],
"online_servers": ["OnlineBot"],
},
)
cached = source.search(book, build_release_search_plan(book))
assert [release.extra["author"] for release in cached] == ["D Petrie", "Gordon Petrie"]