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
661 lines
29 KiB
Python
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
|