Files
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

197 lines
6.8 KiB
Python

"""Unit tests for the transport-neutral ``notebooklm._app.labels`` mutation core.
The composite ``<id|name>`` :func:`resolve_label_id` resolver (id /
unambiguous-prefix / exact-name / ambiguity) is already covered directly in
``tests/unit/cli/test_label_listing.py`` (it re-exports the *same* ``_app``
function), so these tests do **not** duplicate that. They pin the label
**mutation** executors — which had no direct app coverage — plus the
:class:`LabelResolutionError` attribute contract, at the ``_app`` boundary with
a ``MagicMock`` client (no Click / ``CliRunner``):
* ``create`` / ``sources`` / ``generate`` / ``rename`` / ``emoji`` /
``add`` / ``remove`` / ``delete`` executors delegate to the right
``client.labels`` RPC and project the typed results,
* :class:`LabelResolutionError` carries ``message`` / ``code`` / ``extra`` and
stays inside the public ``ValidationError`` hierarchy.
"""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock
import pytest
from notebooklm._app.labels import (
LabelGenerateResult,
LabelMembershipResult,
LabelResolutionError,
execute_label_add_sources,
execute_label_create,
execute_label_delete,
execute_label_generate,
execute_label_remove_sources,
execute_label_rename,
execute_label_set_emoji,
execute_label_sources,
)
from notebooklm.exceptions import ValidationError
from notebooklm.types import Label, Source
def _client() -> MagicMock:
client = MagicMock()
client.labels = MagicMock()
return client
# ---------------------------------------------------------------------------
# LabelResolutionError — typed attribute contract
# ---------------------------------------------------------------------------
def test_label_resolution_error_carries_code_and_extra() -> None:
err = LabelResolutionError("No match", "NOT_FOUND", {"id": "x"})
assert err.message == "No match"
assert err.code == "NOT_FOUND"
assert err.extra == {"id": "x"}
# Stays inside the public NotebookLMError hierarchy (errors.classify covers it).
assert isinstance(err, ValidationError)
# The str includes the code for adapter-agnostic diagnostics.
assert "code=NOT_FOUND" in str(err)
def test_label_resolution_error_extra_defaults_to_none() -> None:
err = LabelResolutionError("Ambiguous", "AMBIGUOUS_NAME")
assert err.extra is None
assert err.code == "AMBIGUOUS_NAME"
# ---------------------------------------------------------------------------
# create
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_execute_label_create_delegates() -> None:
client = _client()
label = Label(id="lbl_1", name="Papers", emoji="📄")
client.labels.create = AsyncMock(return_value=label)
result = await execute_label_create(client, "nb_1", "Papers", "📄")
assert result is label
client.labels.create.assert_awaited_once_with("nb_1", "Papers", "📄")
# ---------------------------------------------------------------------------
# sources
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_execute_label_sources_expands_to_source_objects() -> None:
client = _client()
sources = [Source(id="s1", title="First"), Source(id="s2", title="Second")]
client.labels.sources = AsyncMock(return_value=sources)
result = await execute_label_sources(client, "nb_1", "lbl_1")
assert result == sources
client.labels.sources.assert_awaited_once_with("nb_1", "lbl_1")
# ---------------------------------------------------------------------------
# generate
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_execute_label_generate_projects_result() -> None:
client = _client()
labels = [Label(id="lbl_1", name="Auto")]
client.labels.generate = AsyncMock(return_value=labels)
result = await execute_label_generate(client, "nb_1", "unlabeled")
assert isinstance(result, LabelGenerateResult)
assert result.notebook_id == "nb_1"
assert result.scope == "unlabeled"
assert result.labels == labels
client.labels.generate.assert_awaited_once_with("nb_1", scope="unlabeled")
# ---------------------------------------------------------------------------
# rename / emoji
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_execute_label_rename_returns_label() -> None:
client = _client()
label = Label(id="lbl_1", name="New name", emoji="📄")
client.labels.rename = AsyncMock(return_value=label)
result = await execute_label_rename(client, "nb_1", "lbl_1", "New name")
assert result is label
client.labels.rename.assert_awaited_once_with("nb_1", "lbl_1", "New name")
@pytest.mark.asyncio
async def test_execute_label_set_emoji_returns_label() -> None:
client = _client()
label = Label(id="lbl_1", name="Papers", emoji="🧠")
client.labels.set_emoji = AsyncMock(return_value=label)
result = await execute_label_set_emoji(client, "nb_1", "lbl_1", "🧠")
assert result is label
client.labels.set_emoji.assert_awaited_once_with("nb_1", "lbl_1", "🧠")
# ---------------------------------------------------------------------------
# add / remove sources
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_execute_label_add_sources_projects_membership() -> None:
client = _client()
label = Label(id="lbl_1", name="Papers", source_ids=["s1", "s2"])
client.labels.add_sources = AsyncMock(return_value=label)
result = await execute_label_add_sources(client, "nb_1", "lbl_1", ("s1", "s2"))
assert isinstance(result, LabelMembershipResult)
assert result.label is label
assert result.source_ids == ["s1", "s2"]
client.labels.add_sources.assert_awaited_once_with("nb_1", "lbl_1", ["s1", "s2"])
@pytest.mark.asyncio
async def test_execute_label_remove_sources_projects_membership() -> None:
client = _client()
label = Label(id="lbl_1", name="Papers", source_ids=[])
client.labels.remove_sources = AsyncMock(return_value=label)
result = await execute_label_remove_sources(client, "nb_1", "lbl_1", ["s1"])
assert result.label is label
assert result.source_ids == ["s1"]
client.labels.remove_sources.assert_awaited_once_with("nb_1", "lbl_1", ["s1"])
# ---------------------------------------------------------------------------
# delete
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_execute_label_delete_passes_id_list() -> None:
client = _client()
client.labels.delete = AsyncMock(return_value=None)
await execute_label_delete(client, "nb_1", ("lbl_1", "lbl_2"))
client.labels.delete.assert_awaited_once_with("nb_1", ["lbl_1", "lbl_2"])