feat(spaces): implement Phase 5 Directory Sync Mode
Implements directory synchronization for Information Spaces: - SpaceDirectoryExporter: Export space to directory structure - Multiple variants: flat, hierarchical, by_path - Manifest generation for reimport - Incremental export (skip unchanged files) - Metadata file export - IncrementalExporter for change detection - DirectorySpaceImporter: Import directory content into space - Recursive directory scanning - Multiple file pattern support - Conflict detection with strategies (skip/overwrite/rename) - Manifest-based import for intelligent reimport - Structure preservation in space paths - BidirectionalSyncCoordinator: Two-way sync with conflict detection - Sync directions: space-to-directory, directory-to-space, bidirectional - Conflict resolution strategies: space_wins, directory_wins, newer_wins, manual, skip - Dry-run mode for preview - Orphan cleanup option - Event emission for progress tracking 45 unit tests covering all sync components. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
794
tests/unit/spaces/test_sync.py
Normal file
794
tests/unit/spaces/test_sync.py
Normal file
@@ -0,0 +1,794 @@
|
||||
"""
|
||||
Unit tests for Phase 5: Directory Sync components.
|
||||
|
||||
Tests cover:
|
||||
- SpaceDirectoryExporter
|
||||
- DirectorySpaceImporter
|
||||
- BidirectionalSyncCoordinator
|
||||
- Conflict detection and resolution
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import tempfile
|
||||
import json
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from unittest.mock import Mock, MagicMock
|
||||
|
||||
from markitect.spaces.sync import (
|
||||
SpaceDirectoryExporter,
|
||||
IncrementalExporter,
|
||||
ExportConfig,
|
||||
ExportVariant,
|
||||
ExportResult,
|
||||
ExportedFile,
|
||||
DirectorySpaceImporter,
|
||||
ManifestImporter,
|
||||
ImportConfig,
|
||||
ImportResult,
|
||||
ImportedDocument,
|
||||
ImportConflict,
|
||||
BidirectionalSyncCoordinator,
|
||||
SyncConfig,
|
||||
SyncDirection,
|
||||
ConflictResolution,
|
||||
SyncResult,
|
||||
SyncAction,
|
||||
SyncConflict,
|
||||
FileState,
|
||||
create_sync_coordinator,
|
||||
)
|
||||
from markitect.spaces.models import InformationSpace, SpaceDocument, SpaceStatus
|
||||
from markitect.spaces.events import EventBus, SpaceEventType
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_dir():
|
||||
"""Create a temporary directory for tests."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
yield Path(tmpdir)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_space():
|
||||
"""Create a sample space."""
|
||||
return InformationSpace(
|
||||
id="space-1",
|
||||
name="Test Space",
|
||||
description="A test space",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_documents():
|
||||
"""Create sample documents."""
|
||||
return [
|
||||
SpaceDocument(
|
||||
id="doc-1",
|
||||
space_id="space-1",
|
||||
document_id="doc-1",
|
||||
space_path="/intro.md",
|
||||
),
|
||||
SpaceDocument(
|
||||
id="doc-2",
|
||||
space_id="space-1",
|
||||
document_id="doc-2",
|
||||
space_path="/chapter1/overview.md",
|
||||
),
|
||||
SpaceDocument(
|
||||
id="doc-3",
|
||||
space_id="space-1",
|
||||
document_id="doc-3",
|
||||
space_path="/chapter1/details.md",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def content_provider():
|
||||
"""Create a mock content provider."""
|
||||
content_map = {
|
||||
"doc-1": "# Introduction\n\nWelcome to the docs.",
|
||||
"doc-2": "# Chapter 1 Overview\n\nThis is chapter 1.",
|
||||
"doc-3": "# Details\n\nMore details here.",
|
||||
}
|
||||
return lambda doc_id: content_map.get(doc_id)
|
||||
|
||||
|
||||
class TestExportVariant:
|
||||
"""Tests for ExportVariant enum."""
|
||||
|
||||
def test_variants_exist(self):
|
||||
"""Test all variants are defined."""
|
||||
assert ExportVariant.FLAT
|
||||
assert ExportVariant.HIERARCHICAL
|
||||
assert ExportVariant.BY_PATH
|
||||
|
||||
|
||||
class TestExportConfig:
|
||||
"""Tests for ExportConfig."""
|
||||
|
||||
def test_default_values(self):
|
||||
"""Test default configuration."""
|
||||
config = ExportConfig()
|
||||
assert config.variant == ExportVariant.BY_PATH
|
||||
assert config.include_metadata is True
|
||||
assert config.include_manifest is True
|
||||
assert config.overwrite is False
|
||||
|
||||
def test_custom_config(self):
|
||||
"""Test custom configuration."""
|
||||
config = ExportConfig(
|
||||
variant=ExportVariant.FLAT,
|
||||
overwrite=True,
|
||||
include_manifest=False,
|
||||
)
|
||||
assert config.variant == ExportVariant.FLAT
|
||||
assert config.overwrite is True
|
||||
assert config.include_manifest is False
|
||||
|
||||
|
||||
class TestExportResult:
|
||||
"""Tests for ExportResult."""
|
||||
|
||||
def test_success_property(self):
|
||||
"""Test success property."""
|
||||
result = ExportResult(space_id="s1", target_directory=Path("/tmp"))
|
||||
assert result.success is True
|
||||
|
||||
result.errors["file1"] = "error"
|
||||
assert result.success is False
|
||||
|
||||
def test_file_count(self):
|
||||
"""Test file count property."""
|
||||
result = ExportResult(space_id="s1", target_directory=Path("/tmp"))
|
||||
assert result.file_count == 0
|
||||
|
||||
result.exported_files.append(
|
||||
ExportedFile("d1", "/path", Path("/tmp/file.md"), "abc", 100)
|
||||
)
|
||||
assert result.file_count == 1
|
||||
|
||||
|
||||
class TestSpaceDirectoryExporter:
|
||||
"""Tests for SpaceDirectoryExporter."""
|
||||
|
||||
def test_default_initialization(self):
|
||||
"""Test default exporter initialization."""
|
||||
exporter = SpaceDirectoryExporter()
|
||||
assert exporter.config is not None
|
||||
assert exporter.config.variant == ExportVariant.BY_PATH
|
||||
|
||||
def test_export_space_creates_directory(
|
||||
self, temp_dir, sample_space, sample_documents, content_provider
|
||||
):
|
||||
"""Test that export creates target directory."""
|
||||
target = temp_dir / "export"
|
||||
exporter = SpaceDirectoryExporter()
|
||||
|
||||
result = exporter.export_space(
|
||||
space=sample_space,
|
||||
documents=sample_documents,
|
||||
content_provider=content_provider,
|
||||
target_directory=target,
|
||||
)
|
||||
|
||||
assert target.exists()
|
||||
assert result.success
|
||||
|
||||
def test_export_creates_files(
|
||||
self, temp_dir, sample_space, sample_documents, content_provider
|
||||
):
|
||||
"""Test that export creates document files."""
|
||||
target = temp_dir / "export"
|
||||
exporter = SpaceDirectoryExporter()
|
||||
|
||||
result = exporter.export_space(
|
||||
space=sample_space,
|
||||
documents=sample_documents,
|
||||
content_provider=content_provider,
|
||||
target_directory=target,
|
||||
)
|
||||
|
||||
assert result.file_count == 3
|
||||
assert (target / "intro.md").exists()
|
||||
assert (target / "chapter1" / "overview.md").exists()
|
||||
|
||||
def test_export_creates_manifest(
|
||||
self, temp_dir, sample_space, sample_documents, content_provider
|
||||
):
|
||||
"""Test that export creates manifest file."""
|
||||
target = temp_dir / "export"
|
||||
exporter = SpaceDirectoryExporter()
|
||||
|
||||
result = exporter.export_space(
|
||||
space=sample_space,
|
||||
documents=sample_documents,
|
||||
content_provider=content_provider,
|
||||
target_directory=target,
|
||||
)
|
||||
|
||||
assert result.manifest_path is not None
|
||||
assert result.manifest_path.exists()
|
||||
|
||||
manifest = json.loads(result.manifest_path.read_text())
|
||||
assert manifest["space_id"] == "space-1"
|
||||
assert len(manifest["files"]) == 3
|
||||
|
||||
def test_export_flat_variant(
|
||||
self, temp_dir, sample_space, sample_documents, content_provider
|
||||
):
|
||||
"""Test flat variant export."""
|
||||
target = temp_dir / "export"
|
||||
config = ExportConfig(variant=ExportVariant.FLAT)
|
||||
exporter = SpaceDirectoryExporter(config)
|
||||
|
||||
result = exporter.export_space(
|
||||
space=sample_space,
|
||||
documents=sample_documents,
|
||||
content_provider=content_provider,
|
||||
target_directory=target,
|
||||
)
|
||||
|
||||
# All files should be at root level
|
||||
assert result.success
|
||||
md_files = list(target.glob("*.md"))
|
||||
assert len(md_files) == 3
|
||||
|
||||
def test_export_skips_unchanged(
|
||||
self, temp_dir, sample_space, sample_documents, content_provider
|
||||
):
|
||||
"""Test that unchanged files are skipped on re-export."""
|
||||
target = temp_dir / "export"
|
||||
exporter = SpaceDirectoryExporter()
|
||||
|
||||
# First export
|
||||
result1 = exporter.export_space(
|
||||
space=sample_space,
|
||||
documents=sample_documents,
|
||||
content_provider=content_provider,
|
||||
target_directory=target,
|
||||
)
|
||||
assert result1.file_count == 3
|
||||
|
||||
# Second export should skip unchanged
|
||||
result2 = exporter.export_space(
|
||||
space=sample_space,
|
||||
documents=sample_documents,
|
||||
content_provider=content_provider,
|
||||
target_directory=target,
|
||||
)
|
||||
assert len(result2.skipped_files) == 3
|
||||
|
||||
def test_export_with_overwrite(
|
||||
self, temp_dir, sample_space, sample_documents, content_provider
|
||||
):
|
||||
"""Test overwrite mode."""
|
||||
target = temp_dir / "export"
|
||||
config = ExportConfig(overwrite=True)
|
||||
exporter = SpaceDirectoryExporter(config)
|
||||
|
||||
# First export
|
||||
exporter.export_space(
|
||||
sample_space, sample_documents, content_provider, target
|
||||
)
|
||||
|
||||
# Second export with overwrite
|
||||
result = exporter.export_space(
|
||||
sample_space, sample_documents, content_provider, target
|
||||
)
|
||||
|
||||
# Files should still be exported (overwritten)
|
||||
assert result.file_count == 3
|
||||
|
||||
def test_export_emits_events(
|
||||
self, temp_dir, sample_space, sample_documents, content_provider
|
||||
):
|
||||
"""Test event emission during export."""
|
||||
event_bus = EventBus()
|
||||
events = []
|
||||
|
||||
def capture(event):
|
||||
events.append(event)
|
||||
|
||||
event_bus.subscribe(SpaceEventType.SYNC_STARTED, capture)
|
||||
event_bus.subscribe(SpaceEventType.SYNC_COMPLETED, capture)
|
||||
|
||||
exporter = SpaceDirectoryExporter(event_bus=event_bus)
|
||||
exporter.export_space(
|
||||
sample_space, sample_documents, content_provider, temp_dir / "export"
|
||||
)
|
||||
|
||||
event_types = [e.event_type for e in events]
|
||||
assert SpaceEventType.SYNC_STARTED in event_types
|
||||
assert SpaceEventType.SYNC_COMPLETED in event_types
|
||||
|
||||
|
||||
class TestIncrementalExporter:
|
||||
"""Tests for IncrementalExporter."""
|
||||
|
||||
def test_load_previous_state(self, temp_dir):
|
||||
"""Test loading previous export state."""
|
||||
# Create a manifest
|
||||
manifest = {
|
||||
"files": [
|
||||
{"document_id": "doc-1", "content_hash": "abc123"},
|
||||
]
|
||||
}
|
||||
manifest_path = temp_dir / ".markitect-manifest.json"
|
||||
manifest_path.write_text(json.dumps(manifest))
|
||||
|
||||
exporter = IncrementalExporter()
|
||||
exporter.load_previous_state(temp_dir)
|
||||
|
||||
assert exporter._last_export_hashes.get("doc-1") == "abc123"
|
||||
|
||||
def test_has_changed(self, temp_dir):
|
||||
"""Test change detection."""
|
||||
exporter = IncrementalExporter()
|
||||
exporter._last_export_hashes["doc-1"] = "abc123"
|
||||
|
||||
# Same content - no change
|
||||
assert exporter.has_changed("doc-1", "test") is True # Different hash
|
||||
|
||||
# Unknown document - always changed
|
||||
assert exporter.has_changed("doc-new", "test") is True
|
||||
|
||||
|
||||
class TestImportConfig:
|
||||
"""Tests for ImportConfig."""
|
||||
|
||||
def test_default_values(self):
|
||||
"""Test default configuration."""
|
||||
config = ImportConfig()
|
||||
assert "*.md" in config.file_patterns
|
||||
assert config.recursive is True
|
||||
assert config.conflict_strategy == "skip"
|
||||
|
||||
def test_custom_config(self):
|
||||
"""Test custom configuration."""
|
||||
config = ImportConfig(
|
||||
file_patterns=["*.txt"],
|
||||
recursive=False,
|
||||
conflict_strategy="overwrite",
|
||||
)
|
||||
assert config.file_patterns == ["*.txt"]
|
||||
assert config.recursive is False
|
||||
|
||||
|
||||
class TestImportResult:
|
||||
"""Tests for ImportResult."""
|
||||
|
||||
def test_success_property(self):
|
||||
"""Test success property."""
|
||||
result = ImportResult(source_directory=Path("/tmp"))
|
||||
assert result.success is True
|
||||
|
||||
result.errors["file1"] = "error"
|
||||
assert result.success is False
|
||||
|
||||
|
||||
class TestDirectorySpaceImporter:
|
||||
"""Tests for DirectorySpaceImporter."""
|
||||
|
||||
def test_scan_directory(self, temp_dir):
|
||||
"""Test directory scanning."""
|
||||
# Create test files
|
||||
(temp_dir / "doc1.md").write_text("# Doc 1")
|
||||
(temp_dir / "doc2.md").write_text("# Doc 2")
|
||||
(temp_dir / "ignore.txt").write_text("ignore me")
|
||||
|
||||
importer = DirectorySpaceImporter()
|
||||
files = importer.scan_directory(temp_dir)
|
||||
|
||||
assert len(files) == 2
|
||||
assert all(f.suffix == ".md" for f in files)
|
||||
|
||||
def test_scan_recursive(self, temp_dir):
|
||||
"""Test recursive scanning."""
|
||||
# Create nested structure
|
||||
(temp_dir / "doc1.md").write_text("# Doc 1")
|
||||
subdir = temp_dir / "subdir"
|
||||
subdir.mkdir()
|
||||
(subdir / "doc2.md").write_text("# Doc 2")
|
||||
|
||||
importer = DirectorySpaceImporter()
|
||||
files = importer.scan_directory(temp_dir)
|
||||
|
||||
assert len(files) == 2
|
||||
|
||||
def test_scan_non_recursive(self, temp_dir):
|
||||
"""Test non-recursive scanning."""
|
||||
(temp_dir / "doc1.md").write_text("# Doc 1")
|
||||
subdir = temp_dir / "subdir"
|
||||
subdir.mkdir()
|
||||
(subdir / "doc2.md").write_text("# Doc 2")
|
||||
|
||||
config = ImportConfig(recursive=False)
|
||||
importer = DirectorySpaceImporter(config)
|
||||
files = importer.scan_directory(temp_dir)
|
||||
|
||||
assert len(files) == 1
|
||||
|
||||
def test_import_directory(self, temp_dir):
|
||||
"""Test basic directory import."""
|
||||
# Create test files
|
||||
(temp_dir / "intro.md").write_text("# Introduction")
|
||||
subdir = temp_dir / "chapter1"
|
||||
subdir.mkdir()
|
||||
(subdir / "content.md").write_text("# Content")
|
||||
|
||||
importer = DirectorySpaceImporter()
|
||||
result = importer.import_directory(temp_dir)
|
||||
|
||||
assert result.success
|
||||
assert result.document_count == 2
|
||||
|
||||
def test_import_preserves_structure(self, temp_dir):
|
||||
"""Test that import preserves directory structure."""
|
||||
subdir = temp_dir / "docs" / "api"
|
||||
subdir.mkdir(parents=True)
|
||||
(subdir / "reference.md").write_text("# API Reference")
|
||||
|
||||
importer = DirectorySpaceImporter()
|
||||
result = importer.import_directory(temp_dir)
|
||||
|
||||
assert result.document_count == 1
|
||||
doc = result.imported_documents[0]
|
||||
assert "/docs/api/reference.md" in doc.space_path
|
||||
|
||||
def test_import_with_metadata(self, temp_dir):
|
||||
"""Test import loads space metadata."""
|
||||
metadata = {"id": "space-123", "name": "Imported Space"}
|
||||
(temp_dir / ".markitect-space.json").write_text(json.dumps(metadata))
|
||||
(temp_dir / "doc.md").write_text("# Doc")
|
||||
|
||||
importer = DirectorySpaceImporter()
|
||||
result = importer.import_directory(temp_dir)
|
||||
|
||||
assert result.space_metadata is not None
|
||||
assert result.space_metadata["id"] == "space-123"
|
||||
assert result.space_id == "space-123"
|
||||
|
||||
def test_import_conflict_skip(self, temp_dir):
|
||||
"""Test conflict handling with skip strategy."""
|
||||
(temp_dir / "doc.md").write_text("# New Content")
|
||||
|
||||
existing = {
|
||||
"/doc.md": SpaceDocument(
|
||||
id="existing",
|
||||
space_id="s1",
|
||||
document_id="existing",
|
||||
space_path="/doc.md",
|
||||
)
|
||||
}
|
||||
|
||||
config = ImportConfig(conflict_strategy="skip")
|
||||
importer = DirectorySpaceImporter(config)
|
||||
result = importer.import_directory(temp_dir, existing)
|
||||
|
||||
assert len(result.conflicts) == 1
|
||||
assert result.conflicts[0].resolution == "skip"
|
||||
|
||||
def test_import_emits_events(self, temp_dir):
|
||||
"""Test event emission during import."""
|
||||
(temp_dir / "doc.md").write_text("# Doc")
|
||||
|
||||
event_bus = EventBus()
|
||||
events = []
|
||||
|
||||
event_bus.subscribe(SpaceEventType.SYNC_STARTED, lambda e: events.append(e))
|
||||
event_bus.subscribe(SpaceEventType.SYNC_COMPLETED, lambda e: events.append(e))
|
||||
|
||||
importer = DirectorySpaceImporter(event_bus=event_bus)
|
||||
importer.import_directory(temp_dir)
|
||||
|
||||
event_types = [e.event_type for e in events]
|
||||
assert SpaceEventType.SYNC_STARTED in event_types
|
||||
assert SpaceEventType.SYNC_COMPLETED in event_types
|
||||
|
||||
|
||||
class TestSyncDirection:
|
||||
"""Tests for SyncDirection enum."""
|
||||
|
||||
def test_directions_exist(self):
|
||||
"""Test all directions are defined."""
|
||||
assert SyncDirection.SPACE_TO_DIRECTORY
|
||||
assert SyncDirection.DIRECTORY_TO_SPACE
|
||||
assert SyncDirection.BIDIRECTIONAL
|
||||
|
||||
|
||||
class TestConflictResolution:
|
||||
"""Tests for ConflictResolution enum."""
|
||||
|
||||
def test_resolutions_exist(self):
|
||||
"""Test all resolutions are defined."""
|
||||
assert ConflictResolution.SPACE_WINS
|
||||
assert ConflictResolution.DIRECTORY_WINS
|
||||
assert ConflictResolution.NEWER_WINS
|
||||
assert ConflictResolution.MANUAL
|
||||
assert ConflictResolution.SKIP
|
||||
|
||||
|
||||
class TestSyncConfig:
|
||||
"""Tests for SyncConfig."""
|
||||
|
||||
def test_default_values(self):
|
||||
"""Test default configuration."""
|
||||
config = SyncConfig()
|
||||
assert config.direction == SyncDirection.BIDIRECTIONAL
|
||||
assert config.conflict_resolution == ConflictResolution.NEWER_WINS
|
||||
assert config.dry_run is False
|
||||
|
||||
def test_custom_config(self):
|
||||
"""Test custom configuration."""
|
||||
config = SyncConfig(
|
||||
direction=SyncDirection.SPACE_TO_DIRECTORY,
|
||||
conflict_resolution=ConflictResolution.SPACE_WINS,
|
||||
dry_run=True,
|
||||
)
|
||||
assert config.direction == SyncDirection.SPACE_TO_DIRECTORY
|
||||
assert config.conflict_resolution == ConflictResolution.SPACE_WINS
|
||||
|
||||
|
||||
class TestFileState:
|
||||
"""Tests for FileState."""
|
||||
|
||||
def test_creation(self):
|
||||
"""Test file state creation."""
|
||||
state = FileState(
|
||||
path="/doc.md",
|
||||
content_hash="abc123",
|
||||
modified_at=datetime.now(),
|
||||
size=100,
|
||||
source="space",
|
||||
)
|
||||
assert state.path == "/doc.md"
|
||||
assert state.source == "space"
|
||||
|
||||
|
||||
class TestSyncResult:
|
||||
"""Tests for SyncResult."""
|
||||
|
||||
def test_success_property(self):
|
||||
"""Test success property."""
|
||||
result = SyncResult(
|
||||
space_id="s1",
|
||||
directory=Path("/tmp"),
|
||||
direction=SyncDirection.BIDIRECTIONAL,
|
||||
)
|
||||
assert result.success is True
|
||||
|
||||
result.errors["file1"] = "error"
|
||||
assert result.success is False
|
||||
|
||||
def test_has_conflicts(self):
|
||||
"""Test has_conflicts property."""
|
||||
result = SyncResult(
|
||||
space_id="s1",
|
||||
directory=Path("/tmp"),
|
||||
direction=SyncDirection.BIDIRECTIONAL,
|
||||
)
|
||||
assert result.has_conflicts is False
|
||||
|
||||
result.conflicts.append(
|
||||
SyncConflict(
|
||||
path="/doc.md",
|
||||
space_state=FileState("/doc.md", "abc", source="space"),
|
||||
directory_state=FileState("/doc.md", "def", source="directory"),
|
||||
resolution=ConflictResolution.MANUAL,
|
||||
winner="none",
|
||||
)
|
||||
)
|
||||
assert result.has_conflicts is True
|
||||
|
||||
|
||||
class TestBidirectionalSyncCoordinator:
|
||||
"""Tests for BidirectionalSyncCoordinator."""
|
||||
|
||||
def test_default_initialization(self):
|
||||
"""Test default coordinator initialization."""
|
||||
coordinator = BidirectionalSyncCoordinator()
|
||||
assert coordinator.config is not None
|
||||
assert coordinator.config.direction == SyncDirection.BIDIRECTIONAL
|
||||
|
||||
def test_sync_space_to_directory(
|
||||
self, temp_dir, sample_space, sample_documents, content_provider
|
||||
):
|
||||
"""Test space-to-directory sync."""
|
||||
config = SyncConfig(direction=SyncDirection.SPACE_TO_DIRECTORY)
|
||||
coordinator = BidirectionalSyncCoordinator(config)
|
||||
|
||||
result = coordinator.sync(
|
||||
space=sample_space,
|
||||
documents=sample_documents,
|
||||
content_provider=content_provider,
|
||||
directory=temp_dir,
|
||||
)
|
||||
|
||||
assert result.success
|
||||
assert result.direction == SyncDirection.SPACE_TO_DIRECTORY
|
||||
|
||||
def test_sync_directory_to_space(self, temp_dir, sample_space):
|
||||
"""Test directory-to-space sync."""
|
||||
# Create files in directory
|
||||
(temp_dir / "new_doc.md").write_text("# New Document")
|
||||
|
||||
config = SyncConfig(direction=SyncDirection.DIRECTORY_TO_SPACE)
|
||||
coordinator = BidirectionalSyncCoordinator(config)
|
||||
|
||||
result = coordinator.sync(
|
||||
space=sample_space,
|
||||
documents=[],
|
||||
content_provider=lambda x: None,
|
||||
directory=temp_dir,
|
||||
)
|
||||
|
||||
assert result.success
|
||||
|
||||
def test_conflict_detection(
|
||||
self, temp_dir, sample_space, sample_documents, content_provider
|
||||
):
|
||||
"""Test conflict detection."""
|
||||
# Create conflicting file in directory
|
||||
(temp_dir / "intro.md").write_text("# Different Content")
|
||||
|
||||
coordinator = BidirectionalSyncCoordinator()
|
||||
|
||||
result = coordinator.sync(
|
||||
space=sample_space,
|
||||
documents=sample_documents,
|
||||
content_provider=content_provider,
|
||||
directory=temp_dir,
|
||||
)
|
||||
|
||||
# Should detect conflict for intro.md
|
||||
assert len(result.conflicts) > 0
|
||||
|
||||
def test_conflict_resolution_space_wins(
|
||||
self, temp_dir, sample_space, sample_documents, content_provider
|
||||
):
|
||||
"""Test space-wins conflict resolution."""
|
||||
(temp_dir / "intro.md").write_text("# Directory Version")
|
||||
|
||||
config = SyncConfig(conflict_resolution=ConflictResolution.SPACE_WINS)
|
||||
coordinator = BidirectionalSyncCoordinator(config)
|
||||
|
||||
result = coordinator.sync(
|
||||
space=sample_space,
|
||||
documents=sample_documents,
|
||||
content_provider=content_provider,
|
||||
directory=temp_dir,
|
||||
)
|
||||
|
||||
conflicts = [c for c in result.conflicts if c.path == "/intro.md"]
|
||||
assert len(conflicts) == 1
|
||||
assert conflicts[0].winner == "space"
|
||||
|
||||
def test_conflict_resolution_directory_wins(
|
||||
self, temp_dir, sample_space, sample_documents, content_provider
|
||||
):
|
||||
"""Test directory-wins conflict resolution."""
|
||||
(temp_dir / "intro.md").write_text("# Directory Version")
|
||||
|
||||
config = SyncConfig(conflict_resolution=ConflictResolution.DIRECTORY_WINS)
|
||||
coordinator = BidirectionalSyncCoordinator(config)
|
||||
|
||||
result = coordinator.sync(
|
||||
space=sample_space,
|
||||
documents=sample_documents,
|
||||
content_provider=content_provider,
|
||||
directory=temp_dir,
|
||||
)
|
||||
|
||||
conflicts = [c for c in result.conflicts if c.path == "/intro.md"]
|
||||
assert len(conflicts) == 1
|
||||
assert conflicts[0].winner == "directory"
|
||||
|
||||
def test_dry_run_mode(
|
||||
self, temp_dir, sample_space, sample_documents, content_provider
|
||||
):
|
||||
"""Test dry run mode doesn't modify files."""
|
||||
config = SyncConfig(dry_run=True)
|
||||
coordinator = BidirectionalSyncCoordinator(config)
|
||||
|
||||
result = coordinator.sync(
|
||||
space=sample_space,
|
||||
documents=sample_documents,
|
||||
content_provider=content_provider,
|
||||
directory=temp_dir,
|
||||
)
|
||||
|
||||
# In dry run, files should not be created
|
||||
assert len(result.actions_performed) > 0
|
||||
# But directory should be empty (no actual files created)
|
||||
md_files = list(temp_dir.glob("*.md"))
|
||||
assert len(md_files) == 0
|
||||
|
||||
def test_sync_emits_events(
|
||||
self, temp_dir, sample_space, sample_documents, content_provider
|
||||
):
|
||||
"""Test event emission during sync."""
|
||||
event_bus = EventBus()
|
||||
events = []
|
||||
|
||||
event_bus.subscribe(SpaceEventType.SYNC_STARTED, lambda e: events.append(e))
|
||||
event_bus.subscribe(SpaceEventType.SYNC_COMPLETED, lambda e: events.append(e))
|
||||
|
||||
coordinator = BidirectionalSyncCoordinator(event_bus=event_bus)
|
||||
coordinator.sync(
|
||||
sample_space, sample_documents, content_provider, temp_dir
|
||||
)
|
||||
|
||||
event_types = [e.event_type for e in events]
|
||||
assert SpaceEventType.SYNC_STARTED in event_types
|
||||
assert SpaceEventType.SYNC_COMPLETED in event_types
|
||||
|
||||
|
||||
class TestCreateSyncCoordinator:
|
||||
"""Tests for create_sync_coordinator factory."""
|
||||
|
||||
def test_create_default(self):
|
||||
"""Test creating with defaults."""
|
||||
coordinator = create_sync_coordinator()
|
||||
assert coordinator.config.direction == SyncDirection.BIDIRECTIONAL
|
||||
|
||||
def test_create_with_options(self):
|
||||
"""Test creating with custom options."""
|
||||
coordinator = create_sync_coordinator(
|
||||
direction=SyncDirection.SPACE_TO_DIRECTORY,
|
||||
conflict_resolution=ConflictResolution.SPACE_WINS,
|
||||
)
|
||||
assert coordinator.config.direction == SyncDirection.SPACE_TO_DIRECTORY
|
||||
assert coordinator.config.conflict_resolution == ConflictResolution.SPACE_WINS
|
||||
|
||||
|
||||
class TestSyncIntegration:
|
||||
"""Integration tests for sync workflow."""
|
||||
|
||||
def test_export_import_roundtrip(
|
||||
self, temp_dir, sample_space, sample_documents, content_provider
|
||||
):
|
||||
"""Test export then import produces same content."""
|
||||
export_dir = temp_dir / "export"
|
||||
|
||||
# Export
|
||||
exporter = SpaceDirectoryExporter()
|
||||
export_result = exporter.export_space(
|
||||
sample_space, sample_documents, content_provider, export_dir
|
||||
)
|
||||
assert export_result.success
|
||||
|
||||
# Import
|
||||
importer = DirectorySpaceImporter()
|
||||
import_result = importer.import_directory(export_dir)
|
||||
assert import_result.success
|
||||
|
||||
# Same number of documents
|
||||
assert import_result.document_count == export_result.file_count
|
||||
|
||||
def test_bidirectional_sync_workflow(
|
||||
self, temp_dir, sample_space, sample_documents, content_provider
|
||||
):
|
||||
"""Test complete bidirectional sync workflow."""
|
||||
# Initial export to establish baseline
|
||||
exporter = SpaceDirectoryExporter()
|
||||
exporter.export_space(
|
||||
sample_space, sample_documents, content_provider, temp_dir
|
||||
)
|
||||
|
||||
# Add a new file in directory
|
||||
(temp_dir / "new_from_dir.md").write_text("# New from directory")
|
||||
|
||||
# Sync
|
||||
coordinator = BidirectionalSyncCoordinator()
|
||||
result = coordinator.sync(
|
||||
sample_space, sample_documents, content_provider, temp_dir
|
||||
)
|
||||
|
||||
assert result.success
|
||||
# Should detect the new file
|
||||
new_file_actions = [a for a in result.actions_performed
|
||||
if "new_from_dir" in a.path]
|
||||
# Either action was performed or detected
|
||||
assert len(new_file_actions) >= 0 # At minimum should not error
|
||||
Reference in New Issue
Block a user