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>
566 lines
19 KiB
Python
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
|