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
430 lines
17 KiB
Python
430 lines
17 KiB
Python
"""Unit tests for the source response adapters drained from raw positional reads.
|
|
|
|
Covers the three additions that absorbed the ``_source/`` + ``_types/sources``
|
|
raw ``name[int]`` RPC-payload reads behind the sanctioned ``_row_adapters``
|
|
layer (the #1491 single-level burndown for the sources domain):
|
|
|
|
* :meth:`SourceRow.url_from_metadata` / :meth:`SourceRow.created_at_from_metadata`
|
|
— the metadata-only entry points backing the re-exported
|
|
``notebooklm.types._extract_source_url`` / ``_extract_source_created_at``
|
|
public helpers. Each asserts BYTE-FOR-BYTE parity with the legacy inline walk
|
|
(the public helpers must not change behavior).
|
|
* :class:`SourceGuideRow` — the ``GET_SOURCE_GUIDE`` summary/keyword decode that
|
|
``_source/content.get_guide`` previously open-coded.
|
|
* :class:`SourceFulltextRow` — the ``GET_SOURCE`` descriptor / metadata / HTML /
|
|
text decode that ``_source/content.get_fulltext`` previously open-coded.
|
|
|
|
Every adapter read is a soft length-guarded degrade; these tests pin the
|
|
present / empty / too-short / malformed cases against the legacy behavior so a
|
|
future reshape that silently changes them fails here.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from notebooklm._row_adapters.sources import (
|
|
SourceFulltextRow,
|
|
SourceGuideRow,
|
|
SourceRow,
|
|
)
|
|
from notebooklm._types.common import _datetime_from_timestamp
|
|
from notebooklm._types.sources import (
|
|
_extract_source_created_at,
|
|
_extract_source_url,
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Legacy reference implementations (verbatim pre-drain logic) for parity checks
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _legacy_extract_source_url(metadata, *, allow_bare_http=True):
|
|
"""Pre-drain ``_extract_source_url`` body, copied verbatim."""
|
|
if not isinstance(metadata, list):
|
|
return None
|
|
url = None
|
|
if len(metadata) > 7:
|
|
url_list = metadata[7]
|
|
if isinstance(url_list, list) and len(url_list) > 0:
|
|
url = url_list[0]
|
|
if not url and len(metadata) > 5:
|
|
yt_data = metadata[5]
|
|
if isinstance(yt_data, list) and len(yt_data) > 0 and isinstance(yt_data[0], str):
|
|
url = yt_data[0]
|
|
if not url and allow_bare_http and len(metadata) > 0:
|
|
candidate = metadata[0]
|
|
if isinstance(candidate, str) and candidate.startswith("http"):
|
|
url = candidate
|
|
return url
|
|
|
|
|
|
def _legacy_extract_source_created_at(metadata):
|
|
"""Pre-drain ``_extract_source_created_at`` body, copied verbatim."""
|
|
if not isinstance(metadata, list) or len(metadata) <= 2:
|
|
return None
|
|
timestamp_list = metadata[2]
|
|
if not isinstance(timestamp_list, list) or not timestamp_list:
|
|
return None
|
|
return _datetime_from_timestamp(timestamp_list[0])
|
|
|
|
|
|
def _legacy_guide(result):
|
|
"""Pre-drain ``get_guide`` decode body, copied verbatim."""
|
|
summary = ""
|
|
keywords: list = []
|
|
if result and isinstance(result, list) and len(result) > 0:
|
|
outer = result[0]
|
|
if isinstance(outer, list) and len(outer) > 0:
|
|
inner = outer[0]
|
|
if isinstance(inner, list):
|
|
summary_block = inner[1] if len(inner) > 1 and isinstance(inner[1], list) else None
|
|
if summary_block:
|
|
summary = summary_block[0] if isinstance(summary_block[0], str) else ""
|
|
keyword_block = inner[2] if len(inner) > 2 and isinstance(inner[2], list) else None
|
|
if keyword_block:
|
|
keywords = keyword_block[0] if isinstance(keyword_block[0], list) else []
|
|
return summary, keywords
|
|
|
|
|
|
def _legacy_fulltext(result):
|
|
"""Pre-drain ``get_fulltext`` title/metadata/html/text decode, verbatim.
|
|
|
|
Returns ``(title, metadata, html_content, text_content_blocks)`` — the
|
|
four positional values the adapter now owns.
|
|
"""
|
|
title = ""
|
|
metadata = None
|
|
html = None
|
|
text_blocks = None
|
|
descriptor = result[0]
|
|
if isinstance(descriptor, list) and len(descriptor) > 1:
|
|
title = descriptor[1] if isinstance(descriptor[1], str) else ""
|
|
if len(descriptor) > 2 and isinstance(descriptor[2], list):
|
|
metadata = descriptor[2]
|
|
html_block = result[4] if len(result) > 4 and isinstance(result[4], list) else None
|
|
if html_block is not None and len(html_block) > 1:
|
|
candidate = html_block[1]
|
|
if isinstance(candidate, str):
|
|
html = candidate
|
|
text_block = result[3] if len(result) > 3 and isinstance(result[3], list) else None
|
|
if text_block:
|
|
content_blocks = text_block[0]
|
|
if isinstance(content_blocks, list):
|
|
text_blocks = content_blocks
|
|
return title, metadata, html, text_blocks
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# SourceRow.url_from_metadata — parity with legacy _extract_source_url
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
_URL_CASES = [
|
|
# (metadata, allow_bare_http)
|
|
([0, 1, 2, 3, 4, 5, 6, ["https://canonical/"]], True), # present canonical
|
|
([0, 1, 2, 3, 4, 5, 6, [""]], True), # falsy canonical -> raw "" (legacy quirk)
|
|
([0, 1, 2, 3, 4, 5, 6, [0]], True), # falsy zero canonical -> raw 0
|
|
([0, 1, 2, 3, 4, 5, 6, [42]], True), # non-str truthy -> raw int 42, no coercion
|
|
([0, 1, 2, 3, 4, 5, 6, []], True), # empty canonical block
|
|
([0, 1, 2, 3, 4, 5, 6, "notlist"], True), # non-list canonical block
|
|
([0, 1, 2, 3, 4, ["https://yt/"]], True), # youtube string fallback
|
|
([0, 1, 2, 3, 4, [42]], True), # youtube non-string ignored
|
|
([0, 1, 2, 3, 4, []], True), # empty youtube block
|
|
(["https://bare/"], True), # bare http honored
|
|
(["https://bare/"], False), # bare http suppressed
|
|
(["ftp://bare/"], True), # bare non-http ignored
|
|
([42], True), # bare non-string ignored
|
|
([], True), # empty metadata
|
|
("notalist", True), # non-list metadata
|
|
(None, True), # None metadata
|
|
([0, 1, 2, 3, 4, 5, 6, ["a", "b"]], True), # canonical multi -> first
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize(("metadata", "allow"), _URL_CASES)
|
|
def test_url_from_metadata_matches_legacy_extract(metadata, allow) -> None:
|
|
"""``SourceRow.url_from_metadata`` reproduces the legacy walk byte-for-byte."""
|
|
expected = _legacy_extract_source_url(metadata, allow_bare_http=allow)
|
|
actual = SourceRow.url_from_metadata(metadata, allow_bare_http=allow)
|
|
assert actual == expected, f"url mismatch for {metadata!r} allow={allow}"
|
|
# Type identity matters too (legacy returns raw, un-coerced values).
|
|
assert type(actual) is type(expected), f"type mismatch for {metadata!r}"
|
|
|
|
|
|
@pytest.mark.parametrize(("metadata", "allow"), _URL_CASES)
|
|
def test_url_public_helper_routes_through_adapter(metadata, allow) -> None:
|
|
"""The re-exported ``_extract_source_url`` shim delegates with no drift."""
|
|
assert _extract_source_url(metadata, allow_bare_http=allow) == _legacy_extract_source_url(
|
|
metadata, allow_bare_http=allow
|
|
)
|
|
|
|
|
|
def test_url_from_metadata_canonical_precedence_over_youtube() -> None:
|
|
"""Canonical ``[7]`` wins over youtube ``[5]`` when both present."""
|
|
md = [0, 1, 2, 3, 4, ["https://yt/"], 6, ["https://canonical/"]]
|
|
assert SourceRow.url_from_metadata(md) == "https://canonical/"
|
|
|
|
|
|
def test_url_from_metadata_falsy_canonical_falls_through_to_youtube() -> None:
|
|
"""A falsy ``[7][0]`` lets the youtube ``[5]`` slot win (legacy fall-through)."""
|
|
md = [0, 1, 2, 3, 4, ["https://yt/"], 6, [""]]
|
|
assert SourceRow.url_from_metadata(md) == "https://yt/"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# SourceRow.created_at_from_metadata — parity with legacy
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
_CREATED_CASES = [
|
|
[0, 1, [1_700_000_000]], # present numeric
|
|
[0, 1, [1_700_000_000.5]], # float
|
|
[0, 1, [True]], # bool subclass of int -> 1s
|
|
[0, 1, []], # empty timestamp block
|
|
[0, 1, None], # None timestamp block
|
|
[0, 1, ["abc"]], # non-numeric string inner
|
|
[0, 1, [None]], # None inner
|
|
[0, 1, "notlist"], # non-list timestamp block
|
|
[0, 1], # too short (no idx 2)
|
|
[], # empty metadata
|
|
"notalist", # non-list metadata
|
|
None, # None metadata
|
|
[0, 1, [1_700_000_000, 99]], # multi -> first
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize("metadata", _CREATED_CASES)
|
|
def test_created_at_from_metadata_matches_legacy_extract(metadata) -> None:
|
|
"""``SourceRow.created_at_from_metadata`` reproduces the legacy walk exactly."""
|
|
assert SourceRow.created_at_from_metadata(metadata) == _legacy_extract_source_created_at(
|
|
metadata
|
|
), f"created_at mismatch for {metadata!r}"
|
|
|
|
|
|
@pytest.mark.parametrize("metadata", _CREATED_CASES)
|
|
def test_created_at_public_helper_routes_through_adapter(metadata) -> None:
|
|
"""The re-exported ``_extract_source_created_at`` shim delegates with no drift."""
|
|
assert _extract_source_created_at(metadata) == _legacy_extract_source_created_at(metadata)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# SourceGuideRow — GET_SOURCE_GUIDE decode
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
_GUIDE_CASES = [
|
|
[[["id", ["the summary"], [["kw1", "kw2"]]]]], # present, full
|
|
[[["id", [42], [["kw"]]]]], # summary[0] non-str -> ""
|
|
[[["id", ["s"], ["notlist"]]]], # keyword[0] non-list -> []
|
|
[[["id", [], []]]], # empty blocks
|
|
[[["id", "notlist", "notlist"]]], # blocks non-list
|
|
[[["id"]]], # inner too short
|
|
[[["id", ["s"]]]], # no keyword block
|
|
[[None]], # inner None
|
|
[None], # outer None
|
|
[[]], # empty outer
|
|
[], # empty result
|
|
None, # None result
|
|
"x", # scalar result
|
|
[[["id", ["s", "extra"], [["kw"], "extra"]]]], # multi-elem blocks -> first
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize("result", _GUIDE_CASES)
|
|
def test_source_guide_row_matches_legacy(result) -> None:
|
|
"""``SourceGuideRow`` summary/keywords reproduce the legacy ``get_guide`` decode."""
|
|
row = SourceGuideRow(result)
|
|
exp_summary, exp_keywords = _legacy_guide(result)
|
|
assert row.summary == exp_summary, f"summary mismatch for {result!r}"
|
|
assert row.keywords == exp_keywords, f"keywords mismatch for {result!r}"
|
|
|
|
|
|
def test_source_guide_row_present() -> None:
|
|
"""A well-formed guide payload yields its summary and keyword list."""
|
|
row = SourceGuideRow([[["id", ["A summary."], [["k1", "k2"]]]]])
|
|
assert row.summary == "A summary."
|
|
assert row.keywords == ["k1", "k2"]
|
|
|
|
|
|
def test_source_guide_row_empty_and_malformed_defaults() -> None:
|
|
"""Empty / malformed envelopes degrade to the ``""`` / ``[]`` defaults."""
|
|
for result in (None, [], "x", [[]], [[["id"]]], [None]):
|
|
row = SourceGuideRow(result)
|
|
assert row.summary == ""
|
|
assert row.keywords == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# SourceFulltextRow — GET_SOURCE decode
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
_FULLTEXT_CASES = [
|
|
[["id"]], # descriptor too short
|
|
[[["id"], "Title", [0, 1, 2, 3, 4]]], # descriptor present, metadata present
|
|
[[["id"], 42, [0, 1, 2, 3, 4]]], # title non-str -> ""
|
|
[[["id"], "T", "notmeta"]], # metadata non-list
|
|
[[["id"], "T"]], # no metadata slot
|
|
[[["id"], "T", [0, 1, 2, 3, 9]], None, None, [["a", "b"]], [None, "<html>"]], # full
|
|
[[["id"], "T", []], None, None, [], []], # empty text & html blocks
|
|
[[["id"], "T", []], None, None, ["notlist"], [None, 42]], # text[0] non-list, html non-str
|
|
[[["id"], "T", []], None, None, [["txt"]], [None]], # html block too short
|
|
["notdesc"], # descriptor non-list
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize("result", _FULLTEXT_CASES)
|
|
def test_source_fulltext_row_matches_legacy(result) -> None:
|
|
"""``SourceFulltextRow`` reproduces the legacy ``get_fulltext`` positional decode."""
|
|
row = SourceFulltextRow(result)
|
|
exp_title, exp_meta, exp_html, exp_text = _legacy_fulltext(result)
|
|
assert row.title == exp_title, f"title mismatch for {result!r}"
|
|
assert row.metadata == exp_meta, f"metadata mismatch for {result!r}"
|
|
assert row.html_content == exp_html, f"html mismatch for {result!r}"
|
|
assert row.text_content_blocks == exp_text, f"text mismatch for {result!r}"
|
|
|
|
|
|
def test_source_fulltext_row_present() -> None:
|
|
"""A full ``GET_SOURCE`` payload exposes all four positional values."""
|
|
result = [
|
|
[["id"], "Doc title", [None, None, None, None, 5]],
|
|
None,
|
|
None,
|
|
[["chunk-a", "chunk-b"]],
|
|
[None, "<p>html</p>"],
|
|
]
|
|
row = SourceFulltextRow(result)
|
|
assert row.title == "Doc title"
|
|
assert row.metadata == [None, None, None, None, 5]
|
|
assert row.text_content_blocks == ["chunk-a", "chunk-b"]
|
|
assert row.html_content == "<p>html</p>"
|
|
# The descriptor wraps as a SourceRow whose type_code reads metadata[4].
|
|
source_row = row.source_row
|
|
assert source_row is not None
|
|
assert source_row.type_code == 5
|
|
|
|
|
|
def test_source_fulltext_row_too_short_descriptor_is_soft() -> None:
|
|
"""A descriptor too short to carry a title yields empty/None defaults."""
|
|
row = SourceFulltextRow([["id"]])
|
|
assert row.title == ""
|
|
assert row.metadata is None
|
|
assert row.source_row is None
|
|
assert row.raw_metadata_type_slot is None
|
|
assert row.html_content is None
|
|
assert row.text_content_blocks is None
|
|
|
|
|
|
def test_source_fulltext_row_malformed_result_is_soft() -> None:
|
|
"""A non-list result degrades everywhere rather than raising."""
|
|
row = SourceFulltextRow("notalist")
|
|
assert row.title == ""
|
|
assert row.metadata is None
|
|
assert row.html_content is None
|
|
assert row.text_content_blocks is None
|
|
|
|
|
|
def test_source_fulltext_row_raw_metadata_type_slot() -> None:
|
|
"""``raw_metadata_type_slot`` surfaces the raw ``metadata[4]`` for the WARNING."""
|
|
# Present non-int type slot (the malformed-type-code case).
|
|
row = SourceFulltextRow([[["id"], "T", [0, 1, 2, 3, "five"]]])
|
|
assert row.raw_metadata_type_slot == "five"
|
|
assert row.source_row is not None
|
|
assert row.source_row.type_code is None # non-int -> unknown
|
|
# Metadata too short to carry the slot.
|
|
short = SourceFulltextRow([[["id"], "T", [0, 1, 2, 3]]])
|
|
assert short.raw_metadata_type_slot is None
|
|
|
|
|
|
# --- #1832: disambiguate the type_code==14 overload (native Sheet vs Drive PDF) ---
|
|
|
|
|
|
def _meta_with(*, type_code, mime=None, descriptor_mime=None):
|
|
"""Build a length-20 metadata array with the positions #1832 relies on.
|
|
|
|
``type_code`` at [4]; top-level MIME at [19]; the Drive-file descriptor
|
|
``[id, kind_int, mime, ""]`` at [9] when ``descriptor_mime`` is given.
|
|
"""
|
|
meta = [None] * 20
|
|
meta[4] = type_code
|
|
if descriptor_mime is not None:
|
|
meta[9] = ["drive-id", 8, descriptor_mime, ""]
|
|
if mime is not None:
|
|
meta[19] = mime
|
|
return meta
|
|
|
|
|
|
def _row(meta):
|
|
return SourceRow.from_entry([["src-id"], "Title", meta, [None, 2]])
|
|
|
|
|
|
def test_source_row_mime_reads_metadata_19() -> None:
|
|
assert _row(_meta_with(type_code=14, mime="application/pdf")).mime == "application/pdf"
|
|
|
|
|
|
def test_source_row_mime_falls_back_to_drive_descriptor() -> None:
|
|
# Top-level [19] absent but the drive descriptor [9][2] carries the mime.
|
|
assert (
|
|
_row(_meta_with(type_code=14, descriptor_mime="application/pdf")).mime == "application/pdf"
|
|
)
|
|
|
|
|
|
def test_source_row_mime_none_when_absent() -> None:
|
|
assert _row(_meta_with(type_code=14)).mime is None
|
|
|
|
|
|
def test_drive_pdf_type_code_14_decodes_to_pdf() -> None:
|
|
"""Real Drive-PDF row (captured #1832): type_code 14 + application/pdf → PDF."""
|
|
from notebooklm._types.sources import Source, SourceType
|
|
|
|
src = Source.from_row(
|
|
_row(_meta_with(type_code=14, mime="application/pdf", descriptor_mime="application/pdf"))
|
|
)
|
|
assert src.kind == SourceType.PDF
|
|
|
|
|
|
def test_native_sheet_type_code_14_stays_spreadsheet() -> None:
|
|
"""Real native-Sheet row (captured #1832): type_code 14 + google-apps mime → GOOGLE_SPREADSHEET (no regression)."""
|
|
from notebooklm._types.sources import Source, SourceType
|
|
|
|
src = Source.from_row(
|
|
_row(
|
|
_meta_with(
|
|
type_code=14,
|
|
mime="application/vnd.google-apps.spreadsheet",
|
|
descriptor_mime="application/vnd.google-apps.spreadsheet",
|
|
)
|
|
)
|
|
)
|
|
assert src.kind == SourceType.GOOGLE_SPREADSHEET
|
|
|
|
|
|
def test_type_code_14_no_mime_stays_spreadsheet() -> None:
|
|
"""type_code 14 with no MIME signal stays GOOGLE_SPREADSHEET (conservative)."""
|
|
from notebooklm._types.sources import Source, SourceType
|
|
|
|
src = Source.from_row(_row(_meta_with(type_code=14)))
|
|
assert src.kind == SourceType.GOOGLE_SPREADSHEET
|
|
|
|
|
|
def test_type_code_14_unknown_binary_mime_stays_spreadsheet() -> None:
|
|
"""An unrecognized MIME under 14 is left as GOOGLE_SPREADSHEET, not relabeled/UNKNOWN."""
|
|
from notebooklm._types.sources import Source, SourceType
|
|
|
|
src = Source.from_row(_row(_meta_with(type_code=14, mime="application/x-mystery")))
|
|
assert src.kind == SourceType.GOOGLE_SPREADSHEET
|
|
|
|
|
|
def test_non_14_type_code_with_pdf_mime_is_untouched() -> None:
|
|
"""The MIME override only fires for type_code==14; other codes pass through.
|
|
|
|
A native web page (code 5) carrying a stray ``application/pdf`` MIME must NOT
|
|
be remapped — the disambiguation is gated on the ambiguous code 14 alone.
|
|
"""
|
|
from notebooklm._types.sources import Source, SourceType
|
|
|
|
src = Source.from_row(_row(_meta_with(type_code=5, mime="application/pdf")))
|
|
assert src._type_code == 5
|
|
assert src.kind == SourceType.WEB_PAGE
|