Files
teng-lin--notebooklm-py/tests/unit/test_chat_save_answer_as_note.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

248 lines
9.0 KiB
Python

"""Unit tests for ``ChatAPI.save_answer_as_note`` (Phase 6, issue #660).
Saved-chat ownership moved from the former ``NotesAPI.create_from_chat``
forwarder to ``ChatAPI.save_answer_as_note`` in refactor-history.md Step 8 /
ADR-0013; the forwarder was removed in v0.7.0 and the encoder +
title-derivation semantics now live here.
These tests pin:
* the empty-references guard (raises ``ValueError``, mirroring the
original saved-from-chat contract),
* the default-title derivation (derived from ``ask_result.answer`` —
``AskResult`` has no ``question`` field today),
* the explicit-title override path,
* the 7-element CREATE_NOTE params payload + ``[2]`` mode flag,
* the malformed-response failure mode.
Wave 8 of the session-decoupling plan (ADR-0014 Rule 2 Corollary): the
chat-local ``ChatRuntime`` Protocol was deleted; ``ChatAPI`` takes its
four direct collaborators (RpcCaller, RuntimeTransport, ReqidCounter,
LoopGuard) by keyword argument. ``save_answer_as_note`` only touches
the ``rpc`` collaborator, so the other three are mocked without specs.
"""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock
import pytest
from notebooklm._chat import ChatAPI
from notebooklm._runtime.contracts import RpcCaller
from notebooklm.rpc import RPCMethod
from notebooklm.types import AskResult, ChatReference
@pytest.fixture
def mock_rpc() -> MagicMock:
"""Narrow ``RpcCaller`` fake — the only collaborator this surface uses.
``save_answer_as_note`` only calls ``rpc.rpc_call`` — no transport /
reqid / loop-guard surface is exercised. The ``AsyncMock`` is wired
into the ``MagicMock(spec=...)`` via its constructor so the ADR-0007
meta-lint stays clean (no post-hoc attribute assignment).
"""
return MagicMock(spec=RpcCaller, rpc_call=AsyncMock())
@pytest.fixture
def chat_api(mock_rpc: MagicMock) -> ChatAPI:
"""A ``ChatAPI`` instance wired with narrow collaborator fakes.
A ``MagicMock(get_source_ids=AsyncMock(...))`` notebooks resolver
is injected so the ``NotebooksAPI`` fallback in
``ChatAPI.__init__`` does not try to wrap ``rpc``; the
``NotebookSourceIdProvider`` protocol surface is small enough that
a ``MagicMock`` with a single async stub satisfies it without
falling into ADR-0007's forbidden-attribute-assignment lint
(the stub is passed via constructor injection).
"""
notebooks = MagicMock(get_source_ids=AsyncMock(return_value=[]))
return ChatAPI(
rpc=mock_rpc,
transport=MagicMock(),
reqid=MagicMock(),
loop_guard=MagicMock(),
notebooks=notebooks,
)
def _make_ask_result(
answer: str = "One fruit mentioned is apples [1].",
n_refs: int = 1,
) -> AskResult:
refs = [
ChatReference(
source_id=f"src-{i}",
citation_number=i + 1,
cited_text=f"passage {i}",
start_char=0,
end_char=9,
chunk_id=f"chunk-{i}",
)
for i in range(n_refs)
]
return AskResult(
answer=answer,
conversation_id="conv-1",
turn_number=1,
is_follow_up=False,
references=refs,
raw_response="",
)
class TestSaveAnswerAsNote:
"""Behavioural tests for ``ChatAPI.save_answer_as_note``."""
@pytest.mark.asyncio
async def test_empty_references_raises_value_error(self, chat_api: ChatAPI) -> None:
ask_result = _make_ask_result(n_refs=0)
with pytest.raises(ValueError, match="non-empty"):
await chat_api.save_answer_as_note("nb-1", ask_result)
@pytest.mark.asyncio
async def test_default_title_derives_from_answer_not_question(
self, chat_api: ChatAPI, mock_rpc: MagicMock
) -> None:
# Wrapped response shape — slot [0] is a list whose first
# element is the note_id.
mock_rpc.rpc_call.return_value = [
[
"note-new-id",
"One fruit mentioned is apples [1].",
[2, "user", [123, 456]],
[[]],
"ServerTitle",
[],
]
]
ask_result = _make_ask_result()
note = await chat_api.save_answer_as_note("nb-1", ask_result)
# Server-returned title is what surfaces in the Note.
assert note.title == "ServerTitle"
# The RPC call received our derived default title (from .answer).
sent_title = mock_rpc.rpc_call.call_args[0][1][4]
assert sent_title.startswith("Chat: ")
# Derivation truncates the answer to 50 chars — confirm the
# source field is .answer, not anything question-derived.
assert "fruit" in sent_title or "apples" in sent_title
@pytest.mark.asyncio
async def test_explicit_title_overrides_default(
self, chat_api: ChatAPI, mock_rpc: MagicMock
) -> None:
mock_rpc.rpc_call.return_value = [
"note-new-id",
"answer",
[2, "u", [1, 2]],
[[]],
"My Title", # server echoes the title back
[],
]
ask_result = _make_ask_result()
note = await chat_api.save_answer_as_note("nb-1", ask_result, title="My Title")
assert mock_rpc.rpc_call.call_args[0][1][4] == "My Title"
assert note.title == "My Title"
@pytest.mark.asyncio
async def test_uses_create_note_rpc_with_mode_flag_2(
self, chat_api: ChatAPI, mock_rpc: MagicMock
) -> None:
mock_rpc.rpc_call.return_value = [
"note-id",
"x",
[2, "u", [1, 2]],
[[]],
"T",
[],
]
ask_result = _make_ask_result()
await chat_api.save_answer_as_note("nb-1", ask_result, title="T")
call_args = mock_rpc.rpc_call.call_args
assert call_args[0][0] == RPCMethod.CREATE_NOTE
params = call_args[0][1]
# 7-element params with [2] mode flag at slot 2 (vs [1] for blank-note variant)
assert len(params) == 7
assert params[2] == [2]
assert params[6] == [2]
# Only ONE RPC call — no follow-up UPDATE_NOTE.
assert mock_rpc.rpc_call.call_count == 1
@pytest.mark.asyncio
async def test_missing_note_id_in_response_raises(
self, chat_api: ChatAPI, mock_rpc: MagicMock
) -> None:
# Malformed response (note_id slot is None / not a str) must
# surface a clear ``RuntimeError`` rather than returning a Note
# with id="".
mock_rpc.rpc_call.return_value = [None, "x", [], [], "T", []]
ask_result = _make_ask_result()
with pytest.raises(RuntimeError, match="no note ID"):
await chat_api.save_answer_as_note("nb-1", ask_result, title="T")
@pytest.mark.asyncio
async def test_returned_note_carries_answer_with_markers(
self, chat_api: ChatAPI, mock_rpc: MagicMock
) -> None:
"""``Note.content`` is the answer text WITH ``[N]`` markers.
Rich citation anchors live server-side and surface via the
NotebookLM UI; the dataclass only carries the raw answer.
"""
mock_rpc.rpc_call.return_value = [
"note-id",
"ignored-server-content",
[2, "u", [1, 2]],
[[]],
"ServerTitle",
[],
]
ask_result = _make_ask_result(answer="The answer is X [1].")
note = await chat_api.save_answer_as_note("nb-1", ask_result)
assert note.content == "The answer is X [1]."
assert note.notebook_id == "nb-1"
@pytest.mark.asyncio
async def test_created_at_populated_wrapped_shape(
self, chat_api: ChatAPI, mock_rpc: MagicMock
) -> None:
"""``save_answer_as_note`` decodes the creation timestamp from the
note metadata envelope (``note[2][2][0]``) in the wrapped shape
(issue #1529). Pin the EPOCH INT (TZ-invariant), not a wall-time
string."""
mock_rpc.rpc_call.return_value = [
[
"note-id",
"One fruit mentioned is apples [1].",
[2, "400237754469", [1778936820, 976814000]],
[[]],
"ServerTitle",
[],
]
]
ask_result = _make_ask_result()
note = await chat_api.save_answer_as_note("nb-1", ask_result)
assert note.created_at is not None
assert int(note.created_at.timestamp()) == 1778936820
@pytest.mark.asyncio
async def test_created_at_populated_flat_shape(
self, chat_api: ChatAPI, mock_rpc: MagicMock
) -> None:
"""``save_answer_as_note`` decodes the timestamp from the FLAT
response shape too (``result`` is the inner envelope; issue #1529)."""
mock_rpc.rpc_call.return_value = [
"note-id",
"answer",
[2, "400237754469", [1778936820, 976814000]],
[[]],
"ServerTitle",
[],
]
ask_result = _make_ask_result()
note = await chat_api.save_answer_as_note("nb-1", ask_result)
assert note.created_at is not None
assert int(note.created_at.timestamp()) == 1778936820