Files
markitect-main/tests/unit/spaces/test_transclusion.py
tegwick 7da77396a9 feat(spaces): implement Phase 3 Persistent Transclusion Context
Implements persistent transclusion context for Information Spaces:

- ScopedVariables: Variable scope layers (request > document > space)
- SpaceTransclusionContext: Extends TransclusionContext with DB persistence
- CrossSpaceResolver: Resolve references across space boundaries
- ReferenceGraph: Track document dependencies for cache invalidation
- PersistentReferenceGraph: Repository-backed reference tracking
- RenderCache: Cache rendered output with invalidation support
- CacheInvalidator: Event-driven cache invalidation using reference graph

Key features:
- Variable precedence: request overrides document overrides space
- Reference tracking during transclusion processing
- Transitive dependent calculation for cache invalidation
- Event bus integration for automatic invalidation on content changes

47 unit tests covering all components.

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

566 lines
19 KiB
Python

"""
Unit tests for transclusion context and cache invalidation.
Tests the Phase 3 components:
- SpaceTransclusionContext with scoped variables
- ReferenceGraph for dependency tracking
- RenderCache and CacheInvalidator
"""
import pytest
from pathlib import Path
from markitect.spaces.transclusion import (
SpaceTransclusionContext,
ScopedVariables,
VariableScope,
CrossSpaceResolver,
ReferenceGraph,
DependencyNode,
RenderCache,
CacheEntry,
CacheInvalidator,
)
from markitect.spaces.events import EventBus, SpaceEventType
class TestScopedVariables:
"""Tests for ScopedVariables."""
def test_empty_variables(self):
"""Test empty scoped variables."""
scoped = ScopedVariables()
assert scoped.get("foo") is None
assert scoped.get("foo", "default") == "default"
def test_set_and_get_request_scope(self):
"""Test request scope variables."""
scoped = ScopedVariables()
scoped.set("key", "request_value", VariableScope.REQUEST)
assert scoped.get("key") == "request_value"
def test_set_and_get_document_scope(self):
"""Test document scope variables."""
scoped = ScopedVariables()
scoped.set("key", "doc_value", VariableScope.DOCUMENT)
assert scoped.get("key") == "doc_value"
def test_set_and_get_space_scope(self):
"""Test space scope variables."""
scoped = ScopedVariables()
scoped.set("key", "space_value", VariableScope.SPACE)
assert scoped.get("key") == "space_value"
def test_scope_precedence(self):
"""Test that higher scopes override lower scopes."""
scoped = ScopedVariables()
scoped.set("key", "space_value", VariableScope.SPACE)
scoped.set("key", "doc_value", VariableScope.DOCUMENT)
scoped.set("key", "request_value", VariableScope.REQUEST)
# Request scope wins
assert scoped.get("key") == "request_value"
def test_scope_fallback(self):
"""Test fallback to lower scopes."""
scoped = ScopedVariables()
scoped.set("space_only", "from_space", VariableScope.SPACE)
scoped.set("doc_only", "from_doc", VariableScope.DOCUMENT)
assert scoped.get("space_only") == "from_space"
assert scoped.get("doc_only") == "from_doc"
def test_get_all(self):
"""Test getting all merged variables."""
scoped = ScopedVariables()
scoped.set("a", "space_a", VariableScope.SPACE)
scoped.set("b", "doc_b", VariableScope.DOCUMENT)
scoped.set("c", "req_c", VariableScope.REQUEST)
scoped.set("a", "req_a", VariableScope.REQUEST) # Override
all_vars = scoped.get_all()
assert all_vars["a"] == "req_a" # Request wins
assert all_vars["b"] == "doc_b"
assert all_vars["c"] == "req_c"
def test_clear_scope(self):
"""Test clearing a specific scope."""
scoped = ScopedVariables()
scoped.set("key", "space_value", VariableScope.SPACE)
scoped.set("key", "request_value", VariableScope.REQUEST)
scoped.clear_scope(VariableScope.REQUEST)
# Now should fall back to space
assert scoped.get("key") == "space_value"
class TestSpaceTransclusionContext:
"""Tests for SpaceTransclusionContext."""
def test_basic_creation(self):
"""Test basic context creation."""
ctx = SpaceTransclusionContext(
space_id="space-1",
base_path=Path("/test"),
)
assert ctx.space_id == "space-1"
assert ctx.base_path == Path("/test")
def test_initial_variables(self):
"""Test context with initial variables."""
ctx = SpaceTransclusionContext(
space_id="space-1",
variables={"version": "1.0", "api_url": "https://api.example.com"},
)
assert ctx.get_variable("version") == "1.0"
assert ctx.get_variable("api_url") == "https://api.example.com"
def test_set_variable_with_scope(self):
"""Test setting variables with different scopes."""
ctx = SpaceTransclusionContext(space_id="space-1")
ctx.set_variable("global", "g", VariableScope.SPACE)
ctx.set_variable("local", "l", VariableScope.REQUEST)
assert ctx.get_variable("global") == "g"
assert ctx.get_variable("local") == "l"
def test_variable_substitution(self):
"""Test variable substitution in text."""
ctx = SpaceTransclusionContext(
space_id="space-1",
variables={"name": "John", "version": "2.0"},
)
result = ctx.substitute_variables("Hello {{name}}, welcome to v{{version}}")
assert result == "Hello John, welcome to v2.0"
def test_variable_substitution_missing(self):
"""Test that missing variables are left unchanged."""
ctx = SpaceTransclusionContext(space_id="space-1")
result = ctx.substitute_variables("Hello {{missing}}")
assert result == "Hello {{missing}}"
def test_reference_tracking(self):
"""Test reference tracking during processing."""
ctx = SpaceTransclusionContext(space_id="space-1")
ctx.set_current_document("doc-1")
ctx.track_reference("component-a")
ctx.track_reference("component-b")
refs = ctx.get_tracked_references()
assert len(refs) == 2
assert ("doc-1", "component-a") in refs
assert ("doc-1", "component-b") in refs
def test_clear_tracked_references(self):
"""Test clearing tracked references."""
ctx = SpaceTransclusionContext(space_id="space-1")
ctx.set_current_document("doc-1")
ctx.track_reference("target")
ctx.clear_tracked_references()
assert len(ctx.get_tracked_references()) == 0
def test_create_child_context(self):
"""Test creating a child context."""
parent = SpaceTransclusionContext(
space_id="space-1",
variables={"inherited": "value"},
)
parent.set_variable("space_var", "sv", VariableScope.SPACE)
child = parent.create_child_context(new_base_path=Path("/child"))
assert child.space_id == "space-1"
assert child.base_path == Path("/child")
assert child.get_variable("inherited") == "value"
assert child.get_variable("space_var") == "sv"
def test_child_context_shares_references(self):
"""Test that child context shares reference tracking."""
parent = SpaceTransclusionContext(space_id="space-1")
parent.set_current_document("doc-1")
child = parent.create_child_context()
child.track_reference("from-child")
# Parent should see the reference
refs = parent.get_tracked_references()
assert ("doc-1", "from-child") in refs
def test_variables_property(self):
"""Test the variables property returns merged dict."""
ctx = SpaceTransclusionContext(space_id="space-1")
ctx.set_variable("a", "1", VariableScope.SPACE)
ctx.set_variable("b", "2", VariableScope.REQUEST)
vars_dict = ctx.variables
assert vars_dict["a"] == "1"
assert vars_dict["b"] == "2"
class TestCrossSpaceResolver:
"""Tests for CrossSpaceResolver."""
def test_add_and_get_context(self):
"""Test adding and getting contexts."""
resolver = CrossSpaceResolver({})
ctx1 = SpaceTransclusionContext(space_id="space-1")
resolver.add_context("space-1", ctx1)
assert resolver.get_context("space-1") is ctx1
assert resolver.get_context("space-2") is None
def test_resolve_variable_from_space(self):
"""Test resolving variables across spaces."""
ctx1 = SpaceTransclusionContext(
space_id="space-1",
variables={"api_key": "key-1"},
)
ctx2 = SpaceTransclusionContext(
space_id="space-2",
variables={"api_key": "key-2"},
)
resolver = CrossSpaceResolver({"space-1": ctx1, "space-2": ctx2})
assert resolver.resolve_variable("space-1", "api_key") == "key-1"
assert resolver.resolve_variable("space-2", "api_key") == "key-2"
assert resolver.resolve_variable("space-3", "api_key") is None
def test_resolve_cross_space_reference(self):
"""Test parsing cross-space references."""
resolver = CrossSpaceResolver({})
# Cross-space reference
result = resolver.resolve_cross_space_reference(
"space:other-space/docs/intro.md", "current-space"
)
assert result == ("other-space", "/docs/intro.md")
# Same-space reference
result = resolver.resolve_cross_space_reference(
"/docs/intro.md", "current-space"
)
assert result == ("current-space", "/docs/intro.md")
class TestReferenceGraph:
"""Tests for ReferenceGraph."""
def test_empty_graph(self):
"""Test empty reference graph."""
graph = ReferenceGraph()
assert graph.get_references("doc-1") == set()
assert graph.get_dependents("doc-1") == set()
def test_add_reference(self):
"""Test adding a reference."""
graph = ReferenceGraph()
graph.add_reference("doc-1", "component-a", "space-1")
assert "component-a" in graph.get_references("doc-1")
assert "doc-1" in graph.get_dependents("component-a")
def test_multiple_references(self):
"""Test multiple references from one document."""
graph = ReferenceGraph()
graph.add_reference("doc-1", "comp-a", "space-1")
graph.add_reference("doc-1", "comp-b", "space-1")
refs = graph.get_references("doc-1")
assert refs == {"comp-a", "comp-b"}
def test_multiple_dependents(self):
"""Test multiple documents depending on one."""
graph = ReferenceGraph()
graph.add_reference("doc-1", "shared", "space-1")
graph.add_reference("doc-2", "shared", "space-1")
graph.add_reference("doc-3", "shared", "space-1")
deps = graph.get_dependents("shared")
assert deps == {"doc-1", "doc-2", "doc-3"}
def test_remove_reference(self):
"""Test removing a reference."""
graph = ReferenceGraph()
graph.add_reference("doc-1", "target", "space-1")
graph.remove_reference("doc-1", "target")
assert graph.get_references("doc-1") == set()
assert graph.get_dependents("target") == set()
def test_clear_references_from(self):
"""Test clearing all references from a document."""
graph = ReferenceGraph()
graph.add_reference("doc-1", "a", "space-1")
graph.add_reference("doc-1", "b", "space-1")
graph.add_reference("doc-1", "c", "space-1")
targets = graph.clear_references_from("doc-1")
assert set(targets) == {"a", "b", "c"}
assert graph.get_references("doc-1") == set()
def test_transitive_dependents(self):
"""Test getting transitive dependents."""
graph = ReferenceGraph()
# doc-1 -> shared
# doc-2 -> doc-1
# doc-3 -> doc-2
graph.add_reference("doc-1", "shared", "space-1")
graph.add_reference("doc-2", "doc-1", "space-1")
graph.add_reference("doc-3", "doc-2", "space-1")
# Transitive dependents of shared
deps = graph.get_transitive_dependents("shared")
assert deps == {"doc-1", "doc-2", "doc-3"}
def test_transitive_dependents_with_cycle(self):
"""Test transitive dependents handles cycles gracefully."""
graph = ReferenceGraph()
# Create a cycle: a -> b -> c -> a
graph.add_reference("a", "b", "space-1")
graph.add_reference("b", "c", "space-1")
graph.add_reference("c", "a", "space-1")
# Should not infinite loop
deps = graph.get_transitive_dependents("b")
assert "a" in deps
def test_get_documents_in_space(self):
"""Test getting all documents in a space."""
graph = ReferenceGraph()
graph.add_reference("doc-1", "doc-2", "space-1")
graph.add_reference("doc-3", "doc-4", "space-1")
graph.add_reference("other", "doc", "space-2")
space1_docs = graph.get_documents_in_space("space-1")
assert space1_docs == {"doc-1", "doc-2", "doc-3", "doc-4"}
def test_remove_document(self):
"""Test removing a document from the graph."""
graph = ReferenceGraph()
graph.add_reference("doc-1", "shared", "space-1")
graph.add_reference("doc-2", "shared", "space-1")
graph.remove_document("doc-1")
# doc-1's references should be gone
assert "doc-1" not in graph.get_dependents("shared")
# doc-2's references should remain
assert "doc-2" in graph.get_dependents("shared")
def test_clear_space(self):
"""Test clearing all documents in a space."""
graph = ReferenceGraph()
graph.add_reference("doc-1", "doc-2", "space-1")
graph.add_reference("doc-3", "doc-4", "space-1")
graph.add_reference("other", "doc", "space-2")
graph.clear_space("space-1")
assert graph.get_documents_in_space("space-1") == set()
assert graph.get_documents_in_space("space-2") == {"other", "doc"}
class TestRenderCache:
"""Tests for RenderCache."""
def test_empty_cache(self):
"""Test empty cache."""
cache = RenderCache()
assert cache.get("doc-1") is None
def test_put_and_get(self):
"""Test putting and getting cache entries."""
cache = RenderCache()
entry = cache.put("doc-1", "space-1", "hash123", "<html>content</html>")
retrieved = cache.get("doc-1")
assert retrieved is not None
assert retrieved.rendered_content == "<html>content</html>"
assert retrieved.content_hash == "hash123"
def test_is_valid(self):
"""Test validity checking."""
cache = RenderCache()
cache.put("doc-1", "space-1", "hash123", "content")
assert cache.is_valid("doc-1", "hash123") is True
assert cache.is_valid("doc-1", "different_hash") is False
assert cache.is_valid("non-existent", "hash123") is False
def test_invalidate(self):
"""Test invalidating a cache entry."""
cache = RenderCache()
cache.put("doc-1", "space-1", "hash", "content")
result = cache.invalidate("doc-1")
assert result is True
assert cache.get("doc-1") is None
def test_invalidate_nonexistent(self):
"""Test invalidating non-existent entry."""
cache = RenderCache()
result = cache.invalidate("non-existent")
assert result is False
def test_invalidate_many(self):
"""Test invalidating multiple entries."""
cache = RenderCache()
cache.put("doc-1", "space-1", "h1", "c1")
cache.put("doc-2", "space-1", "h2", "c2")
cache.put("doc-3", "space-1", "h3", "c3")
count = cache.invalidate_many({"doc-1", "doc-2", "doc-4"})
assert count == 2
assert cache.get("doc-1") is None
assert cache.get("doc-2") is None
assert cache.get("doc-3") is not None
def test_invalidate_space(self):
"""Test invalidating all entries in a space."""
cache = RenderCache()
cache.put("doc-1", "space-1", "h1", "c1")
cache.put("doc-2", "space-1", "h2", "c2")
cache.put("doc-3", "space-2", "h3", "c3")
count = cache.invalidate_space("space-1")
assert count == 2
assert cache.get("doc-1") is None
assert cache.get("doc-2") is None
assert cache.get("doc-3") is not None
def test_get_cached_documents(self):
"""Test getting cached document IDs for a space."""
cache = RenderCache()
cache.put("doc-1", "space-1", "h1", "c1")
cache.put("doc-2", "space-1", "h2", "c2")
cache.put("doc-3", "space-2", "h3", "c3")
docs = cache.get_cached_documents("space-1")
assert docs == {"doc-1", "doc-2"}
def test_clear(self):
"""Test clearing all cache entries."""
cache = RenderCache()
cache.put("doc-1", "space-1", "h1", "c1")
cache.put("doc-2", "space-2", "h2", "c2")
count = cache.clear()
assert count == 2
assert cache.get("doc-1") is None
assert cache.get("doc-2") is None
def test_cache_with_dependencies(self):
"""Test cache entry with dependencies."""
cache = RenderCache()
entry = cache.put(
"doc-1",
"space-1",
"hash",
"content",
dependencies={"comp-a", "comp-b"},
)
assert entry.dependencies == {"comp-a", "comp-b"}
class TestCacheInvalidator:
"""Tests for CacheInvalidator."""
def test_basic_invalidation(self):
"""Test basic document invalidation."""
cache = RenderCache()
graph = ReferenceGraph()
cache.put("doc-1", "space-1", "h1", "c1")
invalidator = CacheInvalidator(cache, graph)
invalidated = invalidator.invalidate_for_document("doc-1", "space-1")
assert "doc-1" in invalidated
assert cache.get("doc-1") is None
def test_invalidation_with_dependents(self):
"""Test invalidation cascades to dependents."""
cache = RenderCache()
graph = ReferenceGraph()
# doc-1 and doc-2 depend on shared
graph.add_reference("doc-1", "shared", "space-1")
graph.add_reference("doc-2", "shared", "space-1")
cache.put("doc-1", "space-1", "h1", "c1")
cache.put("doc-2", "space-1", "h2", "c2")
cache.put("shared", "space-1", "hs", "cs")
invalidator = CacheInvalidator(cache, graph, transitive=True)
invalidated = invalidator.invalidate_for_document("shared", "space-1")
assert "shared" in invalidated
assert "doc-1" in invalidated
assert "doc-2" in invalidated
def test_invalidation_non_transitive(self):
"""Test non-transitive invalidation."""
cache = RenderCache()
graph = ReferenceGraph()
# doc-1 -> shared, doc-2 -> doc-1
graph.add_reference("doc-1", "shared", "space-1")
graph.add_reference("doc-2", "doc-1", "space-1")
cache.put("doc-1", "space-1", "h1", "c1")
cache.put("doc-2", "space-1", "h2", "c2")
invalidator = CacheInvalidator(cache, graph, transitive=False)
invalidated = invalidator.invalidate_for_document("shared", "space-1")
# Only direct dependent should be invalidated
assert "doc-1" in invalidated
assert "doc-2" not in invalidated
def test_event_subscription(self):
"""Test that invalidator subscribes to events."""
cache = RenderCache()
graph = ReferenceGraph()
bus = EventBus()
cache.put("doc-1", "space-1", "h1", "c1")
invalidator = CacheInvalidator(cache, graph, event_bus=bus)
# Emit content changed event
from markitect.spaces.events import document_content_changed_event
bus.emit(document_content_changed_event("space-1", "doc-1", "h1", "h2"))
# Cache should be invalidated
assert cache.get("doc-1") is None
# Cleanup
invalidator.unsubscribe()
def test_invalidate_all(self):
"""Test invalidating all entries in a space."""
cache = RenderCache()
graph = ReferenceGraph()
cache.put("doc-1", "space-1", "h1", "c1")
cache.put("doc-2", "space-1", "h2", "c2")
invalidator = CacheInvalidator(cache, graph)
count = invalidator.invalidate_all("space-1")
assert count == 2
assert cache.get("doc-1") is None
assert cache.get("doc-2") is None