09e9f3545f
Test / Code Quality (push) Has been cancelled
Test / Test (macos-latest, Python 3.10) (push) Has been cancelled
Test / Test (macos-latest, Python 3.11) (push) Has been cancelled
Test / Test (macos-latest, Python 3.12) (push) Has been cancelled
Test / Test (macos-latest, Python 3.13) (push) Has been cancelled
Test / Test (macos-latest, Python 3.14) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.10) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.11) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.12) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.13) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.14) (push) Has been cancelled
Test / Test (windows-latest, Python 3.10) (push) Has been cancelled
Test / Test (windows-latest, Python 3.11) (push) Has been cancelled
Test / Test (windows-latest, Python 3.12) (push) Has been cancelled
Test / Test (windows-latest, Python 3.13) (push) Has been cancelled
Test / Test (windows-latest, Python 3.14) (push) Has been cancelled
CodeQL / Analyze (push) Has been cancelled
dependency-audit / pip-audit (push) Has been cancelled
235 lines
8.5 KiB
Python
235 lines
8.5 KiB
Python
"""Tests for ``notebooklm._app.resolve``."""
|
||
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass
|
||
|
||
import pytest
|
||
|
||
from notebooklm._app.resolve import (
|
||
AmbiguousIdError,
|
||
Resolution,
|
||
near_miss_candidates,
|
||
resolve_ref,
|
||
validate_id,
|
||
)
|
||
from notebooklm.exceptions import NotebookLMError, ValidationError
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class Item:
|
||
id: str
|
||
title: str | None = None
|
||
|
||
|
||
def _id_of(item: Item) -> str:
|
||
return item.id
|
||
|
||
|
||
def _title_of(item: Item) -> str | None:
|
||
return item.title
|
||
|
||
|
||
# A canonical 8-4-4-4-12 UUID for the full-id fast-path.
|
||
FULL_ID = "abc12345-6789-4abc-def0-1234567890ab"
|
||
|
||
|
||
# --- validate_id ------------------------------------------------------------
|
||
|
||
|
||
def test_validate_id_strips_and_returns() -> None:
|
||
assert validate_id(" abc ") == "abc"
|
||
|
||
|
||
def test_validate_id_default_name() -> None:
|
||
assert validate_id("xyz", "notebook") == "xyz"
|
||
|
||
|
||
@pytest.mark.parametrize("blank", ["", " ", "\t\n"])
|
||
def test_validate_id_raises_validation_error_on_blank(blank: str) -> None:
|
||
with pytest.raises(ValidationError) as caught:
|
||
validate_id(blank, "notebook")
|
||
assert "notebook ID cannot be empty" in str(caught.value)
|
||
|
||
|
||
def test_validate_id_raises_public_validation_error_type() -> None:
|
||
# The whole point of the _app core is that it raises the public exception
|
||
# hierarchy (so adapters can re-shape it), never a transport exception. Pin
|
||
# the exact raised type rather than a base class to lock the contract.
|
||
with pytest.raises(ValidationError) as caught:
|
||
validate_id("")
|
||
assert type(caught.value) is ValidationError
|
||
assert isinstance(caught.value, NotebookLMError)
|
||
|
||
|
||
# --- resolve_ref ------------------------------------------------------------
|
||
|
||
|
||
def test_resolve_ref_full_id_fast_path_skips_items() -> None:
|
||
# An empty item list still resolves a full UUID — proving no scan happened.
|
||
result = resolve_ref(FULL_ID, [], id_of=_id_of)
|
||
|
||
assert result == Resolution(id=FULL_ID, matched_title=None)
|
||
|
||
|
||
def test_resolve_ref_full_id_returned_verbatim_even_with_items() -> None:
|
||
items = [Item(id="other")]
|
||
result = resolve_ref(FULL_ID, items, id_of=_id_of, title_of=_title_of)
|
||
|
||
assert result.id == FULL_ID
|
||
assert result.matched_title is None
|
||
|
||
|
||
def test_resolve_ref_exact_match_case_insensitive() -> None:
|
||
items = [Item(id="ABCDEF", title="A"), Item(id="abcdef00", title="B")]
|
||
# "abcdef" is an exact (case-insensitive) match AND a prefix of "abcdef00";
|
||
# exact must win and not report ambiguity.
|
||
result = resolve_ref("abcdef", items, id_of=_id_of, title_of=_title_of)
|
||
|
||
assert result.id == "ABCDEF"
|
||
assert result.matched_title is None
|
||
|
||
|
||
def test_resolve_ref_unique_prefix_carries_title() -> None:
|
||
items = [Item(id="abc123", title="First"), Item(id="zzz999", title="Second")]
|
||
result = resolve_ref("abc", items, id_of=_id_of, title_of=_title_of)
|
||
|
||
assert result.id == "abc123"
|
||
assert result.matched_title == "First"
|
||
|
||
|
||
def test_resolve_ref_unique_prefix_without_title_accessor() -> None:
|
||
items = [Item(id="abc123")]
|
||
result = resolve_ref("abc", items, id_of=_id_of)
|
||
|
||
assert result.id == "abc123"
|
||
assert result.matched_title is None
|
||
|
||
|
||
def test_resolve_ref_ambiguous_prefix_raises_with_candidates() -> None:
|
||
items = [
|
||
Item(id="abc111", title="One"),
|
||
Item(id="abc222", title="Two"),
|
||
]
|
||
with pytest.raises(AmbiguousIdError) as caught:
|
||
resolve_ref("abc", items, id_of=_id_of, title_of=_title_of)
|
||
|
||
err = caught.value
|
||
assert err.partial_id == "abc"
|
||
assert set(err.candidate_ids) == {"abc111", "abc222"}
|
||
# AmbiguousIdError must remain catchable as ValidationError.
|
||
assert isinstance(err, ValidationError)
|
||
assert "Ambiguous ID 'abc'" in str(err)
|
||
|
||
|
||
def test_resolve_ref_ambiguous_truncates_to_five_candidates() -> None:
|
||
items = [Item(id=f"abc{i}", title=f"T{i}") for i in range(7)]
|
||
with pytest.raises(AmbiguousIdError) as caught:
|
||
resolve_ref("abc", items, id_of=_id_of, title_of=_title_of)
|
||
|
||
# All 7 ids are tracked; the message lists at most 5 + a "more" line.
|
||
assert len(caught.value.candidate_ids) == 7
|
||
assert "and 2 more" in str(caught.value)
|
||
|
||
|
||
def test_resolve_ref_no_match_raises_validation_error() -> None:
|
||
items = [Item(id="abc123")]
|
||
with pytest.raises(ValidationError) as caught:
|
||
resolve_ref("zzz", items, id_of=_id_of)
|
||
assert "No item found starting with 'zzz'" in str(caught.value)
|
||
assert not isinstance(caught.value, AmbiguousIdError)
|
||
|
||
|
||
def test_resolve_ref_blank_token_raises_validation_error() -> None:
|
||
with pytest.raises(ValidationError):
|
||
resolve_ref(" ", [Item(id="abc")], id_of=_id_of)
|
||
|
||
|
||
def test_resolve_ref_partial_token_with_empty_items_is_no_match() -> None:
|
||
# A non-full-id token against an empty list takes the no-match path (not the
|
||
# fast-path), raising the plain "no match" ValidationError, not ambiguity.
|
||
with pytest.raises(ValidationError) as caught:
|
||
resolve_ref("abc", [], id_of=_id_of)
|
||
assert "No item found starting with 'abc'" in str(caught.value)
|
||
assert not isinstance(caught.value, AmbiguousIdError)
|
||
|
||
|
||
def test_resolve_ref_strips_token_before_matching() -> None:
|
||
items = [Item(id="abc123", title="First")]
|
||
result = resolve_ref(" abc ", items, id_of=_id_of, title_of=_title_of)
|
||
|
||
assert result.id == "abc123"
|
||
|
||
|
||
# --- near_miss_candidates (issue #1787) ------------------------------------
|
||
|
||
|
||
def test_near_miss_prefix_surfaces_full_title() -> None:
|
||
real = "Scientific PDF Parsing — Landscape, Benchmarks & Multimodal Extraction"
|
||
items = [Item(id="37fe5c1d", title=real), Item(id="cafef00d", title="Unrelated")]
|
||
assert near_miss_candidates("Scientific", items, id_of=_id_of, title_of=_title_of) == [
|
||
{"id": "37fe5c1d", "title": real}
|
||
]
|
||
|
||
|
||
def test_near_miss_em_dash_matches_hyphen() -> None:
|
||
items = [Item(id="deadbeef", title="Acme — Competitive Intel")]
|
||
# A plain hyphen typed for the em-dash still surfaces the real title.
|
||
assert near_miss_candidates(
|
||
"Acme - Competitive Intel", items, id_of=_id_of, title_of=_title_of
|
||
) == [{"id": "deadbeef", "title": "Acme — Competitive Intel"}]
|
||
|
||
|
||
def test_near_miss_non_breaking_space_matches_normal_space() -> None:
|
||
items = [Item(id="deadbeef", title="Acme Corp Notes")]
|
||
assert near_miss_candidates("Acme Corp Notes", items, id_of=_id_of, title_of=_title_of) == [
|
||
{"id": "deadbeef", "title": "Acme Corp Notes"}
|
||
]
|
||
|
||
|
||
def test_near_miss_fuzzy_matches_typo() -> None:
|
||
items = [Item(id="deadbeef", title="Competitive Landscape")]
|
||
got = near_miss_candidates("Competitve Landscape", items, id_of=_id_of, title_of=_title_of)
|
||
assert [c["id"] for c in got] == ["deadbeef"]
|
||
|
||
|
||
def test_near_miss_no_close_match_returns_empty() -> None:
|
||
items = [Item(id="deadbeef", title="Alpha"), Item(id="cafef00d", title="Beta")]
|
||
assert near_miss_candidates("Zzzzqwx", items, id_of=_id_of, title_of=_title_of) == []
|
||
|
||
|
||
def test_near_miss_empty_token_returns_empty() -> None:
|
||
items = [Item(id="deadbeef", title="Alpha")]
|
||
assert near_miss_candidates(" ", items, id_of=_id_of, title_of=_title_of) == []
|
||
|
||
|
||
def test_near_miss_skips_untitled_items() -> None:
|
||
items = [Item(id="deadbeef", title=None), Item(id="cafef00d", title="Alpha Notes")]
|
||
got = near_miss_candidates("Alpha", items, id_of=_id_of, title_of=_title_of)
|
||
assert [c["id"] for c in got] == ["cafef00d"]
|
||
|
||
|
||
def test_near_miss_caps_at_limit_and_dedupes() -> None:
|
||
items = [Item(id=f"id{n}", title=f"Report {n}") for n in range(10)]
|
||
got = near_miss_candidates("Report", items, id_of=_id_of, title_of=_title_of, limit=3)
|
||
assert len(got) == 3
|
||
assert len({c["id"] for c in got}) == 3
|
||
|
||
|
||
def test_near_miss_fuzzy_surfaces_all_items_sharing_a_normalized_title() -> None:
|
||
"""Distinct items whose titles normalize identically must each surface (#1794 review).
|
||
|
||
A shared normalized title must not let the first item shadow the rest while
|
||
limit slots remain — and the result must not depend on input order.
|
||
"""
|
||
# "Report — Q3" and "Report - Q3" both normalize to "report - q3"; the token
|
||
# has a typo so this resolves via the fuzzy pass, not the prefix pass.
|
||
items = [Item(id="em", title="Reportt — Q3"), Item(id="hy", title="Reportt - Q3")]
|
||
got = near_miss_candidates("Report Q3", items, id_of=_id_of, title_of=_title_of)
|
||
assert {c["id"] for c in got} == {"em", "hy"}
|
||
|
||
reversed_got = near_miss_candidates(
|
||
"Report Q3", list(reversed(items)), id_of=_id_of, title_of=_title_of
|
||
)
|
||
assert {c["id"] for c in reversed_got} == {"em", "hy"}
|