Files
markitect-main/tests/unit/spaces/test_rendering.py
tegwick 2a5c265458 feat(spaces): implement Phase 4 HTML Rendering Mode
Implements HTML rendering system for Information Spaces:

- SpaceRenderer: Abstract base class for renderers
- RenderConfig: Configuration for format, theme, TOC, etc.
- RenderResult: Immutable result with content hash and metadata
- ThemeConfig: Layered theme system with customization
- CompositeRenderer: Multi-format renderer delegation

- MarkdownToHTMLRenderer: Full markdown-to-HTML conversion
  - Theme support (github, dark, minimal, academic)
  - Code block handling
  - Link target="_blank" for external links
  - Table of contents generation
  - Heading ID generation for navigation
- HTMLRendererFactory: Factory for common renderer configurations

- SpaceRenderingService: Orchestration layer
  - Transclusion variable substitution
  - Render caching with automatic invalidation
  - Event emission (RENDER_STARTED, RENDER_COMPLETED, RENDER_FAILED)
  - Batch rendering support
  - Statistics tracking
- SpaceRenderingServiceBuilder: Fluent builder pattern

60 unit tests covering all components.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-08 08:42:27 +01:00

761 lines
23 KiB
Python

"""
Unit tests for Phase 4: HTML Rendering Mode components.
Tests cover:
- SpaceRenderer base class
- RenderConfig and ThemeConfig
- MarkdownToHTMLRenderer
- SpaceRenderingService
- Cache integration
"""
import pytest
from unittest.mock import Mock, MagicMock, patch
from pathlib import Path
from markitect.spaces.rendering import (
SpaceRenderer,
RenderConfig,
RenderResult,
RenderFormat,
ThemeConfig,
CompositeRenderer,
MarkdownToHTMLRenderer,
HTMLRendererFactory,
THEME_PROPERTIES,
combine_theme_properties,
SpaceRenderingService,
SpaceRenderingServiceBuilder,
)
from markitect.spaces.transclusion import (
RenderCache,
ReferenceGraph,
)
from markitect.spaces.events import EventBus, SpaceEventType
class TestRenderFormat:
"""Tests for RenderFormat enum."""
def test_html_format(self):
"""Test HTML format value."""
assert RenderFormat.HTML.value == "html"
def test_format_members(self):
"""Test all format members exist."""
assert RenderFormat.HTML
assert RenderFormat.PDF
assert RenderFormat.DOCX
assert RenderFormat.LATEX
class TestThemeConfig:
"""Tests for ThemeConfig dataclass."""
def test_default_values(self):
"""Test default configuration."""
config = ThemeConfig()
assert config.name == "default"
assert config.layers == ["basic"]
assert config.custom_css is None
assert config.custom_properties == {}
def test_custom_config(self):
"""Test custom configuration."""
config = ThemeConfig(
name="custom",
layers=["github", "dark"],
custom_css="body { color: red; }",
custom_properties={"max_width": "1000px"},
)
assert config.name == "custom"
assert config.layers == ["github", "dark"]
assert "color: red" in config.custom_css
assert config.custom_properties["max_width"] == "1000px"
class TestRenderConfig:
"""Tests for RenderConfig dataclass."""
def test_default_values(self):
"""Test default configuration."""
config = RenderConfig()
assert config.format == RenderFormat.HTML
assert config.include_toc is False
assert config.highlight_code is True
assert config.sanitize_html is True
assert config.link_target_blank is True
def test_custom_config(self):
"""Test custom configuration."""
theme = ThemeConfig(name="github")
config = RenderConfig(
format=RenderFormat.HTML,
theme=theme,
include_toc=True,
image_max_width="800px",
)
assert config.theme.name == "github"
assert config.include_toc is True
assert config.image_max_width == "800px"
class TestRenderResult:
"""Tests for RenderResult dataclass."""
def test_creation(self):
"""Test result creation."""
result = RenderResult(
content="<h1>Test</h1>",
format=RenderFormat.HTML,
content_hash="abc123",
source_hash="def456",
document_id="doc-1",
space_id="space-1",
)
assert result.content == "<h1>Test</h1>"
assert result.format == RenderFormat.HTML
assert result.document_id == "doc-1"
assert result.space_id == "space-1"
assert result.dependencies == set()
def test_with_dependencies(self):
"""Test result with dependencies."""
result = RenderResult(
content="<p>Test</p>",
format=RenderFormat.HTML,
content_hash="abc",
source_hash="def",
document_id="doc-1",
space_id="space-1",
dependencies={"doc-2", "doc-3"},
)
assert len(result.dependencies) == 2
assert "doc-2" in result.dependencies
def test_compute_hash(self):
"""Test hash computation."""
hash1 = RenderResult.compute_hash("test content")
hash2 = RenderResult.compute_hash("test content")
hash3 = RenderResult.compute_hash("different content")
assert hash1 == hash2
assert hash1 != hash3
assert len(hash1) == 16 # SHA256 truncated to 16 chars
class TestCombineThemeProperties:
"""Tests for theme property combination."""
def test_single_layer(self):
"""Test single theme layer."""
props = combine_theme_properties(["default"])
assert "font_family" in props
assert "body_color" in props
def test_multiple_layers(self):
"""Test multiple theme layers."""
props = combine_theme_properties(["default", "github"])
# GitHub layer should override default
assert props["max_width"] == "980px"
def test_unknown_layer(self):
"""Test with unknown layer."""
props = combine_theme_properties(["nonexistent"])
assert props == {}
def test_empty_layers(self):
"""Test with no layers."""
props = combine_theme_properties([])
assert props == {}
class TestMarkdownToHTMLRenderer:
"""Tests for MarkdownToHTMLRenderer."""
def test_supported_formats(self):
"""Test supported formats."""
renderer = MarkdownToHTMLRenderer()
assert RenderFormat.HTML in renderer.supported_formats
assert len(renderer.supported_formats) == 1
def test_simple_render(self):
"""Test simple markdown rendering."""
renderer = MarkdownToHTMLRenderer()
result = renderer.render(
content="# Hello World\n\nThis is a paragraph.",
document_id="doc-1",
space_id="space-1",
)
assert result.format == RenderFormat.HTML
assert "<h1" in result.content
assert "Hello World" in result.content
assert "<p>" in result.content
assert result.document_id == "doc-1"
assert result.space_id == "space-1"
def test_render_with_code(self):
"""Test rendering code blocks."""
renderer = MarkdownToHTMLRenderer()
markdown = """
# Code Example
```python
def hello():
print("Hello")
```
"""
result = renderer.render(
content=markdown,
document_id="doc-1",
space_id="space-1",
)
assert "<pre>" in result.content or "<code>" in result.content
def test_render_with_links(self):
"""Test link rendering with target blank."""
renderer = MarkdownToHTMLRenderer()
result = renderer.render(
content="Visit [Google](https://google.com)",
document_id="doc-1",
space_id="space-1",
)
assert "https://google.com" in result.content
assert 'target="_blank"' in result.content
def test_render_with_toc(self):
"""Test table of contents generation."""
config = RenderConfig(include_toc=True)
renderer = MarkdownToHTMLRenderer(config)
markdown = """
# Main Title
## Section 1
Content here.
## Section 2
More content.
"""
result = renderer.render(
content=markdown,
document_id="doc-1",
space_id="space-1",
)
assert 'class="toc"' in result.content
assert "Contents" in result.content
def test_render_with_dependencies(self):
"""Test dependencies are tracked."""
renderer = MarkdownToHTMLRenderer()
result = renderer.render(
content="# Test",
document_id="doc-1",
space_id="space-1",
dependencies={"dep-1", "dep-2"},
)
assert len(result.dependencies) == 2
assert "dep-1" in result.dependencies
def test_content_hash_computation(self):
"""Test that hashes are computed."""
renderer = MarkdownToHTMLRenderer()
result = renderer.render(
content="# Test",
document_id="doc-1",
space_id="space-1",
)
assert result.content_hash
assert result.source_hash
assert len(result.content_hash) == 16
def test_different_themes(self):
"""Test different theme configurations."""
github_config = RenderConfig(theme=ThemeConfig(name="github", layers=["github"]))
dark_config = RenderConfig(theme=ThemeConfig(name="dark", layers=["dark"]))
github_renderer = MarkdownToHTMLRenderer(github_config)
dark_renderer = MarkdownToHTMLRenderer(dark_config)
github_result = github_renderer.render("# Test", "doc", "space")
dark_result = dark_renderer.render("# Test", "doc", "space")
# Results should be different due to different themes
assert github_result.content != dark_result.content
assert "#24292e" in github_result.content # GitHub body color
assert "#c9d1d9" in dark_result.content # Dark mode text color
class TestHTMLRendererFactory:
"""Tests for HTMLRendererFactory."""
def test_create_default(self):
"""Test default renderer creation."""
renderer = HTMLRendererFactory.create_default()
assert isinstance(renderer, MarkdownToHTMLRenderer)
def test_create_github_style(self):
"""Test GitHub-style renderer."""
renderer = HTMLRendererFactory.create_github_style()
assert renderer.config.theme.name == "github"
def test_create_academic_style(self):
"""Test academic-style renderer."""
renderer = HTMLRendererFactory.create_academic_style()
assert renderer.config.theme.name == "academic"
assert renderer.config.include_toc is True
def test_create_minimal_style(self):
"""Test minimal-style renderer."""
renderer = HTMLRendererFactory.create_minimal_style()
assert renderer.config.theme.name == "minimal"
def test_create_dark_mode(self):
"""Test dark mode renderer."""
renderer = HTMLRendererFactory.create_dark_mode()
assert renderer.config.theme.name == "dark"
class TestCompositeRenderer:
"""Tests for CompositeRenderer."""
def test_register_renderer(self):
"""Test registering a renderer."""
composite = CompositeRenderer()
html_renderer = MarkdownToHTMLRenderer()
composite.register(html_renderer)
assert composite.get_renderer(RenderFormat.HTML) is html_renderer
def test_supported_formats(self):
"""Test listing supported formats."""
composite = CompositeRenderer()
html_renderer = MarkdownToHTMLRenderer()
composite.register(html_renderer)
formats = composite.supported_formats()
assert RenderFormat.HTML in formats
def test_render_via_composite(self):
"""Test rendering through composite."""
composite = CompositeRenderer()
composite.register(MarkdownToHTMLRenderer())
result = composite.render(
content="# Test",
document_id="doc-1",
space_id="space-1",
format=RenderFormat.HTML,
)
assert result.format == RenderFormat.HTML
assert "<h1" in result.content
def test_no_renderer_for_format(self):
"""Test error when no renderer for format."""
composite = CompositeRenderer()
with pytest.raises(ValueError) as exc:
composite.render(
content="test",
document_id="doc-1",
space_id="space-1",
format=RenderFormat.PDF,
)
assert "No renderer registered" in str(exc.value)
class TestSpaceRenderingService:
"""Tests for SpaceRenderingService."""
def test_default_initialization(self):
"""Test default service initialization."""
service = SpaceRenderingService()
assert service.renderer is not None
assert service.render_cache is not None
assert service.reference_graph is not None
def test_render_document(self):
"""Test basic document rendering."""
service = SpaceRenderingService()
result = service.render_document(
content="# Hello\n\nWorld",
document_id="doc-1",
space_id="space-1",
)
assert result.format == RenderFormat.HTML
assert "Hello" in result.content
assert result.document_id == "doc-1"
def test_render_caching(self):
"""Test that results are cached."""
service = SpaceRenderingService()
# First render
result1 = service.render_document(
content="# Test",
document_id="doc-1",
space_id="space-1",
)
# Second render - should use cache
result2 = service.render_document(
content="# Test",
document_id="doc-1",
space_id="space-1",
)
# Get cached entry
cached = service.get_cached_render("doc-1")
assert cached is not None
def test_force_refresh_bypasses_cache(self):
"""Test force refresh skips cache."""
service = SpaceRenderingService()
# First render
service.render_document(
content="# Test",
document_id="doc-1",
space_id="space-1",
)
# Force refresh with different content
result = service.render_document(
content="# New Content",
document_id="doc-1",
space_id="space-1",
force_refresh=True,
)
assert "New Content" in result.content
def test_render_with_variables(self):
"""Test rendering with variable substitution."""
service = SpaceRenderingService()
result = service.render_document(
content="Hello {{name}}!",
document_id="doc-1",
space_id="space-1",
variables={"name": "World"},
)
assert "World" in result.content
def test_render_documents_batch(self):
"""Test batch rendering."""
service = SpaceRenderingService()
documents = [
{"id": "doc-1", "content": "# Doc 1"},
{"id": "doc-2", "content": "# Doc 2"},
{"id": "doc-3", "content": "# Doc 3"},
]
results = service.render_documents(
documents=documents,
space_id="space-1",
)
assert len(results) == 3
assert "doc-1" in results
assert "doc-2" in results
assert "doc-3" in results
def test_invalidate_document(self):
"""Test cache invalidation."""
service = SpaceRenderingService()
# Render a document
service.render_document(
content="# Test",
document_id="doc-1",
space_id="space-1",
)
# Invalidate
invalidated = service.invalidate_document("doc-1", "space-1")
assert "doc-1" in invalidated
assert service.get_cached_render("doc-1") is None
def test_invalidate_space(self):
"""Test invalidating entire space."""
service = SpaceRenderingService()
# Render multiple documents
service.render_document("# Doc 1", "doc-1", "space-1")
service.render_document("# Doc 2", "doc-2", "space-1")
# Invalidate space
count = service.invalidate_space("space-1")
assert count == 2
def test_render_statistics(self):
"""Test getting render statistics."""
service = SpaceRenderingService()
# Render some documents
service.render_document("# Doc 1", "doc-1", "space-1")
service.render_document("# Doc 2", "doc-2", "space-1")
stats = service.get_render_statistics("space-1")
assert "cached_documents" in stats
assert "render_format" in stats
assert stats["cached_documents"] == 2
def test_event_emission(self):
"""Test that events are emitted during rendering."""
event_bus = EventBus()
events = []
def capture_event(event):
events.append(event)
event_bus.subscribe(SpaceEventType.RENDER_STARTED, capture_event)
event_bus.subscribe(SpaceEventType.RENDER_COMPLETED, capture_event)
service = SpaceRenderingService(event_bus=event_bus)
service.render_document(
content="# Test",
document_id="doc-1",
space_id="space-1",
)
# Should have RENDER_STARTED and RENDER_COMPLETED events
event_types = [e.event_type for e in events]
assert SpaceEventType.RENDER_STARTED in event_types
assert SpaceEventType.RENDER_COMPLETED in event_types
class TestSpaceRenderingServiceBuilder:
"""Tests for SpaceRenderingServiceBuilder."""
def test_build_default(self):
"""Test building with defaults."""
service = SpaceRenderingServiceBuilder().build()
assert service.renderer is not None
def test_with_html_renderer(self):
"""Test building with HTML renderer."""
config = RenderConfig(theme=ThemeConfig(name="github"))
service = (
SpaceRenderingServiceBuilder()
.with_html_renderer(config)
.build()
)
assert service.renderer.config.theme.name == "github"
def test_with_event_bus(self):
"""Test building with event bus."""
bus = EventBus()
service = (
SpaceRenderingServiceBuilder()
.with_event_bus(bus)
.build()
)
assert service.event_bus is bus
def test_with_cache(self):
"""Test building with custom cache."""
cache = RenderCache()
service = (
SpaceRenderingServiceBuilder()
.with_cache(cache)
.build()
)
assert service.render_cache is cache
def test_with_reference_graph(self):
"""Test building with reference graph."""
graph = ReferenceGraph()
service = (
SpaceRenderingServiceBuilder()
.with_reference_graph(graph)
.build()
)
assert service.reference_graph is graph
def test_fluent_chaining(self):
"""Test fluent builder chaining."""
bus = EventBus()
cache = RenderCache()
graph = ReferenceGraph()
service = (
SpaceRenderingServiceBuilder()
.with_html_renderer()
.with_event_bus(bus)
.with_cache(cache)
.with_reference_graph(graph)
.build()
)
assert service.event_bus is bus
assert service.render_cache is cache
assert service.reference_graph is graph
class TestBasicMarkdownParsing:
"""Tests for basic markdown parsing fallback."""
def test_headings(self):
"""Test heading parsing."""
renderer = MarkdownToHTMLRenderer()
markdown = "# H1\n## H2\n### H3"
result = renderer.render(markdown, "doc", "space")
assert "<h1" in result.content
assert "<h2" in result.content
assert "<h3" in result.content
def test_bold_italic(self):
"""Test bold and italic."""
renderer = MarkdownToHTMLRenderer()
markdown = "**bold** and *italic*"
result = renderer.render(markdown, "doc", "space")
assert "<strong>bold</strong>" in result.content or "bold" in result.content
def test_code_blocks(self):
"""Test code block parsing."""
renderer = MarkdownToHTMLRenderer()
markdown = "```python\nprint('hello')\n```"
result = renderer.render(markdown, "doc", "space")
assert "<code" in result.content or "<pre" in result.content
def test_lists(self):
"""Test list parsing."""
renderer = MarkdownToHTMLRenderer()
markdown = "- Item 1\n- Item 2\n- Item 3"
result = renderer.render(markdown, "doc", "space")
assert "<li>" in result.content or "Item" in result.content
def test_blockquotes(self):
"""Test blockquote parsing."""
renderer = MarkdownToHTMLRenderer()
markdown = "> This is a quote"
result = renderer.render(markdown, "doc", "space")
assert "<blockquote" in result.content or "quote" in result.content
def test_horizontal_rule(self):
"""Test horizontal rule."""
renderer = MarkdownToHTMLRenderer()
markdown = "Text\n\n---\n\nMore text"
result = renderer.render(markdown, "doc", "space")
assert "<hr" in result.content
class TestThemeProperties:
"""Tests for theme property definitions."""
def test_default_theme_exists(self):
"""Test default theme is defined."""
assert "default" in THEME_PROPERTIES
def test_github_theme_exists(self):
"""Test GitHub theme is defined."""
assert "github" in THEME_PROPERTIES
def test_dark_theme_exists(self):
"""Test dark theme is defined."""
assert "dark" in THEME_PROPERTIES
def test_minimal_theme_exists(self):
"""Test minimal theme is defined."""
assert "minimal" in THEME_PROPERTIES
def test_academic_theme_exists(self):
"""Test academic theme is defined."""
assert "academic" in THEME_PROPERTIES
def test_theme_has_required_properties(self):
"""Test themes have required properties."""
required = ["font_family", "body_color", "body_background"]
for theme_name, props in THEME_PROPERTIES.items():
for prop in required:
assert prop in props, f"{theme_name} missing {prop}"
class TestRenderIntegration:
"""Integration tests for rendering pipeline."""
def test_full_render_pipeline(self):
"""Test complete render pipeline with cache."""
event_bus = EventBus()
cache = RenderCache()
graph = ReferenceGraph()
service = (
SpaceRenderingServiceBuilder()
.with_html_renderer()
.with_event_bus(event_bus)
.with_cache(cache)
.with_reference_graph(graph)
.build()
)
# First render
result1 = service.render_document(
content="# Hello World",
document_id="doc-1",
space_id="space-1",
)
assert result1.format == RenderFormat.HTML
assert "Hello World" in result1.content
# Check cached
assert cache.get("doc-1") is not None
# Second render should use cache
result2 = service.render_document(
content="# Hello World",
document_id="doc-1",
space_id="space-1",
)
# Invalidate
invalidated = service.invalidate_document("doc-1", "space-1")
assert "doc-1" in invalidated
assert cache.get("doc-1") is None
def test_variable_substitution_in_render(self):
"""Test variable substitution during render."""
service = SpaceRenderingService()
content = "# Welcome {{user}}\n\nToday is {{day}}."
variables = {"user": "Alice", "day": "Monday"}
result = service.render_document(
content=content,
document_id="doc-1",
space_id="space-1",
variables=variables,
)
assert "Alice" in result.content
assert "Monday" in result.content