- Add core VLM modules (scanner, parser, planner, executor, analysis) - Add CLI with quarantine, reports, rollback, and state management - Add comprehensive test suite - Add project configuration and documentation - Add .gitignore for Python project
907 lines
33 KiB
Python
907 lines
33 KiB
Python
"""Unit tests for execution engine.
|
|
|
|
Tests execution mode handling, dry-run simulation, and actual file operations.
|
|
"""
|
|
|
|
import logging
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from uuid import uuid4
|
|
|
|
import pytest
|
|
|
|
from src.vlm.executor import ExecutionEngine
|
|
from src.vlm.models import ExecutionPlan, FileOperation
|
|
|
|
|
|
@pytest.fixture
|
|
def temp_test_dir(tmp_path):
|
|
"""Create a temporary test directory structure."""
|
|
# Create source directory with test files
|
|
source_dir = tmp_path / "source"
|
|
source_dir.mkdir()
|
|
|
|
# Create test files
|
|
test_file1 = source_dir / "test1.mp4"
|
|
test_file1.write_text("test content 1")
|
|
|
|
test_file2 = source_dir / "test2.mkv"
|
|
test_file2.write_text("test content 2")
|
|
|
|
# Create destination directory
|
|
dest_dir = tmp_path / "dest"
|
|
dest_dir.mkdir()
|
|
|
|
return {
|
|
"source_dir": source_dir,
|
|
"dest_dir": dest_dir,
|
|
"test_file1": test_file1,
|
|
"test_file2": test_file2,
|
|
}
|
|
|
|
|
|
@pytest.fixture
|
|
def execution_engine():
|
|
"""Create an execution engine instance with test logger."""
|
|
logger = logging.getLogger("test_executor")
|
|
logger.setLevel(logging.DEBUG)
|
|
return ExecutionEngine(logger=logger)
|
|
|
|
|
|
@pytest.fixture
|
|
def sample_plan(temp_test_dir):
|
|
"""Create a sample execution plan."""
|
|
operations = [
|
|
FileOperation(
|
|
operation_type="move",
|
|
source_path=temp_test_dir["test_file1"],
|
|
destination_path=temp_test_dir["dest_dir"] / "moved1.mp4",
|
|
reason="Organize movie",
|
|
has_conflict=False,
|
|
conflict_reason=None
|
|
),
|
|
FileOperation(
|
|
operation_type="rename",
|
|
source_path=temp_test_dir["test_file2"],
|
|
destination_path=temp_test_dir["dest_dir"] / "renamed2.mkv",
|
|
reason="Rename series episode",
|
|
has_conflict=False,
|
|
conflict_reason=None
|
|
),
|
|
]
|
|
|
|
return ExecutionPlan(
|
|
plan_id=str(uuid4()),
|
|
created_at=datetime.now(),
|
|
operations=operations,
|
|
summary={"move": 1, "rename": 1}
|
|
)
|
|
|
|
|
|
class TestExecutionModeHandling:
|
|
"""Tests for execution mode parameter handling."""
|
|
|
|
def test_dry_run_mode_default(self, execution_engine, sample_plan, temp_test_dir):
|
|
"""Test that dry-run is the default mode."""
|
|
results, summary, rollback_log = execution_engine.execute_plan(sample_plan)
|
|
|
|
# All operations should succeed in dry-run
|
|
assert all(r.success for r in results)
|
|
assert len(results) == 2
|
|
|
|
# Check execution summary
|
|
assert summary["successful"] == 2
|
|
assert summary["failed"] == 0
|
|
assert summary["skipped"] == 0
|
|
assert summary["total"] == 2
|
|
|
|
# No rollback log in dry-run mode
|
|
assert rollback_log is None
|
|
|
|
# Files should not be moved (dry-run doesn't modify files)
|
|
assert temp_test_dir["test_file1"].exists()
|
|
assert temp_test_dir["test_file2"].exists()
|
|
assert not (temp_test_dir["dest_dir"] / "moved1.mp4").exists()
|
|
assert not (temp_test_dir["dest_dir"] / "renamed2.mkv").exists()
|
|
|
|
def test_dry_run_mode_explicit(self, execution_engine, sample_plan, temp_test_dir):
|
|
"""Test explicit dry-run mode parameter."""
|
|
results, summary, rollback_log = execution_engine.execute_plan(
|
|
sample_plan,
|
|
mode="dry-run"
|
|
)
|
|
|
|
# All operations should succeed in dry-run
|
|
assert all(r.success for r in results)
|
|
|
|
# Check execution summary
|
|
assert summary["successful"] == 2
|
|
assert summary["total"] == 2
|
|
|
|
# No rollback log in dry-run mode
|
|
assert rollback_log is None
|
|
|
|
# Files should not be moved
|
|
assert temp_test_dir["test_file1"].exists()
|
|
assert not (temp_test_dir["dest_dir"] / "moved1.mp4").exists()
|
|
|
|
def test_execute_mode_requires_confirmation(self, execution_engine, sample_plan):
|
|
"""Test that execute mode requires explicit confirmation."""
|
|
with pytest.raises(ValueError, match="requires explicit confirmation"):
|
|
execution_engine.execute_plan(sample_plan, mode="execute")
|
|
|
|
def test_execute_mode_with_confirmation(self, execution_engine, sample_plan, temp_test_dir):
|
|
"""Test execute mode with explicit confirmation."""
|
|
results, summary, rollback_log = execution_engine.execute_plan(
|
|
sample_plan,
|
|
mode="execute",
|
|
confirmed=True
|
|
)
|
|
|
|
# All operations should succeed
|
|
assert all(r.success for r in results)
|
|
assert len(results) == 2
|
|
|
|
# Check execution summary
|
|
assert summary["successful"] == 2
|
|
assert summary["failed"] == 0
|
|
assert summary["total"] == 2
|
|
|
|
# Rollback log should be created in execute mode
|
|
assert rollback_log is not None
|
|
assert rollback_log.execution_plan_id == sample_plan.plan_id
|
|
assert len(rollback_log.operations) == 2
|
|
|
|
# Files should be moved
|
|
assert not temp_test_dir["test_file1"].exists()
|
|
assert not temp_test_dir["test_file2"].exists()
|
|
assert (temp_test_dir["dest_dir"] / "moved1.mp4").exists()
|
|
assert (temp_test_dir["dest_dir"] / "renamed2.mkv").exists()
|
|
|
|
def test_invalid_mode_raises_error(self, execution_engine, sample_plan):
|
|
"""Test that invalid mode parameter raises ValueError."""
|
|
with pytest.raises(ValueError, match="Invalid mode"):
|
|
execution_engine.execute_plan(sample_plan, mode="invalid")
|
|
|
|
|
|
class TestDryRunSimulation:
|
|
"""Tests for dry-run mode simulation."""
|
|
|
|
def test_dry_run_logs_operations(self, execution_engine, sample_plan, caplog):
|
|
"""Test that dry-run mode logs what would happen."""
|
|
caplog.set_level(logging.INFO)
|
|
|
|
execution_engine.execute_plan(sample_plan, mode="dry-run")
|
|
|
|
# Check that dry-run operations are logged
|
|
assert "[DRY-RUN]" in caplog.text
|
|
assert "Would move" in caplog.text or "Would rename" in caplog.text
|
|
|
|
def test_dry_run_never_modifies_files(self, execution_engine, sample_plan, temp_test_dir):
|
|
"""Test that dry-run mode never modifies the file system."""
|
|
# Record initial state
|
|
initial_files = list(temp_test_dir["source_dir"].iterdir())
|
|
|
|
# Execute in dry-run mode
|
|
results, summary, rollback_log = execution_engine.execute_plan(sample_plan, mode="dry-run")
|
|
|
|
# Verify no files were moved or modified
|
|
final_files = list(temp_test_dir["source_dir"].iterdir())
|
|
assert set(initial_files) == set(final_files)
|
|
|
|
# Verify destination directory is still empty
|
|
dest_files = list(temp_test_dir["dest_dir"].iterdir())
|
|
assert len(dest_files) == 0
|
|
|
|
def test_dry_run_handles_no_op_operations(self, execution_engine, temp_test_dir):
|
|
"""Test that dry-run mode handles no-op operations correctly."""
|
|
no_op_operation = FileOperation(
|
|
operation_type="no-op",
|
|
source_path=temp_test_dir["test_file1"],
|
|
destination_path=None,
|
|
reason="Anime file - not organized in v1",
|
|
has_conflict=False,
|
|
conflict_reason=None
|
|
)
|
|
|
|
plan = ExecutionPlan(
|
|
plan_id=str(uuid4()),
|
|
created_at=datetime.now(),
|
|
operations=[no_op_operation],
|
|
summary={"no-op": 1}
|
|
)
|
|
|
|
results, summary, _ = execution_engine.execute_plan(plan, mode="dry-run")
|
|
|
|
assert len(results) == 1
|
|
assert results[0].success
|
|
assert results[0].operation.operation_type == "no-op"
|
|
assert summary["skipped"] == 1
|
|
|
|
def test_dry_run_handles_conflicts(self, execution_engine, temp_test_dir):
|
|
"""Test that dry-run mode handles conflicted operations."""
|
|
conflicted_operation = FileOperation(
|
|
operation_type="move",
|
|
source_path=temp_test_dir["test_file1"],
|
|
destination_path=temp_test_dir["dest_dir"] / "conflict.mp4",
|
|
reason="Organize movie",
|
|
has_conflict=True,
|
|
conflict_reason="Destination file already exists"
|
|
)
|
|
|
|
plan = ExecutionPlan(
|
|
plan_id=str(uuid4()),
|
|
created_at=datetime.now(),
|
|
operations=[conflicted_operation],
|
|
summary={"move": 1}
|
|
)
|
|
|
|
results, summary, _ = execution_engine.execute_plan(plan, mode="dry-run")
|
|
|
|
assert len(results) == 1
|
|
assert not results[0].success
|
|
assert "Conflict" in results[0].error_message
|
|
assert summary["failed"] == 1
|
|
assert summary["skipped"] == 1 # Conflicts are also counted as skipped
|
|
|
|
|
|
class TestExecuteMode:
|
|
"""Tests for execute mode with actual file operations."""
|
|
|
|
def test_execute_mode_moves_files(self, execution_engine, sample_plan, temp_test_dir):
|
|
"""Test that execute mode actually moves files."""
|
|
results, summary, rollback_log = execution_engine.execute_plan(
|
|
sample_plan,
|
|
mode="execute",
|
|
confirmed=True
|
|
)
|
|
|
|
# Verify operations succeeded
|
|
assert all(r.success for r in results)
|
|
assert summary["successful"] == 2
|
|
|
|
# Verify files were moved
|
|
assert not temp_test_dir["test_file1"].exists()
|
|
assert (temp_test_dir["dest_dir"] / "moved1.mp4").exists()
|
|
|
|
# Verify file content is preserved
|
|
content = (temp_test_dir["dest_dir"] / "moved1.mp4").read_text()
|
|
assert content == "test content 1"
|
|
|
|
def test_execute_mode_creates_directories(self, execution_engine, temp_test_dir):
|
|
"""Test that execute mode creates destination directories."""
|
|
nested_dest = temp_test_dir["dest_dir"] / "subdir1" / "subdir2" / "file.mp4"
|
|
|
|
operation = FileOperation(
|
|
operation_type="move",
|
|
source_path=temp_test_dir["test_file1"],
|
|
destination_path=nested_dest,
|
|
reason="Organize with nested structure",
|
|
has_conflict=False,
|
|
conflict_reason=None
|
|
)
|
|
|
|
plan = ExecutionPlan(
|
|
plan_id=str(uuid4()),
|
|
created_at=datetime.now(),
|
|
operations=[operation],
|
|
summary={"move": 1}
|
|
)
|
|
|
|
results, summary, _ = execution_engine.execute_plan(
|
|
plan,
|
|
mode="execute",
|
|
confirmed=True
|
|
)
|
|
|
|
# Verify operation succeeded
|
|
assert results[0].success
|
|
assert summary["successful"] == 1
|
|
|
|
# Verify nested directories were created
|
|
assert nested_dest.exists()
|
|
assert nested_dest.parent.exists()
|
|
|
|
def test_execute_mode_handles_missing_source(self, execution_engine, temp_test_dir):
|
|
"""Test that execute mode handles missing source files gracefully."""
|
|
missing_file = temp_test_dir["source_dir"] / "nonexistent.mp4"
|
|
|
|
operation = FileOperation(
|
|
operation_type="move",
|
|
source_path=missing_file,
|
|
destination_path=temp_test_dir["dest_dir"] / "dest.mp4",
|
|
reason="Move nonexistent file",
|
|
has_conflict=False,
|
|
conflict_reason=None
|
|
)
|
|
|
|
plan = ExecutionPlan(
|
|
plan_id=str(uuid4()),
|
|
created_at=datetime.now(),
|
|
operations=[operation],
|
|
summary={"move": 1}
|
|
)
|
|
|
|
results, summary, _ = execution_engine.execute_plan(
|
|
plan,
|
|
mode="execute",
|
|
confirmed=True
|
|
)
|
|
|
|
# Operation should fail gracefully
|
|
assert not results[0].success
|
|
assert "does not exist" in results[0].error_message
|
|
assert summary["failed"] == 1
|
|
|
|
def test_execute_mode_skips_conflicts(self, execution_engine, temp_test_dir):
|
|
"""Test that execute mode skips conflicted operations."""
|
|
# Create a file at the destination
|
|
dest_file = temp_test_dir["dest_dir"] / "existing.mp4"
|
|
dest_file.write_text("existing content")
|
|
|
|
conflicted_operation = FileOperation(
|
|
operation_type="move",
|
|
source_path=temp_test_dir["test_file1"],
|
|
destination_path=dest_file,
|
|
reason="Move to existing location",
|
|
has_conflict=True,
|
|
conflict_reason="Destination file already exists"
|
|
)
|
|
|
|
plan = ExecutionPlan(
|
|
plan_id=str(uuid4()),
|
|
created_at=datetime.now(),
|
|
operations=[conflicted_operation],
|
|
summary={"move": 1}
|
|
)
|
|
|
|
results, summary, _ = execution_engine.execute_plan(
|
|
plan,
|
|
mode="execute",
|
|
confirmed=True
|
|
)
|
|
|
|
# Operation should be skipped
|
|
assert not results[0].success
|
|
assert "Conflict" in results[0].error_message
|
|
assert summary["failed"] == 1
|
|
assert summary["skipped"] == 1
|
|
|
|
# Source file should still exist
|
|
assert temp_test_dir["test_file1"].exists()
|
|
|
|
# Destination file should be unchanged
|
|
assert dest_file.read_text() == "existing content"
|
|
|
|
|
|
class TestRollbackLog:
|
|
"""Tests for rollback log creation."""
|
|
|
|
def test_rollback_log_created_in_execute_mode(self, execution_engine, sample_plan):
|
|
"""Test that rollback log is created in execute mode."""
|
|
results, summary, rollback_log = execution_engine.execute_plan(
|
|
sample_plan,
|
|
mode="execute",
|
|
confirmed=True
|
|
)
|
|
|
|
assert rollback_log is not None
|
|
assert rollback_log.log_id is not None
|
|
assert rollback_log.execution_plan_id == sample_plan.plan_id
|
|
assert len(rollback_log.operations) == 2
|
|
|
|
def test_rollback_log_not_created_in_dry_run(self, execution_engine, sample_plan):
|
|
"""Test that rollback log is not created in dry-run mode."""
|
|
results, summary, rollback_log = execution_engine.execute_plan(
|
|
sample_plan,
|
|
mode="dry-run"
|
|
)
|
|
|
|
assert rollback_log is None
|
|
|
|
def test_rollback_log_only_includes_successful_operations(
|
|
self, execution_engine, temp_test_dir
|
|
):
|
|
"""Test that rollback log only includes successful operations."""
|
|
# Create a plan with one successful and one failed operation
|
|
operations = [
|
|
FileOperation(
|
|
operation_type="move",
|
|
source_path=temp_test_dir["test_file1"],
|
|
destination_path=temp_test_dir["dest_dir"] / "success.mp4",
|
|
reason="This will succeed",
|
|
has_conflict=False,
|
|
conflict_reason=None
|
|
),
|
|
FileOperation(
|
|
operation_type="move",
|
|
source_path=temp_test_dir["source_dir"] / "nonexistent.mp4",
|
|
destination_path=temp_test_dir["dest_dir"] / "fail.mp4",
|
|
reason="This will fail",
|
|
has_conflict=False,
|
|
conflict_reason=None
|
|
),
|
|
]
|
|
|
|
plan = ExecutionPlan(
|
|
plan_id=str(uuid4()),
|
|
created_at=datetime.now(),
|
|
operations=operations,
|
|
summary={"move": 2}
|
|
)
|
|
|
|
results, summary, rollback_log = execution_engine.execute_plan(
|
|
plan,
|
|
mode="execute",
|
|
confirmed=True
|
|
)
|
|
|
|
# Rollback log should only include the successful operation
|
|
assert rollback_log is not None
|
|
assert len(rollback_log.operations) == 1
|
|
assert rollback_log.operations[0].success
|
|
|
|
|
|
class TestExecutionSummary:
|
|
"""Tests for execution summary logging."""
|
|
|
|
def test_execution_summary_counts(self, execution_engine, temp_test_dir, caplog):
|
|
"""Test that execution summary includes correct counts."""
|
|
caplog.set_level(logging.INFO)
|
|
|
|
operations = [
|
|
FileOperation(
|
|
operation_type="move",
|
|
source_path=temp_test_dir["test_file1"],
|
|
destination_path=temp_test_dir["dest_dir"] / "success.mp4",
|
|
reason="Successful move",
|
|
has_conflict=False,
|
|
conflict_reason=None
|
|
),
|
|
FileOperation(
|
|
operation_type="no-op",
|
|
source_path=temp_test_dir["test_file2"],
|
|
destination_path=None,
|
|
reason="Anime file",
|
|
has_conflict=False,
|
|
conflict_reason=None
|
|
),
|
|
FileOperation(
|
|
operation_type="move",
|
|
source_path=temp_test_dir["source_dir"] / "missing.mp4",
|
|
destination_path=temp_test_dir["dest_dir"] / "fail.mp4",
|
|
reason="This will fail",
|
|
has_conflict=False,
|
|
conflict_reason=None
|
|
),
|
|
]
|
|
|
|
plan = ExecutionPlan(
|
|
plan_id=str(uuid4()),
|
|
created_at=datetime.now(),
|
|
operations=operations,
|
|
summary={"move": 2, "no-op": 1}
|
|
)
|
|
|
|
results, summary, rollback_log = execution_engine.execute_plan(plan, mode="execute", confirmed=True)
|
|
|
|
# Check summary structure
|
|
assert summary["successful"] == 2 # 1 successful move + 1 no-op
|
|
assert summary["failed"] == 1 # 1 failed move
|
|
assert summary["skipped"] == 1 # 1 no-op
|
|
assert summary["total"] == 3
|
|
|
|
# Check summary in logs
|
|
assert "Execution summary" in caplog.text
|
|
assert "successful" in caplog.text
|
|
assert "failed" in caplog.text
|
|
assert "skipped" in caplog.text
|
|
|
|
|
|
class TestRollbackLogSaving:
|
|
"""Tests for saving rollback logs to disk."""
|
|
|
|
def test_save_rollback_log_creates_file(self, execution_engine, sample_plan, tmp_path):
|
|
"""Test that save_rollback_log creates a JSON file."""
|
|
# Execute plan to get rollback log
|
|
results, summary, rollback_log = execution_engine.execute_plan(
|
|
sample_plan,
|
|
mode="execute",
|
|
confirmed=True
|
|
)
|
|
|
|
# Save rollback log
|
|
output_path = tmp_path / "rollback" / "test_rollback.json"
|
|
execution_engine.save_rollback_log(rollback_log, output_path)
|
|
|
|
# Verify file was created
|
|
assert output_path.exists()
|
|
assert output_path.is_file()
|
|
|
|
def test_save_rollback_log_json_structure(self, execution_engine, sample_plan, tmp_path):
|
|
"""Test that saved rollback log has correct JSON structure."""
|
|
# Execute plan to get rollback log
|
|
results, summary, rollback_log = execution_engine.execute_plan(
|
|
sample_plan,
|
|
mode="execute",
|
|
confirmed=True
|
|
)
|
|
|
|
# Save rollback log
|
|
output_path = tmp_path / "rollback" / "test_rollback.json"
|
|
execution_engine.save_rollback_log(rollback_log, output_path)
|
|
|
|
# Load and verify JSON structure
|
|
import json
|
|
with open(output_path, 'r', encoding='utf-8') as f:
|
|
data = json.load(f)
|
|
|
|
# Check required fields
|
|
assert "log_id" in data
|
|
assert "execution_plan_id" in data
|
|
assert "executed_at" in data
|
|
assert "operations" in data
|
|
|
|
# Check operations structure
|
|
assert len(data["operations"]) == 2
|
|
for op in data["operations"]:
|
|
assert "operation_type" in op
|
|
assert "source_path" in op
|
|
assert "destination_path" in op
|
|
assert "reason" in op
|
|
assert "success" in op
|
|
assert "executed_at" in op
|
|
|
|
def test_save_rollback_log_includes_timestamps(self, execution_engine, sample_plan, tmp_path):
|
|
"""Test that rollback log includes ISO format timestamps."""
|
|
# Execute plan to get rollback log
|
|
results, summary, rollback_log = execution_engine.execute_plan(
|
|
sample_plan,
|
|
mode="execute",
|
|
confirmed=True
|
|
)
|
|
|
|
# Save rollback log
|
|
output_path = tmp_path / "rollback" / "test_rollback.json"
|
|
execution_engine.save_rollback_log(rollback_log, output_path)
|
|
|
|
# Load and verify timestamps
|
|
import json
|
|
with open(output_path, 'r', encoding='utf-8') as f:
|
|
data = json.load(f)
|
|
|
|
# Verify timestamps are in ISO format
|
|
from datetime import datetime
|
|
executed_at = datetime.fromisoformat(data["executed_at"])
|
|
assert executed_at is not None
|
|
|
|
for op in data["operations"]:
|
|
op_executed_at = datetime.fromisoformat(op["executed_at"])
|
|
assert op_executed_at is not None
|
|
|
|
|
|
|
|
class TestRollbackExecution:
|
|
"""Tests for rollback execution functionality."""
|
|
|
|
def test_load_rollback_log_from_file(self, execution_engine, sample_plan, tmp_path):
|
|
"""Test loading a rollback log from a JSON file."""
|
|
# Execute plan and save rollback log
|
|
results, summary, rollback_log = execution_engine.execute_plan(
|
|
sample_plan,
|
|
mode="execute",
|
|
confirmed=True
|
|
)
|
|
|
|
log_path = tmp_path / "rollback.json"
|
|
execution_engine.save_rollback_log(rollback_log, log_path)
|
|
|
|
# Load the rollback log
|
|
loaded_log = execution_engine.load_rollback_log(log_path)
|
|
|
|
# Verify loaded log matches original
|
|
assert loaded_log.log_id == rollback_log.log_id
|
|
assert loaded_log.execution_plan_id == rollback_log.execution_plan_id
|
|
assert len(loaded_log.operations) == len(rollback_log.operations)
|
|
|
|
def test_load_rollback_log_missing_file(self, execution_engine, tmp_path):
|
|
"""Test that loading a missing rollback log raises FileNotFoundError."""
|
|
missing_path = tmp_path / "nonexistent.json"
|
|
|
|
with pytest.raises(FileNotFoundError, match="Rollback log not found"):
|
|
execution_engine.load_rollback_log(missing_path)
|
|
|
|
def test_load_rollback_log_invalid_json(self, execution_engine, tmp_path):
|
|
"""Test that loading invalid JSON raises ValueError."""
|
|
invalid_path = tmp_path / "invalid.json"
|
|
invalid_path.write_text("not valid json {")
|
|
|
|
with pytest.raises(ValueError, match="Invalid rollback log format"):
|
|
execution_engine.load_rollback_log(invalid_path)
|
|
|
|
def test_rollback_reverses_operations(self, execution_engine, sample_plan, temp_test_dir):
|
|
"""Test that rollback reverses file operations."""
|
|
# Execute plan to move files
|
|
results, summary, rollback_log = execution_engine.execute_plan(
|
|
sample_plan,
|
|
mode="execute",
|
|
confirmed=True
|
|
)
|
|
|
|
# Verify files were moved
|
|
assert not temp_test_dir["test_file1"].exists()
|
|
assert (temp_test_dir["dest_dir"] / "moved1.mp4").exists()
|
|
|
|
# Perform rollback
|
|
rollback_results, rollback_summary = execution_engine.rollback(rollback_log)
|
|
|
|
# Verify files were moved back
|
|
assert temp_test_dir["test_file1"].exists()
|
|
assert not (temp_test_dir["dest_dir"] / "moved1.mp4").exists()
|
|
|
|
# Verify rollback summary
|
|
assert rollback_summary["successful"] == 2
|
|
assert rollback_summary["failed"] == 0
|
|
assert rollback_summary["total"] == 2
|
|
|
|
def test_rollback_lifo_order(self, execution_engine, temp_test_dir):
|
|
"""Test that rollback processes operations in LIFO order."""
|
|
# Create a plan with multiple operations
|
|
operations = [
|
|
FileOperation(
|
|
operation_type="move",
|
|
source_path=temp_test_dir["test_file1"],
|
|
destination_path=temp_test_dir["dest_dir"] / "first.mp4",
|
|
reason="First operation",
|
|
has_conflict=False,
|
|
conflict_reason=None
|
|
),
|
|
FileOperation(
|
|
operation_type="move",
|
|
source_path=temp_test_dir["test_file2"],
|
|
destination_path=temp_test_dir["dest_dir"] / "second.mkv",
|
|
reason="Second operation",
|
|
has_conflict=False,
|
|
conflict_reason=None
|
|
),
|
|
]
|
|
|
|
plan = ExecutionPlan(
|
|
plan_id=str(uuid4()),
|
|
created_at=datetime.now(),
|
|
operations=operations,
|
|
summary={"move": 2}
|
|
)
|
|
|
|
# Execute and rollback
|
|
results, summary, rollback_log = execution_engine.execute_plan(
|
|
plan,
|
|
mode="execute",
|
|
confirmed=True
|
|
)
|
|
|
|
rollback_results, rollback_summary = execution_engine.rollback(rollback_log)
|
|
|
|
# Verify LIFO order: second operation should be rolled back first
|
|
# Both should succeed regardless of order
|
|
assert all(r.success for r in rollback_results)
|
|
assert len(rollback_results) == 2
|
|
|
|
def test_rollback_handles_missing_destination(self, execution_engine, sample_plan, temp_test_dir):
|
|
"""Test that rollback handles missing destination files gracefully."""
|
|
# Execute plan
|
|
results, summary, rollback_log = execution_engine.execute_plan(
|
|
sample_plan,
|
|
mode="execute",
|
|
confirmed=True
|
|
)
|
|
|
|
# Manually delete one of the destination files
|
|
(temp_test_dir["dest_dir"] / "moved1.mp4").unlink()
|
|
|
|
# Perform rollback
|
|
rollback_results, rollback_summary = execution_engine.rollback(rollback_log)
|
|
|
|
# One rollback should fail, one should succeed
|
|
assert rollback_summary["successful"] == 1
|
|
assert rollback_summary["failed"] == 1
|
|
assert rollback_summary["total"] == 2
|
|
|
|
# The file that wasn't deleted should be rolled back
|
|
assert temp_test_dir["test_file2"].exists()
|
|
|
|
def test_rollback_continues_after_failure(self, execution_engine, sample_plan, temp_test_dir):
|
|
"""Test that rollback continues processing after encountering failures."""
|
|
# Execute plan
|
|
results, summary, rollback_log = execution_engine.execute_plan(
|
|
sample_plan,
|
|
mode="execute",
|
|
confirmed=True
|
|
)
|
|
|
|
# Delete one destination file to cause a rollback failure
|
|
(temp_test_dir["dest_dir"] / "moved1.mp4").unlink()
|
|
|
|
# Perform rollback
|
|
rollback_results, rollback_summary = execution_engine.rollback(rollback_log)
|
|
|
|
# Verify all operations were attempted (not halted by failure)
|
|
assert len(rollback_results) == 2
|
|
|
|
# One should fail, one should succeed
|
|
failed_count = sum(1 for r in rollback_results if not r.success)
|
|
success_count = sum(1 for r in rollback_results if r.success)
|
|
assert failed_count == 1
|
|
assert success_count == 1
|
|
|
|
def test_rollback_skips_no_op_operations(self, execution_engine, temp_test_dir):
|
|
"""Test that rollback skips no-op operations."""
|
|
operations = [
|
|
FileOperation(
|
|
operation_type="move",
|
|
source_path=temp_test_dir["test_file1"],
|
|
destination_path=temp_test_dir["dest_dir"] / "moved.mp4",
|
|
reason="Move file",
|
|
has_conflict=False,
|
|
conflict_reason=None
|
|
),
|
|
FileOperation(
|
|
operation_type="no-op",
|
|
source_path=temp_test_dir["test_file2"],
|
|
destination_path=None,
|
|
reason="Anime file",
|
|
has_conflict=False,
|
|
conflict_reason=None
|
|
),
|
|
]
|
|
|
|
plan = ExecutionPlan(
|
|
plan_id=str(uuid4()),
|
|
created_at=datetime.now(),
|
|
operations=operations,
|
|
summary={"move": 1, "no-op": 1}
|
|
)
|
|
|
|
# Execute and rollback
|
|
results, summary, rollback_log = execution_engine.execute_plan(
|
|
plan,
|
|
mode="execute",
|
|
confirmed=True
|
|
)
|
|
|
|
rollback_results, rollback_summary = execution_engine.rollback(rollback_log)
|
|
|
|
# Both operations are in rollback log, but no-op is skipped during rollback
|
|
assert len(rollback_results) == 2
|
|
assert rollback_summary["skipped"] == 1 # no-op is skipped during rollback
|
|
assert rollback_summary["successful"] == 2 # Both succeed (no-op succeeds trivially)
|
|
|
|
def test_rollback_preserves_file_content(self, execution_engine, sample_plan, temp_test_dir):
|
|
"""Test that rollback preserves file content."""
|
|
original_content = temp_test_dir["test_file1"].read_text()
|
|
|
|
# Execute plan
|
|
results, summary, rollback_log = execution_engine.execute_plan(
|
|
sample_plan,
|
|
mode="execute",
|
|
confirmed=True
|
|
)
|
|
|
|
# Perform rollback
|
|
rollback_results, rollback_summary = execution_engine.rollback(rollback_log)
|
|
|
|
# Verify file content is preserved
|
|
restored_content = temp_test_dir["test_file1"].read_text()
|
|
assert restored_content == original_content
|
|
|
|
def test_rollback_idempotence(self, execution_engine, sample_plan, temp_test_dir):
|
|
"""Test that running rollback multiple times produces the same result."""
|
|
# Execute plan
|
|
results, summary, rollback_log = execution_engine.execute_plan(
|
|
sample_plan,
|
|
mode="execute",
|
|
confirmed=True
|
|
)
|
|
|
|
# First rollback
|
|
rollback_results1, rollback_summary1 = execution_engine.rollback(rollback_log)
|
|
|
|
# Verify files are back
|
|
assert temp_test_dir["test_file1"].exists()
|
|
assert temp_test_dir["test_file2"].exists()
|
|
|
|
# Execute plan again
|
|
results2, summary2, rollback_log2 = execution_engine.execute_plan(
|
|
sample_plan,
|
|
mode="execute",
|
|
confirmed=True
|
|
)
|
|
|
|
# Second rollback
|
|
rollback_results2, rollback_summary2 = execution_engine.rollback(rollback_log2)
|
|
|
|
# Both rollbacks should have same results
|
|
assert rollback_summary1["successful"] == rollback_summary2["successful"]
|
|
assert rollback_summary1["failed"] == rollback_summary2["failed"]
|
|
|
|
# Files should be in same state
|
|
assert temp_test_dir["test_file1"].exists()
|
|
assert temp_test_dir["test_file2"].exists()
|
|
|
|
def test_rollback_logs_operations(self, execution_engine, sample_plan, caplog):
|
|
"""Test that rollback logs all operations."""
|
|
caplog.set_level(logging.INFO)
|
|
|
|
# Execute plan
|
|
results, summary, rollback_log = execution_engine.execute_plan(
|
|
sample_plan,
|
|
mode="execute",
|
|
confirmed=True
|
|
)
|
|
|
|
# Clear logs
|
|
caplog.clear()
|
|
|
|
# Perform rollback
|
|
rollback_results, rollback_summary = execution_engine.rollback(rollback_log)
|
|
|
|
# Verify rollback operations are logged
|
|
assert "Starting rollback" in caplog.text
|
|
assert "LIFO order" in caplog.text
|
|
assert "Successfully rolled back" in caplog.text
|
|
assert "Rollback summary" in caplog.text
|
|
|
|
def test_rollback_summary_accuracy(self, execution_engine, temp_test_dir):
|
|
"""Test that rollback summary contains accurate counts."""
|
|
# Create a plan with operations that will have mixed results
|
|
operations = [
|
|
FileOperation(
|
|
operation_type="move",
|
|
source_path=temp_test_dir["test_file1"],
|
|
destination_path=temp_test_dir["dest_dir"] / "file1.mp4",
|
|
reason="Move file 1",
|
|
has_conflict=False,
|
|
conflict_reason=None
|
|
),
|
|
FileOperation(
|
|
operation_type="move",
|
|
source_path=temp_test_dir["test_file2"],
|
|
destination_path=temp_test_dir["dest_dir"] / "file2.mkv",
|
|
reason="Move file 2",
|
|
has_conflict=False,
|
|
conflict_reason=None
|
|
),
|
|
]
|
|
|
|
plan = ExecutionPlan(
|
|
plan_id=str(uuid4()),
|
|
created_at=datetime.now(),
|
|
operations=operations,
|
|
summary={"move": 2}
|
|
)
|
|
|
|
# Execute plan
|
|
results, summary, rollback_log = execution_engine.execute_plan(
|
|
plan,
|
|
mode="execute",
|
|
confirmed=True
|
|
)
|
|
|
|
# Delete one file to cause partial rollback failure
|
|
(temp_test_dir["dest_dir"] / "file1.mp4").unlink()
|
|
|
|
# Perform rollback
|
|
rollback_results, rollback_summary = execution_engine.rollback(rollback_log)
|
|
|
|
# Verify summary accuracy
|
|
assert rollback_summary["total"] == 2
|
|
assert rollback_summary["successful"] == 1
|
|
assert rollback_summary["failed"] == 1
|
|
assert rollback_summary["skipped"] == 0
|
|
|
|
# Verify counts match actual results
|
|
actual_success = sum(1 for r in rollback_results if r.success)
|
|
actual_failed = sum(1 for r in rollback_results if not r.success)
|
|
assert rollback_summary["successful"] == actual_success
|
|
assert rollback_summary["failed"] == actual_failed
|