""" 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", "content") retrieved = cache.get("doc-1") assert retrieved is not None assert retrieved.rendered_content == "content" 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