Files
teng-lin--notebooklm-py/tests/unit/test_save_chat_as_note_encoder.py
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:30:13 +08:00

384 lines
17 KiB
Python

"""Unit tests for the saved-from-chat CREATE_NOTE encoder (issue #660).
These tests exercise ``_chat.notes.build_save_chat_as_note_params`` and
pin its output against the wire-captured payload at
``tests/unit/fixtures/save_chat_as_note_create_note_request.json``.
The golden test is the most important one: a byte-exact match (via
deep-equal of the decoded JSON structure) guarantees that the encoder
produces the same payload Google's web UI sends when its "Save to note"
button is clicked. Drift from that payload risks the server silently
dropping citation anchors and reverting the note to plain text.
The encoder moved from ``_mind_map.py`` to ``_chat/notes.py`` in
Phase 6 (refactor-history.md Step 8, ADR-0013); the test imports were updated
accordingly.
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from notebooklm._chat.notes import (
_CITATION_MARKER_RE,
_resolve_reference,
_strip_citation_markers,
build_save_chat_as_note_params,
)
from notebooklm.types import ChatReference
FIXTURES_DIR = Path(__file__).parent / "fixtures"
def _load_request_fixture() -> dict:
return json.loads((FIXTURES_DIR / "save_chat_as_note_create_note_request.json").read_text())
class TestStripCitationMarkers:
"""Tests for the ``[N]`` marker stripping helper."""
def test_single_marker_with_leading_space(self):
clean, positions = _strip_citation_markers("One fruit mentioned is apples [1].")
assert clean == "One fruit mentioned is apples."
# Marker [1] was at "apples"+space+marker position; after stripping,
# the position in the clean text where the marker was is just after
# "apples" = char 29 (exclusive end of "One fruit mentioned is apples").
assert positions == [(1, 29)]
def test_no_markers(self):
clean, positions = _strip_citation_markers("plain answer, no citations")
assert clean == "plain answer, no citations"
assert positions == []
def test_multiple_markers_segmented(self):
clean, positions = _strip_citation_markers("X is true [1]. Y is also true [2].")
# Stripping " [1]" then " [2]" yields "X is true. Y is also true."
assert clean == "X is true. Y is also true."
# [1] anchors clean[0..9] = "X is true"; [2] anchors clean[0..25] = the whole
# text before the trailing period
assert positions == [(1, 9), (2, 25)]
def test_adjacent_markers(self):
clean, positions = _strip_citation_markers("X [1][2].")
# First match consumes " [1]" with leading space, leaving "X[2]."
# Second match consumes "[2]" with NO leading space (regex space is optional)
assert clean == "X."
assert positions == [(1, 1), (2, 1)]
def test_marker_at_start(self):
clean, positions = _strip_citation_markers("[1] starts here.")
# No leading space; the optional " " doesn't match
assert clean == " starts here."
assert positions == [(1, 0)]
def test_regex_compiled(self):
# Sanity: ensure the module-level compile didn't drift.
assert _CITATION_MARKER_RE.pattern == r" ?\[(\d+)\]"
class TestBuildSaveChatAsNoteParamsGolden:
"""Byte-exact golden tests against the captured curl payload.
The fixture was decoded from the user-supplied curl request sent by
the NotebookLM web UI when "Save to note" was clicked on a chat
answer with a single citation. The encoder MUST match it exactly:
if our output diverges in shape, ordering, or scalar types
(e.g. emitting ``false`` where the wire uses ``0``), the server may
silently strip citation anchors.
"""
def test_golden_single_citation(self):
fixture = _load_request_fixture()
expected_params = fixture["params"]
notebook_id = expected_params[0]
answer_text = expected_params[1]
title = expected_params[4]
# Reconstruct the ChatReference the web UI would have built from
# the same chat response. Slot [3][0] of the captured request
# carries the source-passage descriptor; we read source_id /
# chunk_id / span / cited_text out of it.
passage = expected_params[3][0]
cited_text = passage[4][0][0][2][0][0][2][0] # follow the nested wrapper
# passage[3] = [[null, start, end]]; passage[5] = [[[passage_id], source_id]];
# passage[6] = [chunk_id]
start_char = passage[3][0][1]
end_char = passage[3][0][2]
passage_id = passage[5][0][0][0]
source_id = passage[5][0][1]
chunk_id = passage[6][0]
references = [
ChatReference(
source_id=source_id,
citation_number=1,
cited_text=cited_text,
start_char=start_char,
end_char=end_char,
chunk_id=chunk_id,
passage_id=passage_id,
)
]
actual_params = build_save_chat_as_note_params(notebook_id, answer_text, references, title)
# Deep-equal of the structured form. We also check the compact
# JSON serialization to catch boolean / null divergence that
# deep-equal would miss (Python's `False == 0` quirk).
assert actual_params == expected_params
# Serialize both with the same separators the rpc encoder uses
# (rpc/encoder.py:39: separators=(",", ":")) and compare strings.
# This is the strict byte-exact check.
actual_json = json.dumps(actual_params, separators=(",", ":"))
expected_json = json.dumps(expected_params, separators=(",", ":"))
assert actual_json == expected_json
def test_passage_id_falls_back_to_chunk_id_when_unset(self):
"""When ChatReference.passage_id is None (the production path,
since the chat parser doesn't currently surface this UUID), the
encoder fills the 4th-UUID slot with chunk_id as a placeholder."""
ref = ChatReference(
source_id="src-1",
citation_number=1,
cited_text="text",
start_char=0,
end_char=4,
chunk_id="chunk-1",
passage_id=None,
)
params = build_save_chat_as_note_params("nb-id", "X [1].", [ref], "Title")
# Slot [3][0][5][0] = [[passage_id], source_id]
# With passage_id=None, the encoder uses chunk_id.
passage_descriptor = params[3][0]
assert passage_descriptor[5][0][0] == ["chunk-1"]
assert passage_descriptor[5][0][1] == "src-1"
# The keyed version in rich_content uses the same placeholder.
assert params[5][3][0][1][5][0][0] == ["chunk-1"]
def test_passage_id_overrides_chunk_id_when_set(self):
"""When ChatReference.passage_id IS set (forward-compat path),
the encoder uses it for the 4th-UUID slot regardless of chunk_id."""
ref = ChatReference(
source_id="src-1",
citation_number=1,
cited_text="text",
start_char=0,
end_char=4,
chunk_id="chunk-1",
passage_id="passage-real-uuid",
)
params = build_save_chat_as_note_params("nb-id", "X [1].", [ref], "Title")
passage_descriptor = params[3][0]
assert passage_descriptor[5][0][0] == ["passage-real-uuid"]
assert passage_descriptor[5][0][1] == "src-1"
def test_encoder_serializes_booleans_as_zero_not_false(self):
"""Regression guard: every "rendering flags" slot the ENCODER
emits must serialize to ``0`` (integer), never ``false`` (boolean).
Python's ``json.dumps(False)`` emits ``false`` while
``json.dumps(0)`` emits ``0`` — the wire payload uses ``0``, and
the server's strict request channel won't normalize this. We
assert on the encoder's actual output (not on the static fixture)
so the check can't pass tautologically: if a future change makes
any slot emit ``True``/``False``, this test catches it."""
ref = ChatReference(
source_id="src-1",
citation_number=1,
cited_text="passage text",
start_char=0,
end_char=12,
chunk_id="chunk-1",
)
params = build_save_chat_as_note_params(
"nb-id", "Answer with citation [1].", [ref], "Title"
)
actual_json = json.dumps(params, separators=(",", ":"))
assert "false" not in actual_json
assert "true" not in actual_json
assert "[0,0,0,null,null,null,null,0,0]" in actual_json
class TestBuildSaveChatAsNoteParamsBehavior:
"""Behavioural tests beyond the single-citation golden."""
def _make_ref(self, n: int, chunk_id: str, source_id: str) -> ChatReference:
return ChatReference(
source_id=source_id,
citation_number=n,
cited_text=f"passage text {n}",
start_char=0,
end_char=14,
chunk_id=chunk_id,
)
def test_empty_references_raises(self):
with pytest.raises(ValueError, match="non-empty references"):
build_save_chat_as_note_params("nb-id", "no citations here.", [], "Title")
def test_references_without_chunk_id_raises(self):
ref = ChatReference(source_id="src-1", chunk_id=None)
with pytest.raises(ValueError, match="chunk_id"):
build_save_chat_as_note_params("nb-id", "X [1].", [ref], "Title")
def test_multi_citation_produces_one_anchor_per_marker(self):
refs = [
self._make_ref(1, "chunk-a", "src-a"),
self._make_ref(2, "chunk-b", "src-b"),
]
params = build_save_chat_as_note_params(
"nb-id", "First fact [1]. Second fact [2].", refs, "Title"
)
# Two unique chunks → two source_passages entries
assert len(params[3]) == 2
# Two markers → two chunk_refs in rich_content[0][1]
chunk_refs = params[5][0][1]
assert len(chunk_refs) == 2
# Cumulative spans: [1] anchors clean[0..10] = "First fact"
# [2] anchors clean[0..23] = "First fact. Second fact" (23 chars)
assert chunk_refs[0] == [["chunk-a"], [None, 0, 10]]
assert chunk_refs[1] == [["chunk-b"], [None, 0, 23]]
def test_dedupes_source_passages_by_chunk_id(self):
# Same chunk_id under two distinct citation numbers (the realistic
# repeat: one chunk cited at two markers) → only one source_passages
# entry, but each [N] marker still gets its own anchor. (Previously
# this pinned the numbered-positional fallback — both refs numbered 1
# and [2] resolved via references[1]; that fallback is now gated to
# unnumbered candidates so a numbering hole can't mis-anchor.)
refs = [
self._make_ref(1, "chunk-shared", "src-shared"),
self._make_ref(2, "chunk-shared", "src-shared"),
]
params = build_save_chat_as_note_params("nb-id", "A [1] B [2]", refs, "Title")
assert len(params[3]) == 1 # deduped
# Two markers found in answer_text → two chunk_refs
assert len(params[5][0][1]) == 2
def test_marker_without_matching_reference_is_skipped(self, caplog):
# Answer has [1] and [99] but references only covers [1]
refs = [self._make_ref(1, "chunk-1", "src-1")]
with caplog.at_level("WARNING"):
params = build_save_chat_as_note_params("nb-id", "A [1] B [99]", refs, "Title")
chunk_refs = params[5][0][1]
# Only one anchor — the [99] marker is silently dropped with a warning
assert len(chunk_refs) == 1
assert any("[99]" in record.message or "99" in record.message for record in caplog.records)
def test_holed_numbering_skips_missing_marker_instead_of_mis_anchoring(self, caplog):
"""Regression (codex review of the citation hardening): no wrong anchors.
The wire parser keeps RAW ordinals when it skips a malformed citation
row, so ``[good#1, skipped#2, good#3]`` reaches the encoder as refs
numbered ``{1, 3}``. Marker ``[2]`` must resolve to nothing — the old
positional fallback would have anchored ``references[1]`` = raw #3,
a silently WRONG anchor. Missing anchor (with a warning) is the only
acceptable outcome.
"""
refs = [
self._make_ref(1, "chunk-1", "src-1"),
self._make_ref(3, "chunk-3", "src-3"),
]
# Resolver level: exact matches work, the hole yields None.
resolved_1 = _resolve_reference(refs, 1)
assert resolved_1 is not None and resolved_1.chunk_id == "chunk-1"
assert _resolve_reference(refs, 2) is None
resolved_3 = _resolve_reference(refs, 3)
assert resolved_3 is not None and resolved_3.chunk_id == "chunk-3"
# Encoder level: [2]'s anchor is dropped with a warning; [1] and [3]
# anchor their own chunks.
with caplog.at_level("WARNING"):
params = build_save_chat_as_note_params("nb-id", "A [1] B [2] C [3]", refs, "Title")
chunk_refs = params[5][0][1]
assert [anchor[0] for anchor in chunk_refs] == [["chunk-1"], ["chunk-3"]]
assert any("[2]" in record.message for record in caplog.records)
def test_positional_fallback_still_serves_unnumbered_references(self):
"""The legacy unset-number contract is preserved: positional lookup
applies when the positional candidate carries no citation_number."""
refs = [
ChatReference(source_id="src-a", chunk_id="chunk-a"),
ChatReference(source_id="src-b", chunk_id="chunk-b"),
]
resolved = _resolve_reference(refs, 2)
assert resolved is not None and resolved.chunk_id == "chunk-b"
def test_seven_element_params_shape(self):
"""Sanity: top-level params is always exactly 7 elements."""
refs = [self._make_ref(1, "c1", "s1")]
params = build_save_chat_as_note_params("nb-id", "X [1].", refs, "T")
assert len(params) == 7
assert params[2] == [2] # mode flag
assert params[6] == [2] # trailer flag
assert params[0] == "nb-id"
assert params[1] == "X [1]."
assert params[4] == "T"
def test_rich_content_has_five_slots(self):
refs = [self._make_ref(1, "c1", "s1")]
params = build_save_chat_as_note_params("nb-id", "X [1].", refs, "T")
rich = params[5]
assert len(rich) == 5
assert rich[1] is None # always-null slot
assert rich[2] is None # always-null slot
assert rich[4] == 1 # trailer flag
def test_source_span_and_local_text_wrapper_diverge_for_nonzero_start(self):
"""Regression: source-document span (slot [3]) uses ref.start_char
/ ref.end_char (absolute offsets in the source), but the text-passage
wrapper (slot [4]) uses LOCAL offsets [0, len(cited_text)]. The
captured single-citation fixture has start_char=0 and end_char ==
len(cited_text), so they coincidentally match — but real refs from
the chat parser commonly have non-zero source offsets (e.g.
chars 100..200 in a long document). If the encoder used ref.end_char
for both, the local wrapper would emit ``[[0, 200, ...]]`` for a
100-char passage, breaking server-side hover anchoring."""
ref = ChatReference(
source_id="src-1",
citation_number=1,
cited_text="exactly-ten", # 11 chars, deliberately != source span
start_char=100,
end_char=111, # source offsets
chunk_id="chunk-1",
)
params = build_save_chat_as_note_params("nb-id", "X [1].", [ref], "Title")
passage = params[3][0]
# Source span uses absolute offsets.
assert passage[3] == [[None, 100, 111]]
# Text wrapper uses LOCAL offsets — [0, len(cited_text)].
passage_group = passage[4][0]
assert passage_group[0][0] == 0
assert passage_group[0][1] == 11 # len("exactly-ten")
# The deeply-nested inner mirror must also use the local end.
inner_offsets = passage_group[0][2][0][0]
assert inner_offsets[0] == 0
assert inner_offsets[1] == 11
def test_empty_cited_text_collapses_span(self):
"""Regression: when ref.cited_text is empty, span must collapse to
[0, 0] rather than emitting an invalid [None, start, 0] where
start > 0 (e.g. start_char=10 + cited_text='')."""
ref = ChatReference(
source_id="src-1",
citation_number=1,
cited_text="",
start_char=10,
end_char=20,
chunk_id="chunk-1",
)
params = build_save_chat_as_note_params("nb-id", "X [1].", [ref], "Title")
passage = params[3][0]
# Source span collapses to [0, 0], not [10, 0].
assert passage[3] == [[None, 0, 0]]
# Local text wrapper end is also 0.
assert passage[4][0][0][1] == 0
def test_title_passthrough(self):
refs = [self._make_ref(1, "c1", "s1")]
params = build_save_chat_as_note_params("nb-id", "X [1].", refs, "My Custom Title")
assert params[4] == "My Custom Title"