Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:30:13 +08:00

235 lines
8.5 KiB
Python
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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"}