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

661 lines
29 KiB
Python

"""Unit tests for the unified ``MindMapsAPI`` dispatch (issue #1256 Phase 2)."""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock
import pytest
from notebooklm._mind_maps_api import MindMapsAPI, extract_interactive_tree_leaf
from notebooklm.exceptions import (
ArtifactError,
ArtifactFeatureUnavailableError,
MindMapNotFoundError,
NotFoundError,
UnknownRPCMethodError,
)
from notebooklm.rpc.types import RPCMethod
from notebooklm.types import Artifact, MindMapKind, MindMapResult
def _interactive_artifact(artifact_id: str, title: str = "INT") -> Artifact:
return Artifact(id=artifact_id, title=title, _artifact_type=4, status=3, _variant=4)
def _pending_type4_artifact(artifact_id: str, title: str = "INT") -> Artifact:
# Just-created interactive map: completed (status=3) but the variant slot
# at [9][1][0] is not yet populated, so _variant reads None.
return Artifact(id=artifact_id, title=title, _artifact_type=4, status=3, _variant=None)
def _make_api(*, note_rows=None, interactive=None):
# ADR-0007: configure the rpc_call seam via MagicMock(...) construction
# keyword (and configure_mock(...) for per-test overrides below) rather
# than dotted AsyncMock attribute assignment, which the forbidden-
# monkeypatch lint rejects on the rpc_call seam.
rpc = MagicMock(rpc_call=AsyncMock(return_value=None))
mind_maps = MagicMock()
mind_maps.list_mind_maps = AsyncMock(return_value=note_rows or [])
mind_maps.extract_content = MagicMock(side_effect=lambda row: row[1])
mind_maps.rename_mind_map = AsyncMock()
mind_maps.delete_mind_map = AsyncMock(return_value=True)
artifacts = MagicMock()
artifacts.list = AsyncMock(return_value=interactive or [])
artifacts.rename = AsyncMock()
artifacts.delete = AsyncMock(return_value=True)
artifacts.generate_mind_map = AsyncMock()
artifacts.wait_for_completion = AsyncMock()
notebooks = MagicMock()
notebooks.get_source_ids = AsyncMock(return_value=["s1"])
api = MindMapsAPI(rpc=rpc, mind_maps=mind_maps, artifacts=artifacts, notebooks=notebooks)
return api, rpc, mind_maps, artifacts, notebooks
@pytest.mark.asyncio
async def test_list_unions_both_backings():
api, *_ = _make_api(
note_rows=[["note_mm", '{"name": "NB", "children": []}']],
interactive=[_interactive_artifact("int_mm")],
)
result = await api.list("nb")
by_id = {m.id: m for m in result}
assert by_id["note_mm"].kind == MindMapKind.NOTE_BACKED
assert by_id["note_mm"].tree == {"name": "NB", "children": []}
assert by_id["int_mm"].kind == MindMapKind.INTERACTIVE
assert by_id["int_mm"].tree is None # interactive tree fetched lazily via get_tree
@pytest.mark.asyncio
async def test_list_note_backed_returns_only_note_backed_without_artifact_list():
"""``list_note_backed`` decodes the note-backed rows ONLY, via a single RPC.
Even with an interactive map present, the result carries note-backed
entries exclusively (every ``kind`` is ``NOTE_BACKED``; the interactive id
never appears — the method never sees that backing), and ``artifacts.list``
is NOT consulted: the only fetch is the note-backed service's
``list_mind_maps`` (the single ``GET_NOTES_AND_MIND_MAPS`` round-trip the
artifact-delete probe relies on for cassette stability).
"""
api, _, mind_maps, artifacts, _ = _make_api(
note_rows=[["note_mm", '{"name": "NB", "children": []}']],
interactive=[_interactive_artifact("int_mm")],
)
result = await api.list_note_backed("nb")
assert [m.id for m in result] == ["note_mm"]
assert all(m.kind == MindMapKind.NOTE_BACKED for m in result)
# tree is populated for free from the already-listed note content.
assert result[0].tree == {"name": "NB", "children": []}
assert result[0].notebook_id == "nb"
mind_maps.list_mind_maps.assert_awaited_once_with("nb")
artifacts.list.assert_not_awaited()
@pytest.mark.asyncio
async def test_list_note_backed_empty_when_no_note_backed_rows():
"""No note-backed rows → an empty list (interactive maps never leak in)."""
api, _, mind_maps, artifacts, _ = _make_api(
interactive=[_interactive_artifact("int_mm")],
)
assert await api.list_note_backed("nb") == []
mind_maps.list_mind_maps.assert_awaited_once_with("nb")
artifacts.list.assert_not_awaited()
@pytest.mark.asyncio
async def test_rename_dispatches_by_kind():
# The explicit-interactive path pre-validates the id (issue #1270), so the
# interactive artifact must exist for the rename to dispatch.
# return_object=False keeps this focused on dispatch (no hydrate re-fetch).
api, _, mind_maps, artifacts, _ = _make_api(interactive=[_interactive_artifact("int_mm")])
assert (
await api.rename("nb", "note_mm", "X", kind=MindMapKind.NOTE_BACKED, return_object=False)
is None
)
mind_maps.rename_mind_map.assert_awaited_once_with("nb", "note_mm", "X")
artifacts.rename.assert_not_awaited()
await api.rename("nb", "int_mm", "Y", kind=MindMapKind.INTERACTIVE, return_object=False)
# The interactive artifact rename is delegated with return_object=False so
# the unified API hydrates once (not twice) when an object is requested.
artifacts.rename.assert_awaited_once_with("nb", "int_mm", "Y", return_object=False)
@pytest.mark.asyncio
async def test_rename_returns_renamed_mind_map():
# Note-backed: the post-rename list reflects the new title; rename returns it.
# Current row shape carries the title in the inner envelope at row[1][4]
# (see NoteRow.title); extract_content reads row[1] (the JSON tree string).
api, _, mind_maps, artifacts, _ = _make_api(
note_rows=[
[
"note_mm",
["note_mm", '{"name": "NB", "children": []}', None, None, "New Title"],
]
]
)
result = await api.rename("nb", "note_mm", "New Title", kind=MindMapKind.NOTE_BACKED)
assert result is not None
assert result.id == "note_mm"
assert result.kind == MindMapKind.NOTE_BACKED
# Server-reflected title (NoteRow.title slot), not the input echoed back —
# guards against re-fetching a stale row with the old title.
assert result.title == "New Title"
@pytest.mark.asyncio
async def test_rename_missing_raises():
# Auto-detect path: the id is in neither backing → MindMapNotFoundError.
api, *_ = _make_api()
with pytest.raises(MindMapNotFoundError, match="not found") as excinfo:
await api.rename("nb", "ghost", "X")
# Catchable via the cross-domain umbrella too (ADR-0019).
assert isinstance(excinfo.value, NotFoundError)
assert excinfo.value.mind_map_id == "ghost"
@pytest.mark.asyncio
async def test_rename_missing_raises_even_with_return_object_false():
# Unlike sources/artifacts (whose absence detection rides on the skipped
# hydrate re-fetch), mind maps detect absence via a list lookup *before*
# dispatching the rename, so return_object=False does NOT suppress the raise.
# Auto-detect (kind=None) path.
api, _, _, artifacts, _ = _make_api()
with pytest.raises(MindMapNotFoundError, match="not found"):
await api.rename("nb", "ghost", "X", return_object=False)
artifacts.rename.assert_not_awaited()
@pytest.mark.asyncio
async def test_rename_explicit_kind_missing_raises_even_with_return_object_false():
# The pre-dispatch guarantee holds on the explicit-kind paths too: NOTE_BACKED
# raises from rename_mind_map and INTERACTIVE raises from the _find_interactive
# pre-validation, both before _hydrate_renamed is reached.
api, _, mind_maps, artifacts, _ = _make_api()
mind_maps.rename_mind_map = AsyncMock(side_effect=MindMapNotFoundError("ghost"))
with pytest.raises(MindMapNotFoundError, match="ghost"):
await api.rename("nb", "ghost", "X", kind=MindMapKind.NOTE_BACKED, return_object=False)
with pytest.raises(MindMapNotFoundError, match="not found"):
await api.rename("nb", "ghost", "X", kind=MindMapKind.INTERACTIVE, return_object=False)
artifacts.rename.assert_not_awaited() # never dispatched the no-op RPC
@pytest.mark.asyncio
async def test_delete_dispatches_by_kind():
api, _, mind_maps, artifacts, _ = _make_api()
assert await api.delete("nb", "note_mm", kind=MindMapKind.NOTE_BACKED) is None
mind_maps.delete_mind_map.assert_awaited_once_with("nb", "note_mm")
assert await api.delete("nb", "int_mm", kind=MindMapKind.INTERACTIVE) is None
artifacts.delete.assert_awaited_once_with("nb", "int_mm")
@pytest.mark.asyncio
async def test_get_tree_note_backed_parses_content():
api, *_ = _make_api(note_rows=[["note_mm", '{"name": "NB", "children": [1]}']])
tree = await api.get_tree("nb", "note_mm", kind=MindMapKind.NOTE_BACKED)
assert tree == {"name": "NB", "children": [1]}
@pytest.mark.asyncio
async def test_get_tree_interactive_reads_v9rmvd_position():
api, rpc, *_ = _make_api()
row = [None] * 10
row[9] = [None, None, None, '{"name": "I", "children": []}'] # [0][9][3] = tree
rpc.configure_mock(rpc_call=AsyncMock(return_value=[row]))
tree = await api.get_tree("nb", "int_mm", kind=MindMapKind.INTERACTIVE)
assert tree == {"name": "I", "children": []}
assert rpc.rpc_call.call_args[0][0] == RPCMethod.GET_INTERACTIVE_HTML
@pytest.mark.asyncio
async def test_generate_note_backed_delegates():
api, _, _, artifacts, _ = _make_api()
artifacts.generate_mind_map = AsyncMock(
return_value=MindMapResult(mind_map={"name": "G", "children": []}, note_id="n1")
)
mm = await api.generate("nb", ["s1"], kind=MindMapKind.NOTE_BACKED)
assert mm.kind == MindMapKind.NOTE_BACKED
assert mm.id == "n1"
assert mm.title == "G"
assert mm.tree == {"name": "G", "children": []}
@pytest.mark.asyncio
async def test_generate_interactive_creates_polls_and_fetches_tree():
api, rpc, _, artifacts, notebooks = _make_api(
interactive=[_interactive_artifact("new_int", "T")]
)
tree_row = [None] * 10
tree_row[9] = [None, None, None, '{"name": "I", "children": []}'] # [0][9][3] = tree
rpc.configure_mock(
rpc_call=AsyncMock(
side_effect=[
[["new_int", "T", 4]], # 1: CREATE_ARTIFACT echo
[tree_row], # 2: GET_INTERACTIVE_HTML tree (post-completion)
]
)
)
mm = await api.generate("nb", kind=MindMapKind.INTERACTIVE, wait=True)
assert rpc.rpc_call.call_args_list[0][0][0] == RPCMethod.CREATE_ARTIFACT
notebooks.get_source_ids.assert_awaited_once_with("nb") # source ids resolved
artifacts.wait_for_completion.assert_awaited_once_with("nb", "new_int")
assert mm.kind == MindMapKind.INTERACTIVE
assert mm.id == "new_int"
# Converged surface: interactive generate returns the tree, like note-backed.
assert mm.tree == {"name": "I", "children": []}
@pytest.mark.asyncio
async def test_generate_interactive_wait_false_skips_tree():
api, rpc, _, artifacts, _ = _make_api(interactive=[_interactive_artifact("new_int")])
rpc.configure_mock(rpc_call=AsyncMock(return_value=[["new_int", "T", 4]]))
mm = await api.generate("nb", ["s1"], kind=MindMapKind.INTERACTIVE, wait=False)
assert mm.tree is None # pending; no tree fetched
artifacts.wait_for_completion.assert_not_awaited()
assert rpc.rpc_call.await_count == 1 # only CREATE_ARTIFACT, no get_tree
@pytest.mark.asyncio
async def test_generate_interactive_threads_instructions_into_create_params():
# The interactive CREATE_ARTIFACT payload carries a free-text prompt at
# [9][1][2] (the slot quiz/flashcards use; server-verified to steer variant
# 4). generate(instructions=...) must thread it there rather than drop it.
api, rpc, _, _, _ = _make_api(interactive=[_interactive_artifact("new_int")])
rpc.configure_mock(rpc_call=AsyncMock(return_value=[["new_int", "T", 4]]))
await api.generate(
"nb", ["s1"], kind=MindMapKind.INTERACTIVE, instructions="focus on X", wait=False
)
create_params = rpc.rpc_call.call_args_list[0][0][1]
assert create_params[2][9] == [None, [4, None, "focus on X"]]
@pytest.mark.asyncio
async def test_generate_interactive_without_instructions_keeps_bare_variant():
# No prompt → byte-identical [None, [4]] options block (unchanged request).
api, rpc, _, _, _ = _make_api(interactive=[_interactive_artifact("new_int")])
rpc.configure_mock(rpc_call=AsyncMock(return_value=[["new_int", "T", 4]]))
await api.generate("nb", ["s1"], kind=MindMapKind.INTERACTIVE, wait=False)
create_params = rpc.rpc_call.call_args_list[0][0][1]
assert create_params[2][9] == [None, [4]]
@pytest.mark.asyncio
async def test_generate_interactive_raises_feature_unavailable_when_no_artifact_id():
# ADR-0019 async-kickoff null contract (issue #1359): a null/degenerate
# CREATE_ARTIFACT raises ArtifactFeatureUnavailableError (no task created),
# matching the sibling generate_* / retry_failed null-create paths.
api, rpc, *_ = _make_api()
rpc.configure_mock(rpc_call=AsyncMock(return_value=None)) # CREATE_ARTIFACT yields no id
with pytest.raises(ArtifactFeatureUnavailableError) as exc_info:
await api.generate("nb", ["s1"], kind=MindMapKind.INTERACTIVE)
# Carries the artifact-type + the CREATE_ARTIFACT method id for diagnostics.
assert exc_info.value.artifact_type == "mind_map"
assert exc_info.value.method_id == RPCMethod.CREATE_ARTIFACT.value
# Non-breaking: still catchable as the broad ArtifactError (it is a subclass).
assert isinstance(exc_info.value, ArtifactError)
# --- #1270 sub-fix 1: get_tree drift vs absent-leaf ---------------------------
@pytest.mark.asyncio
async def test_get_tree_interactive_absent_leaf_tolerated_with_warning(caplog):
"""A populated options block missing only the [3] tree leaf is 'not ready'."""
api, rpc, *_ = _make_api()
row = [None] * 10
row[9] = [None, None] # [0][9] present but too short to carry the [3] leaf
rpc.configure_mock(rpc_call=AsyncMock(return_value=[row]))
import logging
with caplog.at_level(logging.WARNING, logger="notebooklm._mind_maps_api"):
tree = await api.get_tree("nb", "int_mm", kind=MindMapKind.INTERACTIVE)
assert tree is None # tolerated as not-yet-populated
# ...but a WARNING with the rpcid/source leaves a drift breadcrumb.
assert any(RPCMethod.GET_INTERACTIVE_HTML.value in r.message for r in caplog.records)
assert any("_mind_maps_api.get_tree" in r.message for r in caplog.records)
@pytest.mark.asyncio
async def test_get_tree_interactive_real_drift_reraises():
"""Genuine [0][9] reshape must fail loud, not masquerade as 'not ready'."""
api, rpc, *_ = _make_api()
# [0] is a short row: descent to [0][9] fails before reaching the leaf.
rpc.configure_mock(rpc_call=AsyncMock(return_value=[[1, 2, 3]]))
with pytest.raises(UnknownRPCMethodError):
await api.get_tree("nb", "int_mm", kind=MindMapKind.INTERACTIVE)
@pytest.mark.asyncio
async def test_get_tree_interactive_null_response_returns_none():
"""A null GET_INTERACTIVE_HTML response stays 'not ready' (no drift)."""
api, rpc, *_ = _make_api()
rpc.configure_mock(rpc_call=AsyncMock(return_value=None))
assert await api.get_tree("nb", "int_mm", kind=MindMapKind.INTERACTIVE) is None
def test_extract_interactive_tree_leaf_helper():
"""The shared helper: null -> None, drift -> raise, present -> value."""
assert extract_interactive_tree_leaf(None, source="t") is None
row = [None] * 10
row[9] = [None, None, None, "TREE"]
assert extract_interactive_tree_leaf([row], source="t") == "TREE"
# [0] too short to reach [0][9] -> drift.
with pytest.raises(UnknownRPCMethodError):
extract_interactive_tree_leaf([[1, 2, 3]], source="t")
# A non-list [0][9] is drift too (not a tolerated short-list).
non_list_row = [None] * 10
non_list_row[9] = "not-a-list"
with pytest.raises(UnknownRPCMethodError):
extract_interactive_tree_leaf([non_list_row], source="t")
# A list [0][9] that is too short for index 3 is the tolerated not-ready leaf.
short_row = [None] * 10
short_row[9] = [None, None]
assert extract_interactive_tree_leaf([short_row], source="t") is None
# --- #1270 sub-fix 2: transient type-4 variant=None classification -----------
@pytest.mark.asyncio
async def test_find_interactive_matches_pending_variant_none_by_id():
"""generate(wait=True) must keep the real title during the settling window."""
api, rpc, _, artifacts, _ = _make_api(
interactive=[_pending_type4_artifact("new_int", "Real Title")]
)
tree_row = [None] * 10
tree_row[9] = [None, None, None, '{"name": "I", "children": []}']
rpc.configure_mock(
rpc_call=AsyncMock(
side_effect=[
[["new_int", "Real Title", 4]], # CREATE_ARTIFACT echo
[tree_row], # GET_INTERACTIVE_HTML tree
]
)
)
mm = await api.generate("nb", kind=MindMapKind.INTERACTIVE, wait=True)
assert mm.id == "new_int"
# Did NOT degrade to the title="Mind Map" placeholder.
assert mm.title == "Real Title"
assert mm.tree == {"name": "I", "children": []}
@pytest.mark.asyncio
async def test_generate_interactive_unresolved_id_falls_back_to_placeholder():
"""If even the unfiltered list never shows the id, fall back gracefully."""
api, rpc, _, artifacts, _ = _make_api(interactive=[])
rpc.configure_mock(
rpc_call=AsyncMock(
side_effect=[
[["ghost_int", "T", 4]], # CREATE_ARTIFACT echo
None, # GET_INTERACTIVE_HTML tree not ready
]
)
)
mm = await api.generate("nb", kind=MindMapKind.INTERACTIVE, wait=True)
assert mm.id == "ghost_int"
assert mm.title == "Mind Map" # placeholder fallback preserved
# --- #1270 sub-fix 3: rename(kind=INTERACTIVE) pre-validates the id ----------
@pytest.mark.asyncio
async def test_rename_interactive_bad_id_raises_not_silent_noop():
api, _, _, artifacts, _ = _make_api(interactive=[_interactive_artifact("real_int")])
with pytest.raises(MindMapNotFoundError, match="not found"):
await api.rename("nb", "ghost", "X", kind=MindMapKind.INTERACTIVE)
artifacts.rename.assert_not_awaited() # never dispatched the no-op RPC
@pytest.mark.asyncio
async def test_rename_interactive_good_id_dispatches():
api, _, _, artifacts, _ = _make_api(interactive=[_interactive_artifact("real_int")])
await api.rename("nb", "real_int", "X", kind=MindMapKind.INTERACTIVE, return_object=False)
# The unified API delegates with return_object=False (it hydrates once, here
# skipped) — the artifact rename is not asked to re-fetch.
artifacts.rename.assert_awaited_once_with("nb", "real_int", "X", return_object=False)
@pytest.mark.asyncio
async def test_rename_interactive_rejects_settling_type4_variant_none():
"""The unclassified-type4 fallback is scoped to generate only: rename/detect
must NOT accept a settling (or malformed) quiz/flashcard as a mind map."""
api, _, _, artifacts, _ = _make_api(interactive=[_pending_type4_artifact("settling")])
# Explicit-interactive rename: the strict path rejects a variant=None row.
with pytest.raises(MindMapNotFoundError, match="not found"):
await api.rename("nb", "settling", "X", kind=MindMapKind.INTERACTIVE)
artifacts.rename.assert_not_awaited()
# Auto-detect (kind=None) also rejects it -> MindMapNotFoundError, never dispatched.
with pytest.raises(MindMapNotFoundError, match="not found"):
await api.rename("nb", "settling", "X")
artifacts.rename.assert_not_awaited()
@pytest.mark.asyncio
async def test_get_tree_interactive_non_list_options_block_reraises():
"""A non-list [0][9] is genuine drift -> fail loud, not 'not ready'."""
api, rpc, *_ = _make_api()
row = [None] * 10
row[9] = "not-a-list" # [0][9] is no longer a list -> drift
rpc.configure_mock(rpc_call=AsyncMock(return_value=[row]))
with pytest.raises(UnknownRPCMethodError):
await api.get_tree("nb", "int_mm", kind=MindMapKind.INTERACTIVE)
# --- #1270 sub-fix 4: non-str title coercion ---------------------------------
@pytest.mark.asyncio
async def test_generate_note_backed_non_str_name_falls_back_to_placeholder():
api, _, _, artifacts, _ = _make_api()
artifacts.generate_mind_map = AsyncMock(
return_value=MindMapResult(mind_map={"name": 123, "children": []}, note_id="n1")
)
mm = await api.generate("nb", ["s1"], kind=MindMapKind.NOTE_BACKED)
assert mm.title == "Mind Map" # numeric name rejected, placeholder used
@pytest.mark.asyncio
async def test_generate_note_backed_empty_name_falls_back_to_placeholder():
api, _, _, artifacts, _ = _make_api()
artifacts.generate_mind_map = AsyncMock(
return_value=MindMapResult(mind_map={"name": "", "children": []}, note_id="n1")
)
mm = await api.generate("nb", ["s1"], kind=MindMapKind.NOTE_BACKED)
assert mm.title == "Mind Map" # empty name rejected, placeholder used
@pytest.mark.asyncio
async def test_list_excludes_non_interactive_mind_map_artifacts():
# A MIND_MAP-type artifact that is not a settled interactive map (variant
# not yet populated) is filtered out of the unified listing.
api, *_ = _make_api(
note_rows=[["note_mm", "{}"]],
interactive=[_pending_type4_artifact("settling"), _interactive_artifact("int_mm")],
)
ids = {m.id for m in await api.list("nb")}
assert ids == {"note_mm", "int_mm"} # "settling" excluded
# --- get() lookup ------------------------------------------------------------
@pytest.mark.asyncio
async def test_get_returns_matching_mind_map():
api, *_ = _make_api(note_rows=[["note_mm", '{"name": "NB", "children": []}']])
# A hit is silent — only the miss path is deprecated (#1358).
import warnings
with warnings.catch_warnings():
warnings.simplefilter("error", DeprecationWarning)
mm = await api.get("nb", "note_mm")
assert mm is not None
assert mm.id == "note_mm"
assert mm.kind == MindMapKind.NOTE_BACKED
@pytest.mark.asyncio
async def test_get_raises_when_absent():
# Neither backing contains the id -> MindMapNotFoundError (v0.8.0 flip, #1247).
api, *_ = _make_api(note_rows=[["note_mm", "{}"]])
with pytest.raises(MindMapNotFoundError):
await api.get("nb", "ghost")
@pytest.mark.asyncio
async def test_get_or_none_silent_on_miss():
# The sanctioned None-on-miss path stays silent (#1358): no warning even
# though it returns None for a missing id.
import warnings
api, *_ = _make_api(note_rows=[["note_mm", "{}"]])
with warnings.catch_warnings():
warnings.simplefilter("error", DeprecationWarning)
assert await api.get_or_none("nb", "ghost") is None
# --- rename(kind=None) auto-detect dispatch ---------------------------------
@pytest.mark.asyncio
async def test_rename_auto_detect_note_backed_dispatches():
# kind=None with the id in the note collection -> note-backed rename,
# interactive rename never consulted.
# Decoy first row exercises the loop-continue branch before the match.
api, _, mind_maps, artifacts, _ = _make_api(
note_rows=[["other_mm", "{}"], ["note_mm", '{"name": "NB", "children": []}']]
)
await api.rename("nb", "note_mm", "X", return_object=False)
mind_maps.rename_mind_map.assert_awaited_once_with("nb", "note_mm", "X")
artifacts.rename.assert_not_awaited()
@pytest.mark.asyncio
async def test_rename_auto_detect_interactive_dispatches():
# kind=None with the id absent from notes but present as an interactive
# artifact -> artifact rename.
api, _, mind_maps, artifacts, _ = _make_api(interactive=[_interactive_artifact("int_mm")])
await api.rename("nb", "int_mm", "Y", return_object=False)
artifacts.rename.assert_awaited_once_with("nb", "int_mm", "Y", return_object=False)
mind_maps.rename_mind_map.assert_not_awaited()
@pytest.mark.asyncio
async def test_rename_hydrate_raises_when_target_vanishes():
# Explicit note-backed rename whose post-rename refetch finds nothing
# (vanished-between-rename-and-refetch race) -> MindMapNotFoundError from
# _hydrate_renamed rather than a stale/None object.
# DeprecationWarning escalated to an error pins the #1358 self-warn fix:
# _hydrate_renamed must re-fetch via the silent _get_or_none, not the
# warning-emitting public get() (this would fail if it regressed).
import warnings
api, _, mind_maps, _, _ = _make_api(note_rows=[])
with warnings.catch_warnings():
warnings.simplefilter("error", DeprecationWarning)
with pytest.raises(MindMapNotFoundError, match="not found"):
await api.rename("nb", "note_mm", "X", kind=MindMapKind.NOTE_BACKED)
mind_maps.rename_mind_map.assert_awaited_once_with("nb", "note_mm", "X")
# --- get_tree auto-detect + note-backed paths -------------------------------
@pytest.mark.asyncio
async def test_get_tree_auto_detect_note_backed_returns_tree():
# Decoy first row exercises the loop-continue branch before the match.
api, *_ = _make_api(
note_rows=[["other_mm", "{}"], ["note_mm", '{"name": "NB", "children": [1]}']]
)
assert await api.get_tree("nb", "note_mm") == {"name": "NB", "children": [1]}
@pytest.mark.asyncio
async def test_get_tree_auto_detect_interactive_falls_through_to_rpc():
# kind=None, id absent from notes but present as an interactive artifact ->
# falls through to GET_INTERACTIVE_HTML and parses the tree leaf.
api, rpc, *_ = _make_api(interactive=[_interactive_artifact("int_mm")])
row = [None] * 10
row[9] = [None, None, None, '{"name": "INT", "children": []}']
rpc.configure_mock(rpc_call=AsyncMock(return_value=[row]))
assert await api.get_tree("nb", "int_mm") == {"name": "INT", "children": []}
@pytest.mark.asyncio
async def test_get_tree_auto_detect_missing_returns_none():
# Derived read (ADR-0019): a missing parent yields the uniform-empty value
# (None), not a raise — get() is the existence check, not get_tree().
api, rpc, *_ = _make_api()
assert await api.get_tree("nb", "ghost") is None
# Auto-detect resolved the miss without falling through to GET_INTERACTIVE_HTML.
rpc.rpc_call.assert_not_awaited()
@pytest.mark.asyncio
async def test_get_tree_note_backed_absent_returns_none():
# Explicit kind=NOTE_BACKED with an unknown id returns None (does not raise).
api, *_ = _make_api(note_rows=[["other", "{}"]])
assert await api.get_tree("nb", "ghost", kind=MindMapKind.NOTE_BACKED) is None
@pytest.mark.asyncio
async def test_get_tree_note_backed_invalid_json_returns_none():
# _parse_tree swallows a JSONDecodeError on malformed content -> None.
api, *_ = _make_api(note_rows=[["note_mm", "not-json{"]])
assert await api.get_tree("nb", "note_mm", kind=MindMapKind.NOTE_BACKED) is None
# --- _detect_kind via delete(kind=None) -------------------------------------
@pytest.mark.asyncio
async def test_delete_auto_detect_note_backed():
# Decoy first row exercises the _detect_kind loop-continue branch.
api, _, mind_maps, artifacts, _ = _make_api(note_rows=[["other_mm", "{}"], ["note_mm", "{}"]])
await api.delete("nb", "note_mm")
mind_maps.delete_mind_map.assert_awaited_once_with("nb", "note_mm")
artifacts.delete.assert_not_awaited()
@pytest.mark.asyncio
async def test_delete_auto_detect_interactive():
api, _, mind_maps, artifacts, _ = _make_api(interactive=[_interactive_artifact("int_mm")])
await api.delete("nb", "int_mm")
artifacts.delete.assert_awaited_once_with("nb", "int_mm")
mind_maps.delete_mind_map.assert_not_awaited()
@pytest.mark.asyncio
async def test_delete_auto_detect_missing_is_idempotent():
# Auto-detect (kind=None) on an already-absent id is a no-op that returns
# None (ADR-0019 idempotent delete), matching sources/artifacts/notes —
# not a raise. Neither delete RPC family is dispatched.
api, _, mind_maps, artifacts, _ = _make_api()
assert await api.delete("nb", "ghost") is None
mind_maps.delete_mind_map.assert_not_awaited()
artifacts.delete.assert_not_awaited()
@pytest.mark.parametrize(
"create_response, expected",
[
([["artifact_abc"]], "artifact_abc"), # happy: [[id, ...]]
([["artifact_abc", "extra"]], "artifact_abc"), # id is slot 0
(None, None), # null response
([], None), # empty response
("nope", None), # non-list response
([[]], None), # empty inner row
([None], None), # non-list inner row
([[123]], None), # non-str id
],
)
def test_new_artifact_id_degenerate_shapes(create_response, expected):
"""``_new_artifact_id`` keeps its soft ``CREATE_ARTIFACT`` contract after the
#1491 ``safe_index`` migration: every degenerate response shape returns
``None`` (never raises ``UnknownRPCMethodError``), and a well-formed
``[[id, ...]]`` yields the id. Pins the empty / non-list / non-str paths the
two guarded ``safe_index`` descents must keep soft."""
from notebooklm._mind_maps_api import _new_artifact_id
assert _new_artifact_id(create_response) == expected