Files
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

1338 lines
48 KiB
Python

"""Unit tests for multi-source selection in chat and artifact generation.
Tests that source_ids are correctly handled when:
1. Explicitly passed (subset of sources)
2. None (uses all sources via NotebooksAPI.get_source_ids)
Verifies correct encoding of source IDs in RPC parameters:
- source_ids_triple = [[[sid]] for sid in source_ids]
- source_ids_double = [[sid] for sid in source_ids]
"""
import json
from unittest.mock import AsyncMock, MagicMock
import pytest
from notebooklm._artifacts import ArtifactsAPI
from notebooklm._chat import ChatAPI
from notebooklm.exceptions import ValidationError
from notebooklm.rpc import (
AudioFormat,
AudioLength,
InfographicDetail,
InfographicOrientation,
InfographicStyle,
QuizDifficulty,
QuizQuantity,
SlideDeckFormat,
SlideDeckLength,
VideoFormat,
VideoStyle,
)
@pytest.fixture
def mock_core():
"""Create a mock Session.
After Wave 8 of session-decoupling, ``ChatAPI.ask`` reaches the network
through its injected :class:`RuntimeTransport` collaborator via
``self._transport.perform_authed_post`` (constructor-injected by the
``_chat_from_mock_core`` helper below, which maps the bag-of-attributes
``mock_core`` fixture onto the four keyword-only collaborator slots).
The fixture stubs ``mock_core.session_transport.perform_authed_post`` —
that ``AsyncMock`` is the value ``_chat_from_mock_core`` passes as
``transport=`` — and invokes the caller-supplied ``build_request``
factory so URL/body assertions still exercise the production request
builder.
"""
from types import SimpleNamespace
from notebooklm._request_types import AuthSnapshot
# ``ChatAPI.get_conversation_id`` calls ``rpc_executor.rpc_call`` with
# the ``hPTbtc`` (GET_LAST_CONVERSATION_ID) method. Issue #659: after a
# new-conversation ask, ``ChatAPI.ask`` calls this to recover the real
# conversation_id. Route only that method to a hPTbtc-shaped reply;
# every other RPC honors ``rpc_call.return_value`` so the artifact
# tests in this module (which set ``return_value`` per call) are
# unaffected.
from notebooklm.rpc import RPCMethod as _RPC
rpc_call = AsyncMock(return_value=MagicMock())
# Forward declare so the dispatcher and default-transport closures can
# capture the eventual ``core`` symbol. The actual ``SimpleNamespace``
# assembly happens further below, after both closures are defined.
auth = SimpleNamespace(
csrf_token="test_csrf",
session_id="test_session",
authuser=0,
account_email=None,
)
async def _rpc_call_dispatch(method, params, **kwargs):
if method == _RPC.GET_LAST_CONVERSATION_ID:
return [[["mock-core-conv-id"]]]
return rpc_call.return_value
rpc_call.side_effect = _rpc_call_dispatch
# Default ``perform_authed_post`` stub on the session-transport
# collaborator: invokes the caller-supplied ``build_request`` factory
# with a frozen snapshot (so the URL/body the test wants to assert on
# actually gets assembled) and returns a stock answer response.
# Individual tests that need to inspect the URL/body can read
# ``core._last_chat_request`` after calling ``ChatAPI.ask``. The
# chat-side ``parse_label`` is forwarded as ``log_label``.
async def _perform_authed_post_default(
*,
build_request,
log_label,
read_timeout=None,
max_response_bytes=None,
disable_read_timeout_retries=False,
**_kwargs,
):
snapshot = AuthSnapshot(
csrf_token=auth.csrf_token,
session_id=auth.session_id,
authuser=auth.authuser,
account_email=auth.account_email,
)
url, body, headers = build_request(snapshot)
core._last_chat_request = {"url": url, "body": body, "headers": headers}
resp = MagicMock()
# ``first[2][0]`` carries the server-assigned conversation_id; new
# conversations require this slot (issue #659).
inner = json.dumps(
[
[
"Default answer long enough to be valid.",
None,
["server-source-selection-conv", 12345],
None,
[1],
]
]
)
chunk = json.dumps([["wrb.fr", None, inner]])
resp.text = f")]}}'\n{len(chunk)}\n{chunk}\n"
return resp
# Assemble the bag-of-attributes fixture in one ``SimpleNamespace`` call
# so every collaborator slot ``ChatAPI`` and ``ArtifactsAPI`` read from
# the fixture lands at construction time. ADR-0007 specifically forbids
# the ``core.<attr> = <value>`` re-assignment pattern (which is why this
# is *not* built via ``make_fake_core`` + post-construction stubs); the
# SimpleNamespace constructor satisfies the policy by setting every
# attribute up-front.
#
# Wave 8 of session-decoupling: chat now reaches the network through
# ``session_transport.perform_authed_post`` rather than the legacy
# ``transport_post`` facade on Session. Reqid is bumped via
# ``await self._reqid.next_reqid()``; the bag below is passed as the
# ``reqid=`` collaborator by ``_chat_from_mock_core``.
session_transport = SimpleNamespace(
perform_authed_post=AsyncMock(side_effect=_perform_authed_post_default),
)
# ArtifactsAPI uses the same fixture as its runtime collaborator and
# exercises ``register_drain_hook`` (close-time hook) and
# ``operation_scope`` (drain-coordinated scope). Stub both up-front so
# the fixture satisfies both ChatAPI's reqid/transport surfaces and
# ArtifactsAPI's runtime surface in one bag.
from contextlib import asynccontextmanager
@asynccontextmanager
async def _operation_scope_factory(label: str):
yield None
drain_hooks: dict = {}
def _register_drain_hook(name: str, hook):
drain_hooks[name] = hook
core = SimpleNamespace(
rpc_executor=SimpleNamespace(rpc_call=rpc_call),
# ``rpc_call`` mirrors ``rpc_executor.rpc_call`` so the SimpleNamespace
# also satisfies the composite ``ArtifactsRuntime`` shape
# (``RpcCaller`` + ``LoopGuard`` + ``OperationScopeProvider`` +
# ``DrainHookRegistration``) when threaded into ``ArtifactsAPI`` as
# the runtime adapter.
rpc_call=rpc_call,
auth=auth,
next_reqid=AsyncMock(return_value=100000),
assert_bound_loop=MagicMock(return_value=None),
get_http_client=MagicMock(),
session_transport=session_transport,
_last_chat_request=None,
operation_scope=MagicMock(side_effect=_operation_scope_factory),
register_drain_hook=MagicMock(side_effect=_register_drain_hook),
_drain_hooks=drain_hooks,
)
return core
@pytest.fixture
def mock_notebooks_api():
notebooks = MagicMock()
notebooks.get_source_ids = AsyncMock(return_value=[])
return notebooks
def _chat_from_mock_core(mock_core, *, notebooks=None) -> ChatAPI:
"""Build a ``ChatAPI`` from the ``mock_core`` fixture's surfaces.
Wave 8 of session-decoupling (ADR-0014 Rule 2 Corollary): ``ChatAPI``
takes its four direct collaborators by keyword arg. The legacy single-
arg ``ChatAPI(mock_core)`` form is gone; this helper preserves the
test shape by mapping the bag-of-attributes mock_core fixture onto
the new constructor surface (rpc, transport, reqid, loop_guard).
Tests pass ``mock_core.rpc_executor.rpc_call`` for ``rpc.rpc_call`` and the
fixture's pre-wired ``mock_core.session_transport.perform_authed_post``
for the transport entry point.
"""
return ChatAPI(
rpc=mock_core.rpc_executor,
transport=mock_core.session_transport,
reqid=mock_core,
loop_guard=mock_core,
notebooks=notebooks,
)
@pytest.fixture
def mock_mind_map_service():
"""Bundle of stand-in services required by ``ArtifactsAPI.__init__``.
These tests exercise generation/encoding paths that never call the
mind-map services. The ``mind_maps`` + ``note_service`` parameters
are both required (Phase 5 / refactor-history.md Migration Plan steps 6-7)
so we return a dict of stand-in mocks that construction sites can
splat into ``ArtifactsAPI(...)`` calls via
``**mock_mind_map_service``.
"""
from notebooklm._mind_map import NoteBackedMindMapService
from notebooklm._note_service import NoteService
return {
"mind_maps": MagicMock(spec=NoteBackedMindMapService),
"note_service": MagicMock(spec=NoteService),
}
class TestChatSourceSelection:
"""Tests for source selection in ChatAPI.ask()."""
@pytest.mark.asyncio
async def test_ask_with_explicit_source_ids(self, mock_core):
"""Test ask() with explicitly provided source_ids."""
api = _chat_from_mock_core(mock_core)
result = await api.ask(
notebook_id="nb_123",
question="Test question?",
source_ids=["src_001", "src_002"],
)
assert result.answer == "Default answer long enough to be valid."
# session_transport.perform_authed_post is the session entry point;
# the request body is captured into ``_last_chat_request`` by the
# mock_core fixture.
body = mock_core._last_chat_request["body"]
# The body should contain the encoded sources_array
# sources_array = [[[sid]] for sid in source_ids]
# For ["src_001", "src_002"], this becomes [[["src_001"]], [["src_002"]]]
assert "src_001" in body
assert "src_002" in body
@pytest.mark.asyncio
async def test_ask_with_none_fetches_all_sources(self, mock_core, mock_notebooks_api):
"""Test ask() with source_ids=None fetches all sources."""
api = _chat_from_mock_core(mock_core, notebooks=mock_notebooks_api)
# Mock get_source_ids to return source IDs
mock_notebooks_api.get_source_ids.return_value = ["src_001", "src_002", "src_003"]
result = await api.ask(
notebook_id="nb_123",
question="Test question?",
source_ids=None, # Should fetch all sources
)
assert result.answer == "Default answer long enough to be valid."
# Verify get_source_ids was called on notebooks API
mock_notebooks_api.get_source_ids.assert_called_once_with("nb_123")
@pytest.mark.asyncio
async def test_ask_source_encoding_format(self, mock_core):
"""Verify the correct encoding format for source IDs in ask()."""
api = _chat_from_mock_core(mock_core)
await api.ask(
notebook_id="nb_123",
question="Test?",
source_ids=["s1", "s2", "s3"],
)
# session_transport.perform_authed_post should have been called once
# with a build_request factory that produces the URL-encoded body
# with the triple-nested sources.
mock_core.session_transport.perform_authed_post.assert_awaited_once()
body = mock_core._last_chat_request["body"]
# The body contains URL-encoded f.req parameter
# sources_array should be [[["s1"]], [["s2"]], [["s3"]]]
# This gets encoded in the params as the first element
# Extract f.req from body
import re
from urllib.parse import unquote
match = re.search(r"f\.req=([^&]+)", body)
assert match, f"f.req= missing from body: {body!r}"
f_req_encoded = match.group(1)
f_req_decoded = unquote(f_req_encoded)
f_req_data = json.loads(f_req_decoded)
# f_req is [None, params_json]
params = json.loads(f_req_data[1])
sources_array = params[0]
# Verify the triple-nested format
assert sources_array == [[["s1"]], [["s2"]], [["s3"]]]
class TestArtifactsSourceSelection:
"""Tests for source selection in ArtifactsAPI generation methods."""
@pytest.mark.asyncio
async def test_generate_audio_with_explicit_source_ids(self, mock_core, mock_mind_map_service):
"""Test generate_audio with explicitly provided source_ids."""
api = ArtifactsAPI(
rpc=mock_core,
drain=mock_core,
lifecycle=mock_core,
notebooks=MagicMock(),
**mock_mind_map_service,
)
# Mock successful generation response
mock_core.rpc_executor.rpc_call.return_value = [
["artifact_123", "Audio", 1, None, 1] # status 1 = in_progress
]
result = await api.generate_audio(
notebook_id="nb_123",
source_ids=["src_001", "src_002"],
)
assert result.task_id == "artifact_123"
assert result.status == "in_progress"
# Verify RPC was called with correct source encoding
mock_core.rpc_executor.rpc_call.assert_called_once()
call_args = mock_core.rpc_executor.rpc_call.call_args
params = call_args.args[1]
# params structure for audio:
# [
# [2],
# notebook_id,
# [
# None, None, 1, # type = audio
# source_ids_triple, # [[[sid]] for sid]
# None, None,
# [None, [instructions, length_code, None, source_ids_double, language, None, format_code]]
# ]
# ]
inner_params = params[2]
source_ids_triple = inner_params[3]
audio_config = inner_params[6][1]
source_ids_double = audio_config[3]
assert source_ids_triple == [[["src_001"]], [["src_002"]]]
assert source_ids_double == [["src_001"], ["src_002"]]
assert audio_config[1] == AudioLength.DEFAULT.value
assert audio_config[6] == AudioFormat.DEEP_DIVE.value
@pytest.mark.asyncio
async def test_generate_audio_explicit_options_override_defaults(
self, mock_core, mock_mind_map_service
):
"""Explicit audio format and length are encoded instead of API defaults."""
api = ArtifactsAPI(
rpc=mock_core,
drain=mock_core,
lifecycle=mock_core,
notebooks=MagicMock(),
**mock_mind_map_service,
)
mock_core.rpc_call.return_value = [["artifact_123", "Audio", 1, None, 1]]
await api.generate_audio(
notebook_id="nb_123",
source_ids=["src_001"],
audio_format=AudioFormat.DEBATE,
audio_length=AudioLength.LONG,
)
params = mock_core.rpc_executor.rpc_call.call_args.args[1]
audio_config = params[2][6][1]
assert audio_config[1] == AudioLength.LONG.value
assert audio_config[6] == AudioFormat.DEBATE.value
@pytest.mark.asyncio
async def test_generate_audio_with_none_fetches_all_sources(
self, mock_core, mock_mind_map_service, mock_notebooks_api
):
"""Test generate_audio with source_ids=None fetches all sources."""
api = ArtifactsAPI(
rpc=mock_core,
drain=mock_core,
lifecycle=mock_core,
notebooks=mock_notebooks_api,
**mock_mind_map_service,
)
# Mock get_source_ids to return source IDs
mock_notebooks_api.get_source_ids.return_value = ["src_001", "src_002"]
# Mock the generation RPC call
mock_core.rpc_executor.rpc_call.return_value = [["artifact_123", "Audio", 1, None, 1]]
result = await api.generate_audio(
notebook_id="nb_123",
source_ids=None,
)
assert result.task_id == "artifact_123"
# Verify get_source_ids was called
mock_notebooks_api.get_source_ids.assert_called_once_with("nb_123")
# Verify CREATE_ARTIFACT RPC was called with fetched source IDs
mock_core.rpc_executor.rpc_call.assert_called_once()
call_args = mock_core.rpc_executor.rpc_call.call_args
params = call_args.args[1]
inner_params = params[2]
source_ids_triple = inner_params[3]
assert source_ids_triple == [[["src_001"]], [["src_002"]]]
@pytest.mark.asyncio
async def test_generate_video_source_encoding(self, mock_core, mock_mind_map_service):
"""Test generate_video has correct source encoding format."""
api = ArtifactsAPI(
rpc=mock_core,
drain=mock_core,
lifecycle=mock_core,
notebooks=MagicMock(),
**mock_mind_map_service,
)
mock_core.rpc_executor.rpc_call.return_value = [["artifact_456", "Video", 3, None, 1]]
await api.generate_video(
notebook_id="nb_123",
source_ids=["src_a", "src_b"],
)
call_args = mock_core.rpc_executor.rpc_call.call_args
params = call_args.args[1]
# Video params structure:
# [
# client_options, notebook_id,
# [None, None, 3, source_ids_triple, None, None, None, None,
# [None, None, [source_ids_double, language, instructions, None, format_code, style_code]]]
# ]
inner_params = params[2]
source_ids_triple = inner_params[3]
video_config = inner_params[8][2]
source_ids_double = video_config[0]
assert source_ids_triple == [[["src_a"]], [["src_b"]]]
assert source_ids_double == [["src_a"], ["src_b"]]
assert video_config[4] == VideoFormat.EXPLAINER.value
assert video_config[5] == VideoStyle.AUTO_SELECT.value
@pytest.mark.asyncio
async def test_generate_video_explicit_options_override_defaults(
self, mock_core, mock_mind_map_service
):
"""Explicit video format and style are encoded instead of API defaults."""
api = ArtifactsAPI(
rpc=mock_core,
drain=mock_core,
lifecycle=mock_core,
notebooks=MagicMock(),
**mock_mind_map_service,
)
mock_core.rpc_call.return_value = [["artifact_456", "Video", 3, None, 1]]
await api.generate_video(
notebook_id="nb_123",
source_ids=["src_a"],
video_format=VideoFormat.BRIEF,
video_style=VideoStyle.ANIME,
)
params = mock_core.rpc_executor.rpc_call.call_args.args[1]
video_config = params[2][8][2]
assert video_config[4] == VideoFormat.BRIEF.value
assert video_config[5] == VideoStyle.ANIME.value
@pytest.mark.asyncio
async def test_generate_video_custom_style_prompt_encoding(
self, mock_core, mock_mind_map_service
):
"""Test custom video style prompt is encoded like the live Web UI."""
api = ArtifactsAPI(
rpc=mock_core,
drain=mock_core,
lifecycle=mock_core,
notebooks=MagicMock(),
**mock_mind_map_service,
)
mock_core.rpc_call.return_value = [["artifact_456", "Video", 3, None, 1]]
await api.generate_video(
notebook_id="nb_123",
source_ids=["src_a"],
video_style=VideoStyle.CUSTOM,
style_prompt=" Use hand-drawn diagrams ",
)
params = mock_core.rpc_executor.rpc_call.call_args.args[1]
video_config = params[2][8][2]
assert video_config[5] is None
assert video_config[6] == "Use hand-drawn diagrams"
@pytest.mark.asyncio
async def test_generate_video_custom_style_requires_prompt(
self, mock_core, mock_mind_map_service
):
api = ArtifactsAPI(
rpc=mock_core,
drain=mock_core,
lifecycle=mock_core,
notebooks=MagicMock(),
**mock_mind_map_service,
)
with pytest.raises(ValidationError, match="style_prompt is required"):
await api.generate_video(
notebook_id="nb_123",
source_ids=["src_a"],
video_style=VideoStyle.CUSTOM,
)
@pytest.mark.asyncio
async def test_generate_video_custom_style_rejects_empty_prompt(
self, mock_core, mock_mind_map_service
):
api = ArtifactsAPI(
rpc=mock_core,
drain=mock_core,
lifecycle=mock_core,
notebooks=MagicMock(),
**mock_mind_map_service,
)
with pytest.raises(ValidationError, match="style_prompt is required"):
await api.generate_video(
notebook_id="nb_123",
source_ids=["src_a"],
video_style=VideoStyle.CUSTOM,
style_prompt="",
)
@pytest.mark.asyncio
async def test_generate_video_custom_style_rejects_blank_prompt(
self, mock_core, mock_mind_map_service
):
api = ArtifactsAPI(
rpc=mock_core,
drain=mock_core,
lifecycle=mock_core,
notebooks=MagicMock(),
**mock_mind_map_service,
)
with pytest.raises(ValidationError, match="style_prompt is required"):
await api.generate_video(
notebook_id="nb_123",
source_ids=["src_a"],
video_style=VideoStyle.CUSTOM,
style_prompt=" ",
)
@pytest.mark.asyncio
async def test_generate_video_style_prompt_requires_custom_style(
self, mock_core, mock_mind_map_service
):
api = ArtifactsAPI(
rpc=mock_core,
drain=mock_core,
lifecycle=mock_core,
notebooks=MagicMock(),
**mock_mind_map_service,
)
with pytest.raises(ValidationError, match="style_prompt requires"):
await api.generate_video(
notebook_id="nb_123",
source_ids=["src_a"],
video_style=VideoStyle.ANIME,
style_prompt="Use hand-drawn diagrams",
)
@pytest.mark.asyncio
async def test_generate_video_cinematic_rejects_style_prompt(
self, mock_core, mock_mind_map_service
):
api = ArtifactsAPI(
rpc=mock_core,
drain=mock_core,
lifecycle=mock_core,
notebooks=MagicMock(),
**mock_mind_map_service,
)
with pytest.raises(ValidationError, match="cinematic"):
await api.generate_video(
notebook_id="nb_123",
source_ids=["src_a"],
video_format=VideoFormat.CINEMATIC,
style_prompt="Use hand-drawn diagrams",
)
@pytest.mark.asyncio
async def test_generate_report_source_encoding(self, mock_core, mock_mind_map_service):
"""Test generate_report has correct source encoding format."""
api = ArtifactsAPI(
rpc=mock_core,
drain=mock_core,
lifecycle=mock_core,
notebooks=MagicMock(),
**mock_mind_map_service,
)
mock_core.rpc_executor.rpc_call.return_value = [["artifact_789", "Report", 2, None, 1]]
await api.generate_report(
notebook_id="nb_123",
source_ids=["src_x", "src_y", "src_z"],
)
call_args = mock_core.rpc_executor.rpc_call.call_args
params = call_args.args[1]
# Report params structure:
# [
# client_options, notebook_id,
# [None, None, 2, source_ids_triple, None, None, None,
# [None, [title, desc, None, source_ids_double, language, prompt, None, True]]]
# ]
inner_params = params[2]
source_ids_triple = inner_params[3]
report_config = inner_params[7][1]
source_ids_double = report_config[3]
assert source_ids_triple == [[["src_x"]], [["src_y"]], [["src_z"]]]
assert source_ids_double == [["src_x"], ["src_y"], ["src_z"]]
@pytest.mark.asyncio
async def test_generate_report_extra_instructions_appended(
self, mock_core, mock_mind_map_service
):
"""extra_instructions is appended to the built-in prompt with \\n\\n separator."""
api = ArtifactsAPI(
rpc=mock_core,
drain=mock_core,
lifecycle=mock_core,
notebooks=MagicMock(),
**mock_mind_map_service,
)
mock_core.rpc_call.return_value = [["artifact_789", "Report", 2, None, 1]]
await api.generate_report(
notebook_id="nb_123",
source_ids=["src_x"],
extra_instructions="Focus on financial metrics",
)
params = mock_core.rpc_executor.rpc_call.call_args.args[1]
report_config = params[2][7][1]
prompt = report_config[5]
assert "Focus on financial metrics" in prompt
assert "\n\nFocus on financial metrics" in prompt
@pytest.mark.asyncio
async def test_generate_report_extra_instructions_ignored_for_custom(
self, mock_core, mock_mind_map_service
):
"""extra_instructions has no effect when report_format is CUSTOM."""
from notebooklm.rpc.types import ReportFormat
api = ArtifactsAPI(
rpc=mock_core,
drain=mock_core,
lifecycle=mock_core,
notebooks=MagicMock(),
**mock_mind_map_service,
)
mock_core.rpc_call.return_value = [["artifact_789", "Report", 2, None, 1]]
await api.generate_report(
notebook_id="nb_123",
source_ids=["src_x"],
report_format=ReportFormat.CUSTOM,
custom_prompt="My custom prompt",
extra_instructions="Should be ignored",
)
params = mock_core.rpc_executor.rpc_call.call_args.args[1]
report_config = params[2][7][1]
prompt = report_config[5]
assert "Should be ignored" not in prompt
assert prompt == "My custom prompt"
@pytest.mark.asyncio
async def test_generate_quiz_source_encoding(self, mock_core, mock_mind_map_service):
"""Test generate_quiz has correct source encoding format."""
api = ArtifactsAPI(
rpc=mock_core,
drain=mock_core,
lifecycle=mock_core,
notebooks=MagicMock(),
**mock_mind_map_service,
)
mock_core.rpc_executor.rpc_call.return_value = [["artifact_quiz", "Quiz", 4, None, 1]]
await api.generate_quiz(
notebook_id="nb_123",
source_ids=["src_1", "src_2"],
)
call_args = mock_core.rpc_executor.rpc_call.call_args
params = call_args.args[1]
# Quiz params structure:
# [
# client_options, notebook_id,
# [None, None, 4, source_ids_triple, ...]
# ]
inner_params = params[2]
source_ids_triple = inner_params[3]
quiz_options = inner_params[9][1][7]
assert source_ids_triple == [[["src_1"]], [["src_2"]]]
assert quiz_options == [QuizQuantity.STANDARD.value, QuizDifficulty.MEDIUM.value]
@pytest.mark.asyncio
async def test_generate_quiz_explicit_options_override_defaults(
self, mock_core, mock_mind_map_service
):
"""Explicit quiz quantity and difficulty are encoded instead of defaults."""
api = ArtifactsAPI(
rpc=mock_core,
drain=mock_core,
lifecycle=mock_core,
notebooks=MagicMock(),
**mock_mind_map_service,
)
mock_core.rpc_call.return_value = [["artifact_quiz", "Quiz", 4, None, 1]]
await api.generate_quiz(
notebook_id="nb_123",
source_ids=["src_1"],
quantity=QuizQuantity.FEWER,
difficulty=QuizDifficulty.HARD,
)
params = mock_core.rpc_executor.rpc_call.call_args.args[1]
quiz_options = params[2][9][1][7]
assert quiz_options == [QuizQuantity.FEWER.value, QuizDifficulty.HARD.value]
@pytest.mark.asyncio
async def test_generate_flashcards_source_encoding(self, mock_core, mock_mind_map_service):
"""Test generate_flashcards has correct source encoding format."""
api = ArtifactsAPI(
rpc=mock_core,
drain=mock_core,
lifecycle=mock_core,
notebooks=MagicMock(),
**mock_mind_map_service,
)
mock_core.rpc_executor.rpc_call.return_value = [["artifact_fc", "Flashcards", 4, None, 1]]
await api.generate_flashcards(
notebook_id="nb_123",
source_ids=["src_flash"],
)
call_args = mock_core.rpc_executor.rpc_call.call_args
params = call_args.args[1]
inner_params = params[2]
source_ids_triple = inner_params[3]
flashcard_options = inner_params[9][1][6]
assert source_ids_triple == [[["src_flash"]]]
assert flashcard_options == [QuizDifficulty.MEDIUM.value, QuizQuantity.STANDARD.value]
@pytest.mark.asyncio
async def test_generate_flashcards_explicit_options_override_defaults(
self, mock_core, mock_mind_map_service
):
"""Explicit flashcard quantity and difficulty preserve flashcard option order."""
api = ArtifactsAPI(
rpc=mock_core,
drain=mock_core,
lifecycle=mock_core,
notebooks=MagicMock(),
**mock_mind_map_service,
)
mock_core.rpc_call.return_value = [["artifact_fc", "Flashcards", 4, None, 1]]
await api.generate_flashcards(
notebook_id="nb_123",
source_ids=["src_flash"],
quantity=QuizQuantity.FEWER,
difficulty=QuizDifficulty.EASY,
)
params = mock_core.rpc_executor.rpc_call.call_args.args[1]
flashcard_options = params[2][9][1][6]
assert flashcard_options == [QuizDifficulty.EASY.value, QuizQuantity.FEWER.value]
@pytest.mark.asyncio
async def test_generate_infographic_source_encoding(self, mock_core, mock_mind_map_service):
"""Test generate_infographic has correct source encoding format."""
api = ArtifactsAPI(
rpc=mock_core,
drain=mock_core,
lifecycle=mock_core,
notebooks=MagicMock(),
**mock_mind_map_service,
)
mock_core.rpc_executor.rpc_call.return_value = [
["artifact_info", "Infographic", 7, None, 1]
]
await api.generate_infographic(
notebook_id="nb_123",
source_ids=["src_info_1", "src_info_2"],
)
call_args = mock_core.rpc_executor.rpc_call.call_args
params = call_args.args[1]
inner_params = params[2]
source_ids_triple = inner_params[3]
infographic_config = inner_params[14][0]
assert source_ids_triple == [[["src_info_1"]], [["src_info_2"]]]
assert infographic_config[3] == InfographicOrientation.LANDSCAPE.value
assert infographic_config[4] == InfographicDetail.STANDARD.value
assert infographic_config[5] == InfographicStyle.AUTO_SELECT.value
@pytest.mark.asyncio
async def test_generate_infographic_visual_option_encoding(
self, mock_core, mock_mind_map_service
):
"""Test generate_infographic encodes explicit visual options in config slots."""
api = ArtifactsAPI(
rpc=mock_core,
drain=mock_core,
lifecycle=mock_core,
notebooks=MagicMock(),
**mock_mind_map_service,
)
mock_core.rpc_executor.rpc_call.return_value = [
["artifact_info", "Infographic", 7, None, 1]
]
await api.generate_infographic(
notebook_id="nb_123",
source_ids=["src_info_1"],
orientation=InfographicOrientation.PORTRAIT,
detail_level=InfographicDetail.DETAILED,
style=InfographicStyle.PROFESSIONAL,
)
call_args = mock_core.rpc_executor.rpc_call.call_args
params = call_args.args[1]
inner_params = params[2]
infographic_config = inner_params[14][0]
assert infographic_config[3] == InfographicOrientation.PORTRAIT.value
assert infographic_config[4] == InfographicDetail.DETAILED.value
assert infographic_config[5] == InfographicStyle.PROFESSIONAL.value
@pytest.mark.asyncio
async def test_generate_slide_deck_source_encoding(self, mock_core, mock_mind_map_service):
"""Test generate_slide_deck has correct source encoding format."""
api = ArtifactsAPI(
rpc=mock_core,
drain=mock_core,
lifecycle=mock_core,
notebooks=MagicMock(),
**mock_mind_map_service,
)
mock_core.rpc_executor.rpc_call.return_value = [["artifact_slide", "Slides", 8, None, 1]]
await api.generate_slide_deck(
notebook_id="nb_123",
source_ids=["src_slide"],
)
call_args = mock_core.rpc_executor.rpc_call.call_args
params = call_args.args[1]
inner_params = params[2]
source_ids_triple = inner_params[3]
slide_config = inner_params[16][0]
assert source_ids_triple == [[["src_slide"]]]
assert slide_config[2] == SlideDeckFormat.DETAILED_DECK.value
assert slide_config[3] == SlideDeckLength.DEFAULT.value
@pytest.mark.asyncio
async def test_generate_slide_deck_explicit_options_override_defaults(
self, mock_core, mock_mind_map_service
):
"""Explicit slide deck format and length are encoded instead of defaults."""
api = ArtifactsAPI(
rpc=mock_core,
drain=mock_core,
lifecycle=mock_core,
notebooks=MagicMock(),
**mock_mind_map_service,
)
mock_core.rpc_call.return_value = [["artifact_slide", "Slides", 8, None, 1]]
await api.generate_slide_deck(
notebook_id="nb_123",
source_ids=["src_slide"],
slide_format=SlideDeckFormat.PRESENTER_SLIDES,
slide_length=SlideDeckLength.SHORT,
)
params = mock_core.rpc_executor.rpc_call.call_args.args[1]
slide_config = params[2][16][0]
assert slide_config[2] == SlideDeckFormat.PRESENTER_SLIDES.value
assert slide_config[3] == SlideDeckLength.SHORT.value
@pytest.mark.asyncio
async def test_generate_data_table_source_encoding(self, mock_core, mock_mind_map_service):
"""Test generate_data_table has correct source encoding format."""
api = ArtifactsAPI(
rpc=mock_core,
drain=mock_core,
lifecycle=mock_core,
notebooks=MagicMock(),
**mock_mind_map_service,
)
mock_core.rpc_executor.rpc_call.return_value = [["artifact_table", "Table", 9, None, 1]]
await api.generate_data_table(
notebook_id="nb_123",
source_ids=["src_table_1", "src_table_2"],
)
call_args = mock_core.rpc_executor.rpc_call.call_args
params = call_args.args[1]
inner_params = params[2]
source_ids_triple = inner_params[3]
assert source_ids_triple == [[["src_table_1"]], [["src_table_2"]]]
@pytest.mark.asyncio
async def test_generate_mind_map_source_encoding(
self, mock_core, mock_mind_map_service, mock_notebooks_api
):
"""Test generate_mind_map has correct source encoding format."""
api = ArtifactsAPI(
rpc=mock_core,
drain=mock_core,
lifecycle=mock_core,
notebooks=mock_notebooks_api,
**mock_mind_map_service,
)
# Mock get_source_ids to return source IDs
mock_notebooks_api.get_source_ids.return_value = ["src_mm_1", "src_mm_2"]
# Mock the mind map generation RPC call
mock_core.rpc_executor.rpc_call.return_value = [['{"name": "Mind Map", "children": []}']]
await api.generate_mind_map(
notebook_id="nb_123",
source_ids=None, # Will fetch sources
)
# Verify get_source_ids was called
mock_notebooks_api.get_source_ids.assert_called_once_with("nb_123")
# After the mind-map relocation, ``generate_mind_map`` also drives the CREATE_NOTE +
# UPDATE_NOTE calls itself (previously delegated to NotesAPI), so
# rpc_call is invoked three times. The source-encoding assertion
# targets the GENERATE_MIND_MAP call specifically.
generate_call = next(
c
for c in mock_core.rpc_executor.rpc_call.call_args_list
if c.args[0].name == "GENERATE_MIND_MAP"
)
params = generate_call.args[1]
# Mind map uses source_ids_nested = [[[sid]] for sid]
source_ids_nested = params[0]
assert source_ids_nested == [[["src_mm_1"]], [["src_mm_2"]]]
@pytest.mark.asyncio
async def test_generate_mind_map_passes_language_and_instructions(
self, mock_core, mock_mind_map_service
):
"""Test generate_mind_map passes language and instructions to RPC payload."""
api = ArtifactsAPI(
rpc=mock_core,
drain=mock_core,
lifecycle=mock_core,
notebooks=MagicMock(),
**mock_mind_map_service,
)
mock_core.rpc_executor.rpc_call.return_value = [['{"name": "Mind Map", "children": []}']]
await api.generate_mind_map(
notebook_id="nb_123",
source_ids=["src_1"],
language="ja",
instructions="Focus on key themes",
)
# Pick the GENERATE_MIND_MAP call specifically — CREATE_NOTE and
# UPDATE_NOTE are now invoked alongside it.
generate_call = next(
c
for c in mock_core.rpc_executor.rpc_call.call_args_list
if c.args[0].name == "GENERATE_MIND_MAP"
)
params = generate_call.args[1]
# params[5] should contain the mind map config with language and instructions
mind_map_config = params[5]
assert mind_map_config[1][0][1] == "Focus on key themes"
assert mind_map_config[2] == "ja"
@pytest.mark.asyncio
async def test_suggest_reports_uses_get_suggested_reports(
self, mock_core, mock_mind_map_service
):
"""Test suggest_reports uses GET_SUGGESTED_REPORTS RPC."""
from notebooklm.rpc.types import RPCMethod
api = ArtifactsAPI(
rpc=mock_core,
drain=mock_core,
lifecycle=mock_core,
notebooks=MagicMock(),
**mock_mind_map_service,
)
# Mock the GET_SUGGESTED_REPORTS RPC call
# Response format: [[[title, description, null, null, prompt, audience_level], ...]]
mock_core.rpc_executor.rpc_call.return_value = [
[["Report Title", "Description", None, None, "Custom prompt", 2]]
]
result = await api.suggest_reports(notebook_id="nb_123")
# Verify GET_SUGGESTED_REPORTS was called with correct params
mock_core.rpc_executor.rpc_call.assert_called_once()
call_args = mock_core.rpc_executor.rpc_call.call_args
assert call_args.args[0] == RPCMethod.GET_SUGGESTED_REPORTS
assert call_args.args[1] == [[2], "nb_123"]
# Verify result parsing
assert len(result) == 1
assert result[0].title == "Report Title"
class TestArtifactValidationFootguns:
"""Regression tests for the #1874 validation footguns (B: generate_report
positional coercion; C: export exactly-one-of + keyword-only content)."""
def _api(self, mock_core, mock_mind_map_service):
return ArtifactsAPI(
rpc=mock_core,
drain=mock_core,
lifecycle=mock_core,
notebooks=MagicMock(),
**mock_mind_map_service,
)
# --- B: generate_report ------------------------------------------------
@pytest.mark.asyncio
async def test_generate_report_source_ids_in_format_slot_raises(
self, mock_core, mock_mind_map_service
):
"""generate_report("nb", ["s1", "s2"]) -> ValidationError (not TypeError).
The second positional is ``report_format`` (unlike every sibling
``generate_*`` where it is ``source_ids``); passing a list must fail
loudly with a message that points at ``source_ids=``.
"""
api = self._api(mock_core, mock_mind_map_service)
with pytest.raises(ValidationError, match="source_ids"):
await api.generate_report("nb_123", ["s1", "s2"])
@pytest.mark.asyncio
async def test_generate_report_accepts_enum_format(self, mock_core, mock_mind_map_service):
"""A valid ReportFormat enum still works (idempotent coercion)."""
from notebooklm.rpc.types import ReportFormat
api = self._api(mock_core, mock_mind_map_service)
mock_core.rpc_executor.rpc_call.return_value = [["a", "Report", 2, None, 1]]
await api.generate_report("nb_123", ReportFormat.STUDY_GUIDE, source_ids=["src_x"])
mock_core.rpc_executor.rpc_call.assert_called_once()
@pytest.mark.asyncio
async def test_generate_report_accepts_valid_format_string(
self, mock_core, mock_mind_map_service
):
"""A valid str-enum value ("briefing_doc") is coerced, not rejected."""
api = self._api(mock_core, mock_mind_map_service)
mock_core.rpc_executor.rpc_call.return_value = [["a", "Report", 2, None, 1]]
await api.generate_report("nb_123", "briefing_doc", source_ids=["src_x"])
mock_core.rpc_executor.rpc_call.assert_called_once()
# --- C: export ---------------------------------------------------------
@pytest.mark.asyncio
async def test_export_neither_target_raises(self, mock_core, mock_mind_map_service):
"""export("nb") with neither artifact_id nor content -> ValidationError."""
api = self._api(mock_core, mock_mind_map_service)
with pytest.raises(ValidationError, match="exactly one"):
await api.export("nb_123")
@pytest.mark.asyncio
async def test_export_both_targets_raises(self, mock_core, mock_mind_map_service):
"""export with both artifact_id and content -> ValidationError."""
api = self._api(mock_core, mock_mind_map_service)
with pytest.raises(ValidationError, match="exactly one"):
await api.export("nb_123", artifact_id="a", content="c")
@pytest.mark.asyncio
async def test_export_by_artifact_id_params(self, mock_core, mock_mind_map_service):
"""export("nb", "art_001") sends [None, "art_001", None, "Export", DOCS]."""
from notebooklm.rpc import ExportType, RPCMethod
api = self._api(mock_core, mock_mind_map_service)
await api.export("nb_123", "art_001")
call_args = mock_core.rpc_call.call_args
assert call_args.args[0] == RPCMethod.EXPORT_ARTIFACT
assert call_args.args[1] == [None, "art_001", None, "Export", int(ExportType.DOCS)]
@pytest.mark.asyncio
async def test_export_by_content_params(self, mock_core, mock_mind_map_service):
"""export("nb", content="hello") sends [None, None, "hello", ...]."""
api = self._api(mock_core, mock_mind_map_service)
await api.export("nb_123", content="hello")
params = mock_core.rpc_call.call_args.args[1]
assert params[0] is None
assert params[1] is None
assert params[2] == "hello"
@pytest.mark.asyncio
async def test_export_positional_title_binds_to_title_not_content(
self, mock_core, mock_mind_map_service
):
"""export("nb", "art_001", "My Title") binds title (slot 3), matching siblings."""
api = self._api(mock_core, mock_mind_map_service)
await api.export("nb_123", "art_001", "My Title")
params = mock_core.rpc_call.call_args.args[1]
# [client_opt, artifact_id, content, title, export_type]
assert params[1] == "art_001"
assert params[2] is None # content stays None (not mis-bound)
assert params[3] == "My Title"
class TestEmptySourceIds:
"""Tests for edge cases with empty source lists."""
@pytest.mark.asyncio
async def test_generate_with_empty_source_list(self, mock_core, mock_mind_map_service):
"""Test generation with empty source_ids list produces empty arrays."""
api = ArtifactsAPI(
rpc=mock_core,
drain=mock_core,
lifecycle=mock_core,
notebooks=MagicMock(),
**mock_mind_map_service,
)
mock_core.rpc_executor.rpc_call.return_value = [["artifact_empty", "Audio", 1, None, 1]]
await api.generate_audio(
notebook_id="nb_123",
source_ids=[], # Explicit empty list
)
call_args = mock_core.rpc_executor.rpc_call.call_args
params = call_args.args[1]
inner_params = params[2]
source_ids_triple = inner_params[3]
source_ids_double = inner_params[6][1][3]
# Empty list should produce empty arrays
assert source_ids_triple == []
assert source_ids_double == []
@pytest.mark.asyncio
async def test_ask_with_empty_source_list(self, mock_core):
"""Test ask with empty source_ids list."""
api = _chat_from_mock_core(mock_core)
await api.ask(
notebook_id="nb_123",
question="Test?",
source_ids=[],
)
# Verify the sources_array is empty in the request
body = mock_core._last_chat_request["body"]
import re
from urllib.parse import unquote
match = re.search(r"f\.req=([^&]+)", body)
assert match, f"f.req= missing from body: {body!r}"
f_req_encoded = match.group(1)
f_req_decoded = unquote(f_req_encoded)
f_req_data = json.loads(f_req_decoded)
params = json.loads(f_req_data[1])
sources_array = params[0]
assert sources_array == []
class TestGetSourceIds:
"""Tests for NotebooksAPI.get_source_ids method."""
@pytest.mark.asyncio
async def test_get_source_ids_extracts_correctly(self):
"""Test get_source_ids correctly extracts source IDs from notebook data."""
from notebooklm._notebooks import NotebooksAPI
from tests._fixtures.fake_core import make_fake_core
rpc = AsyncMock()
core = make_fake_core(rpc_call=rpc)
api = NotebooksAPI(core.rpc_executor)
# Mock notebook data with multiple sources
# Structure: notebook_data[0][1] = sources list
# Each source: [["source_id"], "Source Title", ...]
rpc.return_value = [
[
"nb_123", # notebook_info[0]
[
# sources list - source[0] is ["id"], source[0][0] is the id
[["source_aaa"], "Source A Title"],
[["source_bbb"], "Source B Title"],
[["source_ccc"], "Source C Title"],
],
]
]
source_ids = await api.get_source_ids("nb_123")
assert source_ids == ["source_aaa", "source_bbb", "source_ccc"]
@pytest.mark.asyncio
async def test_get_source_ids_handles_empty_notebook(self):
"""Test get_source_ids handles notebook with no sources."""
from notebooklm._notebooks import NotebooksAPI
from tests._fixtures.fake_core import make_fake_core
rpc = AsyncMock()
core = make_fake_core(rpc_call=rpc)
api = NotebooksAPI(core.rpc_executor)
rpc.return_value = [["nb_123", []]]
source_ids = await api.get_source_ids("nb_123")
assert source_ids == []
@pytest.mark.asyncio
async def test_get_source_ids_handles_null_response(self):
"""Test get_source_ids handles null API response."""
from notebooklm._notebooks import NotebooksAPI
from tests._fixtures.fake_core import make_fake_core
rpc = AsyncMock()
core = make_fake_core(rpc_call=rpc)
api = NotebooksAPI(core.rpc_executor)
rpc.return_value = None
source_ids = await api.get_source_ids("nb_123")
assert source_ids == []
@pytest.mark.asyncio
async def test_get_source_ids_handles_malformed_data(self):
"""Test get_source_ids handles malformed source data gracefully."""
from notebooklm._notebooks import NotebooksAPI
from tests._fixtures.fake_core import make_fake_core
rpc = AsyncMock()
core = make_fake_core(rpc_call=rpc)
api = NotebooksAPI(core.rpc_executor)
# Malformed data - missing nested structure
# Structure: source[0] must be a list, source[0][0] must be a string
rpc.return_value = [
[
"nb_123",
[
[None, "Missing ID"], # Invalid: source[0] is None
[["valid_id"], "Valid Source"], # Valid
"not a list", # Invalid: not a list at all
],
]
]
source_ids = await api.get_source_ids("nb_123")
# Should only extract the valid source
assert source_ids == ["valid_id"]