Files
teng-lin--notebooklm-py/tests/unit/test_source_content_renderer.py
wehub-resource-sync 09e9f3545f
CodeQL / Analyze (push) Waiting to run
dependency-audit / pip-audit (push) Waiting to run
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
chore: import upstream snapshot with attribution
2026-07-13 13:30:13 +08:00

397 lines
13 KiB
Python

"""Unit tests for the private source content rendering service."""
from __future__ import annotations
import builtins
import logging
from typing import Any
import pytest
from notebooklm._source.content import SourceContentRenderer
from notebooklm.rpc import RPCMethod
from notebooklm.types import SourceNotFoundError
SOURCE_LOGGER = logging.getLogger("notebooklm._sources")
class RecordingRpc:
def __init__(self, response: Any) -> None:
self.response = response
self.calls: list[dict[str, Any]] = []
async def rpc_call(
self,
method: RPCMethod,
params: list[Any],
source_path: str = "/",
allow_null: bool = False,
_is_retry: bool = False,
*,
disable_internal_retries: bool = False,
operation_variant: str | None = None,
) -> Any:
self.calls.append(
{
"method": method,
"params": params,
"source_path": source_path,
"allow_null": allow_null,
"disable_internal_retries": disable_internal_retries,
}
)
return self.response
@pytest.mark.asyncio
async def test_text_mode_uses_exact_rpc_shape_and_extracts_nested_plaintext() -> None:
rpc = RecordingRpc(
[
[
"src_1",
"Article",
[None, None, None, None, 5, None, None, ["https://example.com"]],
],
None,
None,
[[["First paragraph.", [0, 20, "Second paragraph."]]]],
]
)
renderer = SourceContentRenderer(rpc)
fulltext = await renderer.get_fulltext("nb_1", "src_1")
assert fulltext.title == "Article"
assert fulltext.content == "First paragraph.\nSecond paragraph."
assert fulltext._type_code == 5
assert fulltext.url == "https://example.com"
assert fulltext.char_count == len(fulltext.content)
assert rpc.calls == [
{
"method": RPCMethod.GET_SOURCE,
"params": [["src_1"], [2], [2]],
"source_path": "/notebook/nb_1",
"allow_null": True,
"disable_internal_retries": False,
}
]
@pytest.mark.asyncio
async def test_markdown_mode_uses_html_rpc_shape_and_converts_html() -> None:
pytest.importorskip("markdownify")
rpc = RecordingRpc(
[
["src_md", "Markdown Source", [None, None, None, None, 5]],
None,
None,
None,
[None, "<h1>Title</h1><p>Hello <strong>world</strong>.</p>"],
]
)
renderer = SourceContentRenderer(rpc)
fulltext = await renderer.get_fulltext("nb_1", "src_md", output_format="markdown")
assert "# Title" in fulltext.content
assert "**world**" in fulltext.content
assert rpc.calls[0]["params"] == [["src_md"], [3], [3]]
@pytest.mark.asyncio
async def test_markdown_mode_missing_dependency_fails_before_rpc(
monkeypatch: pytest.MonkeyPatch,
) -> None:
real_import = builtins.__import__
def fake_import(name: str, *args: Any, **kwargs: Any) -> Any:
if name == "markdownify":
raise ImportError("No module named 'markdownify'")
return real_import(name, *args, **kwargs)
monkeypatch.setattr(builtins, "__import__", fake_import)
rpc = RecordingRpc([["src_md", "Markdown Source", []]])
renderer = SourceContentRenderer(rpc)
with pytest.raises(ImportError, match="notebooklm-py\\[markdown\\]"):
await renderer.get_fulltext("nb_1", "src_md", output_format="markdown")
assert rpc.calls == []
@pytest.mark.asyncio
async def test_invalid_output_format_fails_before_rpc() -> None:
rpc = RecordingRpc([["src_1", "Article", []]])
renderer = SourceContentRenderer(rpc)
with pytest.raises(ValueError, match="text.*markdown"):
await renderer.get_fulltext(
"nb_1",
"src_1",
output_format="html", # type: ignore[arg-type]
)
assert rpc.calls == []
@pytest.mark.asyncio
async def test_missing_html_rendition_logs_warning_and_returns_empty(
caplog: pytest.LogCaptureFixture,
) -> None:
pytest.importorskip("markdownify")
renderer = SourceContentRenderer(
RecordingRpc([["src_yt", "Video", [None, None, None, None, 9]], None, None, None]),
logger=SOURCE_LOGGER,
)
caplog.set_level("WARNING", logger="notebooklm._sources")
fulltext = await renderer.get_fulltext("nb_1", "src_yt", output_format="markdown")
assert fulltext.content == ""
assert "no HTML rendition" in caplog.text
assert "returned empty content" in caplog.text
@pytest.mark.asyncio
async def test_empty_text_content_logs_warning(caplog: pytest.LogCaptureFixture) -> None:
renderer = SourceContentRenderer(
RecordingRpc([["src_empty", "Empty", [None, None, None, None, 4]], None, None, [[]]]),
logger=SOURCE_LOGGER,
)
caplog.set_level("WARNING", logger="notebooklm._sources")
fulltext = await renderer.get_fulltext("nb_1", "src_empty")
assert fulltext.content == ""
assert fulltext.char_count == 0
assert "returned empty content" in caplog.text
@pytest.mark.asyncio
async def test_missing_source_raises_not_found() -> None:
renderer = SourceContentRenderer(RecordingRpc(None), logger=SOURCE_LOGGER)
with pytest.raises(SourceNotFoundError):
await renderer.get_fulltext("nb_1", "missing")
@pytest.mark.asyncio
async def test_malformed_type_code_warns_and_degrades_to_none(
caplog: pytest.LogCaptureFixture,
) -> None:
"""A present-but-non-int ``metadata[4]`` degrades to ``None`` LOUDLY (#1485).
Historically the slot was read with no validation at all, so a malformed
value flowed straight into ``SourceFulltext._type_code``. The read now
goes through ``SourceRow.type_code`` (int-validated); the malformed case
warns with a bounded payload preview.
"""
renderer = SourceContentRenderer(
RecordingRpc(
[
["src_bad", "Article", [None, None, None, None, "not-an-int"]],
None,
None,
[[["Body."]]],
]
),
logger=SOURCE_LOGGER,
)
caplog.set_level("WARNING", logger="notebooklm._sources")
fulltext = await renderer.get_fulltext("nb_1", "src_bad")
assert fulltext._type_code is None
assert fulltext.content == "Body."
assert "type-code slot malformed" in caplog.text
@pytest.mark.asyncio
async def test_drive_pdf_type_code_14_fulltext_decodes_to_pdf() -> None:
"""A Drive-hosted PDF read via GET_SOURCE decodes as PDF, not spreadsheet (#1832).
Real GET_SOURCE metadata (live capture): ``type_code == 14`` collides with a
native Google Sheet, but the row's MIME (``metadata[19]`` / ``metadata[9][2]``)
is ``application/pdf`` — so the fulltext path must disambiguate to PDF exactly
like ``Source.from_row`` does for the list path.
"""
from notebooklm.types import SourceType
meta = [None] * 20
meta[4] = 14
meta[9] = ["drive-id", 5, "application/pdf", ""]
meta[19] = "application/pdf"
renderer = SourceContentRenderer(
RecordingRpc([["src_pdf", "Report.pdf", meta], None, None, [[["Body."]]]])
)
fulltext = await renderer.get_fulltext("nb_1", "src_pdf")
assert fulltext._type_code == 3
assert fulltext.kind == SourceType.PDF
@pytest.mark.asyncio
async def test_pdf_url_title_fallback_applies_to_fulltext() -> None:
"""``source fulltext`` corrects a degraded direct-PDF-URL title (#1850).
The fallback lives in a shared helper used by both ``Source.from_row`` and
this ``GET_SOURCE`` read, so ``source fulltext`` shows the basename too —
not the raw URL (codex review on #1858).
"""
url = "https://example.com/papers/SomePaper.pdf"
meta = [None, None, None, None, 3, None, None, [url]]
renderer = SourceContentRenderer(
RecordingRpc([["src_pdf", url, meta], None, None, [[["Body."]]]])
)
fulltext = await renderer.get_fulltext("nb_1", "src_pdf")
assert fulltext.title == "SomePaper"
assert fulltext.url == url
assert fulltext._type_code == 3
@pytest.mark.asyncio
async def test_native_sheet_type_code_14_fulltext_stays_spreadsheet() -> None:
"""A native Sheet read via GET_SOURCE stays GOOGLE_SPREADSHEET (no regression, #1832)."""
from notebooklm.types import SourceType
meta = [None] * 20
meta[4] = 14
meta[9] = ["sheet-id", 8, "application/vnd.google-apps.spreadsheet", ""]
meta[19] = "application/vnd.google-apps.spreadsheet"
renderer = SourceContentRenderer(
RecordingRpc([["src_sheet", "Budget", meta], None, None, [[["Body."]]]])
)
fulltext = await renderer.get_fulltext("nb_1", "src_sheet")
assert fulltext._type_code == 14
assert fulltext.kind == SourceType.GOOGLE_SPREADSHEET
@pytest.mark.asyncio
@pytest.mark.parametrize(
"metadata",
[
[None, None], # too short to carry the type-code slot (absence)
[None, None, None, None, None], # null type-code slot (absence)
],
)
async def test_absent_type_code_stays_silent(
metadata: list[Any], caplog: pytest.LogCaptureFixture
) -> None:
"""An absent / ``None`` type-code slot keeps the silent ``None`` default."""
renderer = SourceContentRenderer(
RecordingRpc([["src_short", "Article", metadata], None, None, [[["Body."]]]]),
logger=SOURCE_LOGGER,
)
caplog.set_level("WARNING", logger="notebooklm._sources")
fulltext = await renderer.get_fulltext("nb_1", "src_short")
assert fulltext._type_code is None
assert "type-code slot malformed" not in caplog.text
@pytest.mark.asyncio
async def test_url_and_type_parsing_uses_shared_metadata_rules() -> None:
renderer = SourceContentRenderer(
RecordingRpc(
[
[
"src_yt",
"Video",
[
"https://bare.example/ignored",
None,
None,
None,
9,
["https://www.youtube.com/watch?v=abc123"],
None,
None,
],
],
None,
None,
[[["Transcript."]]],
]
)
)
fulltext = await renderer.get_fulltext("nb_1", "src_yt")
assert fulltext._type_code == 9
assert fulltext.url == "https://www.youtube.com/watch?v=abc123"
def test_extract_all_text_handles_nesting_and_max_depth(
caplog: pytest.LogCaptureFixture,
) -> None:
renderer = SourceContentRenderer(RecordingRpc(None), logger=SOURCE_LOGGER)
assert renderer.extract_all_text([["hello", ["world"]], "", "tail"]) == [
"hello",
"world",
"tail",
]
caplog.set_level("WARNING", logger="notebooklm._sources")
assert renderer.extract_all_text(["too", "deep"], max_depth=0) == []
assert "Max recursion depth reached" in caplog.text
@pytest.mark.asyncio
async def test_get_guide_uses_exact_rpc_shape_and_parses_summary_keywords() -> None:
rpc = RecordingRpc([[[None, ["Summary"], [["keyword1", "keyword2"]], []]]])
renderer = SourceContentRenderer(rpc)
guide = await renderer.get_guide("nb_1", "src_1")
# Typed return; attribute access is the only way (keywords is a tuple).
assert guide.summary == "Summary"
assert guide.keywords == ("keyword1", "keyword2")
# The dict-subscript back-compat bridge was dropped in v0.8.0 (#1251): the
# dataclass is now attribute-only, so subscript raises a plain TypeError.
with pytest.raises(TypeError, match="not subscriptable"):
guide["summary"] # type: ignore[index]
assert guide.to_public_dict() == {
"summary": "Summary",
"keywords": ["keyword1", "keyword2"],
}
assert rpc.calls == [
{
"method": RPCMethod.GET_SOURCE_GUIDE,
"params": [[[["src_1"]]]],
"source_path": "/notebook/nb_1",
"allow_null": True,
"disable_internal_retries": False,
}
]
@pytest.mark.asyncio
@pytest.mark.parametrize(
"response",
[
None,
["not_a_list"],
[["not_a_list"]],
[[[None, [], [], []]]],
[[[None, [123], [["keyword"]], []]]],
[[[None, ["Summary"], ["not_keyword_list"], []]]],
],
)
async def test_get_guide_shape_variants_return_stable_defaults(response: Any) -> None:
renderer = SourceContentRenderer(RecordingRpc(response))
guide = await renderer.get_guide("nb_1", "src_1")
# Attribute-only typed return (#1251): the historical key set lives in the
# to_public_dict() JSON shape, not on the dataclass's (removed) mapping API.
assert set(guide.to_public_dict()) == {"summary", "keywords"}
assert isinstance(guide.summary, str)
assert isinstance(guide.keywords, tuple)