Files
2026-07-13 12:10:23 +08:00

428 lines
15 KiB
Python

"""
Unit tests for the open_notebook.graphs module.
This test suite focuses on testing graph structures, tools, and validation
without heavy mocking of the actual processing logic.
"""
from datetime import datetime
from typing import cast
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from langchain_core.runnables import RunnableConfig
from open_notebook.domain.notebook import Source
from open_notebook.graphs.prompt import PatternChainState, graph
from open_notebook.graphs.tools import get_current_timestamp
from open_notebook.graphs.transformation import (
TransformationState,
run_transformation,
)
from open_notebook.graphs.transformation import (
graph as transformation_graph,
)
# ============================================================================
# TEST SUITE 1: Graph Tools
# ============================================================================
class TestGraphTools:
"""Test suite for graph tool definitions."""
def test_get_current_timestamp_format(self):
"""Test timestamp tool returns correct format."""
timestamp = get_current_timestamp.invoke({})
assert isinstance(timestamp, str)
assert len(timestamp) == 14 # YYYYMMDDHHmmss format
assert timestamp.isdigit()
def test_get_current_timestamp_validity(self):
"""Test timestamp represents valid datetime."""
timestamp = get_current_timestamp.invoke({})
# Parse it back to datetime to verify validity
year = int(timestamp[0:4])
month = int(timestamp[4:6])
day = int(timestamp[6:8])
hour = int(timestamp[8:10])
minute = int(timestamp[10:12])
second = int(timestamp[12:14])
# Should be valid date components
assert 2020 <= year <= 2100
assert 1 <= month <= 12
assert 1 <= day <= 31
assert 0 <= hour <= 23
assert 0 <= minute <= 59
assert 0 <= second <= 59
# Should parse as datetime
dt = datetime.strptime(timestamp, "%Y%m%d%H%M%S")
assert isinstance(dt, datetime)
def test_get_current_timestamp_is_tool(self):
"""Test that function is properly decorated as a tool."""
# Check it has tool attributes
assert hasattr(get_current_timestamp, "name")
assert hasattr(get_current_timestamp, "description")
# ============================================================================
# TEST SUITE 2: Prompt Graph State
# ============================================================================
class TestPromptGraph:
"""Test suite for prompt pattern chain graph."""
def test_pattern_chain_state_structure(self):
"""Test PatternChainState structure and fields."""
state = PatternChainState(
prompt="Test prompt", parser=None, input_text="Test input", output=""
)
assert state["prompt"] == "Test prompt"
assert state["parser"] is None
assert state["input_text"] == "Test input"
assert state["output"] == ""
def test_prompt_graph_compilation(self):
"""Test that prompt graph compiles correctly."""
assert graph is not None
# Graph should have the expected structure
assert hasattr(graph, "invoke")
assert hasattr(graph, "ainvoke")
# ============================================================================
# TEST SUITE 3: Transformation Graph
# ============================================================================
class TestTransformationGraph:
"""Test suite for transformation graph workflows."""
def test_transformation_state_structure(self):
"""Test TransformationState structure and fields."""
from unittest.mock import MagicMock
from open_notebook.domain.notebook import Source
from open_notebook.domain.transformation import Transformation
mock_source = MagicMock(spec=Source)
mock_transformation = MagicMock(spec=Transformation)
state = TransformationState(
input_text="Test text",
source=mock_source,
transformation=mock_transformation,
output="",
)
assert state["input_text"] == "Test text"
assert state["source"] == mock_source
assert state["transformation"] == mock_transformation
assert state["output"] == ""
@pytest.mark.asyncio
async def test_run_transformation_assertion_no_content(self):
"""Test transformation raises assertion with no content."""
from unittest.mock import MagicMock
from open_notebook.domain.transformation import Transformation
mock_transformation = MagicMock(spec=Transformation)
state = {
"input_text": None,
"transformation": mock_transformation,
"source": None,
}
config: RunnableConfig = {"configurable": {"model_id": None}}
with pytest.raises(AssertionError, match="No content to transform"):
await run_transformation(state, config)
def test_transformation_graph_compilation(self):
"""Test that transformation graph compiles correctly."""
assert transformation_graph is not None
assert hasattr(transformation_graph, "invoke")
assert hasattr(transformation_graph, "ainvoke")
# ============================================================================
# TEST SUITE 4: Source Graph - Title Preservation
# ============================================================================
class TestSaveSourceTitlePreservation:
"""Test save_source node preserves user-set titles (#670)."""
@pytest.mark.asyncio
@patch("open_notebook.graphs.source.Source.get")
async def test_custom_title_preserved(self, mock_get):
"""User-set title is NOT overwritten by the extracted title."""
from content_core.common import ExtractionOutput
from open_notebook.graphs.source import SourceState, save_source
mock_source = MagicMock(spec=Source)
mock_source.title = "My Custom Research Title"
mock_source.save = AsyncMock()
mock_get.return_value = mock_source
state = {
"source_id": "source:123",
"content_state": {"url": "https://example.com", "file_path": None},
"extraction": ExtractionOutput(title="video.mp4", content="Some content"),
"embed": False,
"apply_transformations": [],
}
# cast: the node only reads these keys; SourceState is a total TypedDict
await save_source(cast(SourceState, state))
assert mock_source.title == "My Custom Research Title"
mock_source.save.assert_awaited_once()
@pytest.mark.asyncio
@patch("open_notebook.graphs.source.Source.get")
async def test_placeholder_title_replaced(self, mock_get):
"""Placeholder 'Processing...' title IS replaced by extracted title."""
from content_core.common import ExtractionOutput
from open_notebook.graphs.source import SourceState, save_source
mock_source = MagicMock(spec=Source)
mock_source.title = "Processing..."
mock_source.save = AsyncMock()
mock_get.return_value = mock_source
state = {
"source_id": "source:123",
"content_state": {"url": "https://example.com", "file_path": None},
"extraction": ExtractionOutput(
title="Extracted Article Title", content="Some content"
),
"embed": False,
"apply_transformations": [],
}
# cast: the node only reads these keys; SourceState is a total TypedDict
await save_source(cast(SourceState, state))
assert mock_source.title == "Extracted Article Title"
mock_source.save.assert_awaited_once()
@pytest.mark.asyncio
@patch("open_notebook.graphs.source.Source.get")
async def test_none_title_replaced(self, mock_get):
"""None title IS replaced by extracted title."""
from content_core.common import ExtractionOutput
from open_notebook.graphs.source import SourceState, save_source
mock_source = MagicMock(spec=Source)
mock_source.title = None
mock_source.save = AsyncMock()
mock_get.return_value = mock_source
state = {
"source_id": "source:123",
"content_state": {"url": None, "file_path": "/tmp/file.pdf"},
"extraction": ExtractionOutput(title="Extracted Title", content="Content"),
"embed": False,
"apply_transformations": [],
}
# cast: the node only reads these keys; SourceState is a total TypedDict
await save_source(cast(SourceState, state))
assert mock_source.title == "Extracted Title"
mock_source.save.assert_awaited_once()
@pytest.mark.asyncio
@patch("open_notebook.graphs.source.Source.get")
async def test_empty_title_replaced(self, mock_get):
"""Empty string title IS replaced by extracted title."""
from content_core.common import ExtractionOutput
from open_notebook.graphs.source import SourceState, save_source
mock_source = MagicMock(spec=Source)
mock_source.title = ""
mock_source.save = AsyncMock()
mock_get.return_value = mock_source
state = {
"source_id": "source:123",
"content_state": {"url": None, "file_path": None},
"extraction": ExtractionOutput(title="Extracted Title", content="Content"),
"embed": False,
"apply_transformations": [],
}
# cast: the node only reads these keys; SourceState is a total TypedDict
await save_source(cast(SourceState, state))
assert mock_source.title == "Extracted Title"
mock_source.save.assert_awaited_once()
# ============================================================================
# TEST SUITE 5: Source Graph - content_process (content-core 2.x)
# ============================================================================
class TestContentProcessDeleteSource:
"""content-core 2.x no longer deletes the uploaded file; the graph must."""
@pytest.mark.asyncio
@patch("open_notebook.graphs.source.extract_content")
@patch("open_notebook.graphs.source.ModelManager")
async def test_uploaded_file_deleted_when_flag_set(
self, mock_model_manager, mock_extract, tmp_path
):
from content_core.common import ExtractionOutput
from open_notebook.graphs.source import SourceState, content_process
# No STT default configured -> no audio override, no DB access.
mm_instance = MagicMock()
mm_instance.get_defaults = AsyncMock(
return_value=MagicMock(default_speech_to_text_model=None)
)
mock_model_manager.return_value = mm_instance
mock_extract.return_value = ExtractionOutput(
title="Doc", content="extracted text"
)
uploaded = tmp_path / "upload.pdf"
uploaded.write_text("data")
state = {
"source_id": "source:123",
"content_state": {"file_path": str(uploaded), "delete_source": True},
"embed": False,
"apply_transformations": [],
}
result = await content_process(cast(SourceState, state))
assert result["extraction"].content == "extracted text"
assert not uploaded.exists() # file removed by the graph
mock_extract.assert_awaited_once()
# The broader YouTube transcript language list is wired into the config
# (content-core's own default is only en/es/pt).
config = mock_extract.await_args.kwargs["config"]
assert "de" in config.youtube_languages
assert "ja" in config.youtube_languages
@pytest.mark.asyncio
@patch("open_notebook.graphs.source.extract_content")
@patch("open_notebook.graphs.source.ModelManager")
async def test_uploaded_file_kept_when_flag_not_set(
self, mock_model_manager, mock_extract, tmp_path
):
from content_core.common import ExtractionOutput
from open_notebook.graphs.source import SourceState, content_process
mm_instance = MagicMock()
mm_instance.get_defaults = AsyncMock(
return_value=MagicMock(default_speech_to_text_model=None)
)
mock_model_manager.return_value = mm_instance
mock_extract.return_value = ExtractionOutput(title="Doc", content="text")
uploaded = tmp_path / "upload.pdf"
uploaded.write_text("data")
state = {
"source_id": "source:123",
"content_state": {"file_path": str(uploaded), "delete_source": False},
"embed": False,
"apply_transformations": [],
}
await content_process(cast(SourceState, state))
assert uploaded.exists() # file preserved
@pytest.mark.asyncio
@patch("open_notebook.graphs.source.extract_content")
@patch("open_notebook.graphs.source.ModelManager")
async def test_empty_extraction_raises_valueerror(
self, mock_model_manager, mock_extract, tmp_path
):
from content_core.common import ExtractionOutput
from open_notebook.graphs.source import SourceState, content_process
mm_instance = MagicMock()
mm_instance.get_defaults = AsyncMock(
return_value=MagicMock(default_speech_to_text_model=None)
)
mock_model_manager.return_value = mm_instance
mock_extract.return_value = ExtractionOutput(title="", content=" ")
state = {
"source_id": "source:123",
"content_state": {"url": "https://example.com"},
"embed": False,
"apply_transformations": [],
}
with pytest.raises(ValueError):
await content_process(cast(SourceState, state))
@pytest.mark.asyncio
@patch("open_notebook.graphs.source.ContentSettings")
@patch("open_notebook.graphs.source.extract_content")
@patch("open_notebook.graphs.source.ModelManager")
async def test_persisted_engines_wired_into_config(
self, mock_model_manager, mock_extract, mock_settings
):
"""The persisted content-processing engines reach ContentCoreConfig, so
a user-selected engine (e.g. crawl4ai) actually takes effect."""
from content_core.common import ExtractionOutput
from open_notebook.graphs.source import SourceState, content_process
mm_instance = MagicMock()
mm_instance.get_defaults = AsyncMock(
return_value=MagicMock(default_speech_to_text_model=None)
)
mock_model_manager.return_value = mm_instance
mock_settings.get_instance = AsyncMock(
return_value=MagicMock(
default_content_processing_engine_url="crawl4ai",
default_content_processing_engine_doc="docling",
docling_ocr=False,
)
)
mock_extract.return_value = ExtractionOutput(title="T", content="body")
state = {
"source_id": "source:123",
"content_state": {"url": "https://example.com"},
"embed": False,
"apply_transformations": [],
}
await content_process(cast(SourceState, state))
config = mock_extract.await_args.kwargs["config"]
assert config.url_engine == "crawl4ai"
assert config.document_engine == "docling"
assert config.docling_ocr is False
if __name__ == "__main__":
pytest.main([__file__, "-v"])