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
339 lines
14 KiB
Python
339 lines
14 KiB
Python
"""Tests for the ``POLL_RESEARCH`` row adapters (issue #1501).
|
|
|
|
These adapters centralise the positional knowledge ``_research_task_parser.py``
|
|
used to open-code as scattered single-level subscripts (``result[0]``,
|
|
``src[1]``, ``bundle[0]``, ``task_info[1][0]`` …). The tests cover three layers:
|
|
|
|
1. **Position-contract pins** — the canaries that fail loudly if a position
|
|
constant is edited (the wire-shape change signal).
|
|
2. **Shape handling** — happy-path reads plus the permissive "absent / short /
|
|
non-list → default" degrade that matches the historical wire parser.
|
|
3. **Drift** — the two GUARANTEED descents (``ResearchTaskRow.task_id_raw`` /
|
|
``task_info_raw``) RAISE ``UnknownRPCMethodError`` when their slot is absent.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from notebooklm._row_adapters.research import (
|
|
ImportedSourceRow,
|
|
ResearchResultRow,
|
|
ResearchStartRow,
|
|
ResearchTaskInfoRow,
|
|
ResearchTaskRow,
|
|
unwrap_import_rows,
|
|
unwrap_poll_tasks,
|
|
)
|
|
from notebooklm.exceptions import UnknownRPCMethodError
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 1. Position-contract pins (the canaries)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestResearchTaskRowPositionContract:
|
|
def test_positions_pinned(self) -> None:
|
|
assert (ResearchTaskRow._ID_POS, ResearchTaskRow._INFO_POS) == (0, 1)
|
|
|
|
|
|
class TestResearchTaskInfoRowPositionContract:
|
|
def test_positions_pinned(self) -> None:
|
|
assert (
|
|
ResearchTaskInfoRow._QUERY_TEXT_POS,
|
|
ResearchTaskInfoRow._SOURCES_POS,
|
|
ResearchTaskInfoRow._SUMMARY_POS,
|
|
ResearchTaskInfoRow._SUMMARY_MIN_LEN,
|
|
) == (0, 0, 1, 2)
|
|
|
|
|
|
class TestResearchResultRowPositionContract:
|
|
def test_positions_pinned(self) -> None:
|
|
assert (
|
|
ResearchResultRow._URL_POS,
|
|
ResearchResultRow._TITLE_POS,
|
|
ResearchResultRow._RESULT_TYPE_POS,
|
|
ResearchResultRow._LEGACY_CHUNKS_POS,
|
|
ResearchResultRow._MIN_LEN,
|
|
) == (0, 1, 3, 6, 2)
|
|
|
|
def test_deep_payload_positions_pinned(self) -> None:
|
|
assert (
|
|
ResearchResultRow._PAYLOAD_TITLE_POS,
|
|
ResearchResultRow._PAYLOAD_REPORT_POS,
|
|
ResearchResultRow._PAYLOAD_MIN_LEN,
|
|
) == (0, 1, 2)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 2. ResearchTaskRow — GUARANTEED descents
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestResearchTaskRow:
|
|
def test_happy_path(self) -> None:
|
|
row = ResearchTaskRow(["task_abc", ["info"]])
|
|
assert row.task_id_raw == "task_abc"
|
|
assert row.task_info_raw == ["info"]
|
|
|
|
def test_missing_id_slot_raises(self) -> None:
|
|
with pytest.raises(UnknownRPCMethodError):
|
|
_ = ResearchTaskRow([]).task_id_raw
|
|
|
|
def test_non_list_input_id_raises(self) -> None:
|
|
with pytest.raises(UnknownRPCMethodError):
|
|
_ = ResearchTaskRow(None).task_id_raw
|
|
|
|
def test_missing_info_slot_raises(self) -> None:
|
|
with pytest.raises(UnknownRPCMethodError):
|
|
_ = ResearchTaskRow(["only_id"]).task_info_raw
|
|
|
|
def test_wrong_type_id_is_returned_verbatim(self) -> None:
|
|
# The adapter only *reads* the slot; the parser validates the type.
|
|
assert ResearchTaskRow([42, ["info"]]).task_id_raw == 42
|
|
|
|
def test_wrong_type_info_is_returned_verbatim(self) -> None:
|
|
assert ResearchTaskRow(["id", "not_a_list"]).task_info_raw == "not_a_list"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 3. ResearchTaskInfoRow — routinely-optional inner reads (soft)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestResearchTaskInfoRow:
|
|
def test_query_text_happy_path(self) -> None:
|
|
assert ResearchTaskInfoRow.query_text(["quantum computing", "extra"]) == "quantum computing"
|
|
|
|
def test_query_text_empty_returns_none(self) -> None:
|
|
assert ResearchTaskInfoRow.query_text([]) is None
|
|
|
|
def test_bundle_sources_returns_first(self) -> None:
|
|
assert ResearchTaskInfoRow.bundle_sources([["a", "b"], "summary"]) == ["a", "b"]
|
|
|
|
def test_bundle_sources_empty_bundle_soft_degrades(self) -> None:
|
|
# Regression (#1502 review): an empty task_info[3] must hit the
|
|
# missing-slot default like every other soft read in this adapter —
|
|
# not raise IndexError. The parser's caller coerces None to [].
|
|
assert ResearchTaskInfoRow.bundle_sources([]) is None
|
|
|
|
def test_bundle_summary_present(self) -> None:
|
|
assert ResearchTaskInfoRow.bundle_summary([["a"], "Summary text"]) == "Summary text"
|
|
|
|
def test_bundle_summary_absent_returns_none(self) -> None:
|
|
assert ResearchTaskInfoRow.bundle_summary([["a"]]) is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 4. ResearchResultRow — routinely-optional source-row reads (soft)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestResearchResultRow:
|
|
def test_fast_research_shape(self) -> None:
|
|
row = ResearchResultRow(["https://example.com", "Example", "desc", "web"])
|
|
assert row.is_well_formed is True
|
|
assert row.length == 4
|
|
assert row.url_slot == "https://example.com"
|
|
assert row.title_slot == "Example"
|
|
assert row.has_result_type is True
|
|
assert row.result_type_slot == "web"
|
|
|
|
def test_short_row_not_well_formed(self) -> None:
|
|
row = ResearchResultRow(["only_one"])
|
|
assert row.is_well_formed is False
|
|
|
|
def test_non_list_not_well_formed(self) -> None:
|
|
row = ResearchResultRow(None)
|
|
assert row.is_well_formed is False
|
|
assert row.length == 0
|
|
assert row.url_slot is None
|
|
assert row.title_slot is None
|
|
assert row.result_type_slot is None
|
|
assert row.has_result_type is False
|
|
assert row.legacy_report_chunks == []
|
|
|
|
def test_result_type_absent_short_circuits(self) -> None:
|
|
row = ResearchResultRow([None, "title"])
|
|
assert row.has_result_type is False
|
|
assert row.result_type_slot is None
|
|
|
|
def test_deep_research_sentinel_url(self) -> None:
|
|
row = ResearchResultRow([None, ["Deep Report", "# Report"], None, 1])
|
|
assert row.url_slot is None
|
|
assert row.title_slot == ["Deep Report", "# Report"]
|
|
|
|
def test_legacy_chunks_present(self) -> None:
|
|
row = ResearchResultRow([None, "Legacy", None, "report", None, None, ["a", "b"]])
|
|
assert row.legacy_report_chunks == ["a", "b"]
|
|
|
|
def test_legacy_chunks_non_list_returns_empty(self) -> None:
|
|
row = ResearchResultRow([None, "t", None, 5, None, None, "str"])
|
|
assert row.legacy_report_chunks == []
|
|
|
|
def test_legacy_chunks_absent_returns_empty(self) -> None:
|
|
row = ResearchResultRow([None, "t", None, 5, None, None])
|
|
assert row.legacy_report_chunks == []
|
|
|
|
|
|
class TestResearchResultRowDeepPayload:
|
|
def test_two_strings_unpacked(self) -> None:
|
|
assert ResearchResultRow.deep_payload(["Title", "# Report"]) == ("Title", "# Report")
|
|
|
|
def test_bare_string_is_not_payload(self) -> None:
|
|
assert ResearchResultRow.deep_payload("Title") is None
|
|
|
|
def test_non_string_elements_rejected(self) -> None:
|
|
assert ResearchResultRow.deep_payload([123, "# Report"]) is None
|
|
assert ResearchResultRow.deep_payload(["Title", 456]) is None
|
|
|
|
def test_too_short_rejected(self) -> None:
|
|
assert ResearchResultRow.deep_payload(["Title"]) is None
|
|
|
|
def test_non_list_rejected(self) -> None:
|
|
assert ResearchResultRow.deep_payload(None) is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 5. unwrap_poll_tasks — envelope probe (soft)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestUnwrapPollTasks:
|
|
def test_empty_and_non_list_return_empty(self) -> None:
|
|
assert unwrap_poll_tasks(None) == []
|
|
assert unwrap_poll_tasks([]) == []
|
|
assert unwrap_poll_tasks("not rows") == []
|
|
|
|
def test_wrapped_envelope_is_unwrapped(self) -> None:
|
|
tasks = [["task_1", ["info"]]]
|
|
assert unwrap_poll_tasks([tasks]) == tasks
|
|
|
|
def test_flat_list_is_returned_unchanged(self) -> None:
|
|
flat = [["task_1", ["info"]]]
|
|
# A flat list whose first element's first element is itself a list is
|
|
# treated as wrapped; a flat list of task rows (first element's first is
|
|
# a str id) is returned unchanged.
|
|
assert unwrap_poll_tasks(flat) == flat
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 6. ResearchStartRow — START_*_RESEARCH kickoff result
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestResearchStartRowPositionContract:
|
|
def test_positions_pinned(self) -> None:
|
|
assert (ResearchStartRow._TASK_ID_POS, ResearchStartRow._REPORT_ID_POS) == (0, 1)
|
|
|
|
|
|
class TestResearchStartRow:
|
|
def test_task_id_and_report_id(self) -> None:
|
|
row = ResearchStartRow(["task_abc", "report_xyz"])
|
|
assert row.task_id_raw == "task_abc"
|
|
assert row.report_id == "report_xyz"
|
|
|
|
def test_report_id_absent_returns_none(self) -> None:
|
|
# A fast-research start legitimately omits the report id — soft default.
|
|
row = ResearchStartRow(["task_only"])
|
|
assert row.task_id_raw == "task_only"
|
|
assert row.report_id is None
|
|
|
|
def test_task_id_returned_verbatim(self) -> None:
|
|
# The adapter only reads the slot; the caller validates truthiness.
|
|
assert ResearchStartRow([None]).task_id_raw is None
|
|
assert ResearchStartRow([""]).task_id_raw == ""
|
|
|
|
def test_missing_task_id_slot_raises(self) -> None:
|
|
# The caller guards a non-empty list before wrapping; an absent id slot
|
|
# on a wrapped row is genuine drift and RAISES (strict ``safe_index``).
|
|
with pytest.raises(UnknownRPCMethodError):
|
|
_ = ResearchStartRow([]).task_id_raw
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 7. IMPORT_RESEARCH adapters — envelope probe + per-row reads (soft)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestUnwrapImportRows:
|
|
def test_empty_and_non_list_return_empty(self) -> None:
|
|
assert unwrap_import_rows(None) == []
|
|
assert unwrap_import_rows([]) == []
|
|
assert unwrap_import_rows("not rows") == []
|
|
|
|
def test_wrapped_envelope_is_unwrapped(self) -> None:
|
|
rows = [[["id_1"], "Title"]]
|
|
assert unwrap_import_rows([rows]) == rows
|
|
|
|
def test_single_wrapped_row_is_unwrapped(self) -> None:
|
|
# Matches ``research_import_sources_direct.yaml``: the recorded single
|
|
# imported row still arrives under a one-element ``[[row]]`` envelope.
|
|
wrapped = [[[["id"], "t"]]]
|
|
assert unwrap_import_rows(wrapped) == [[["id"], "t"]]
|
|
|
|
def test_flat_single_row_with_id_envelope_is_returned_unchanged(self) -> None:
|
|
# Regression for #1558: flat ``[row]`` where row is ``[[id], title]``
|
|
# must not mistake the row's id envelope for a wrapper.
|
|
flat = [[["id"], "t"]]
|
|
assert unwrap_import_rows(flat) == flat
|
|
|
|
def test_flat_populated_row_with_metadata_is_returned_unchanged(self) -> None:
|
|
# Real imported rows may carry metadata arrays after the title. The
|
|
# envelope probe must look only at ``result[0][0]`` as a row candidate;
|
|
# scanning the whole row could mistake metadata arrays for rows.
|
|
flat = [[["id"], "Title", [None, 1, [2]], [None, 2]]]
|
|
assert unwrap_import_rows(flat) == flat
|
|
|
|
def test_wrapped_row_with_absent_id_envelope_is_unwrapped(self) -> None:
|
|
rows = [[None, "Missing id"], [["id_2"], "Imported"]]
|
|
assert unwrap_import_rows([rows]) == rows
|
|
|
|
def test_flat_list_with_non_list_head_returned_unchanged(self) -> None:
|
|
# ``result[0]`` is a list but its first element is NOT a list, so the
|
|
# probe falls through and returns ``result`` unchanged — this is the
|
|
# already-flat row list ``[row, row, ...]`` shape.
|
|
flat = [["id_str_head", "t"]]
|
|
assert unwrap_import_rows(flat) == flat
|
|
|
|
|
|
class TestImportedSourceRowPositionContract:
|
|
def test_positions_pinned(self) -> None:
|
|
assert (
|
|
ImportedSourceRow._ID_ENVELOPE_POS,
|
|
ImportedSourceRow._ID_POS,
|
|
ImportedSourceRow._TITLE_POS,
|
|
ImportedSourceRow._MIN_LEN,
|
|
) == (0, 0, 1, 2)
|
|
|
|
|
|
class TestImportedSourceRow:
|
|
def test_happy_path(self) -> None:
|
|
row = ImportedSourceRow([["src_id_1"], "My Title"])
|
|
assert row.is_well_formed is True
|
|
assert row.source_id == "src_id_1"
|
|
assert row.title_slot == "My Title"
|
|
|
|
def test_short_row_not_well_formed(self) -> None:
|
|
row = ImportedSourceRow([["src_id_1"]])
|
|
assert row.is_well_formed is False
|
|
assert row.source_id is None
|
|
assert row.title_slot is None
|
|
|
|
def test_non_list_not_well_formed(self) -> None:
|
|
row = ImportedSourceRow(None)
|
|
assert row.is_well_formed is False
|
|
assert row.source_id is None
|
|
assert row.title_slot is None
|
|
|
|
def test_absent_id_envelope_short_circuits(self) -> None:
|
|
# A falsy / non-list id envelope legitimately means "skip this row".
|
|
assert ImportedSourceRow([None, "Title"]).source_id is None
|
|
assert ImportedSourceRow([[], "Title"]).source_id is None
|
|
assert ImportedSourceRow(["not_a_list", "Title"]).source_id is None
|
|
|
|
def test_id_returned_verbatim(self) -> None:
|
|
# The adapter reads the slot; the caller checks truthiness.
|
|
assert ImportedSourceRow([[None], "Title"]).source_id is None
|
|
assert ImportedSourceRow([[42], "Title"]).source_id == 42
|