Files
dl-organizer/tests/test_executor.py
T

948 lines
35 KiB
Python
Raw Normal View History

2026-02-09 17:43:35 +08:00
"""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 vlm.config import Config
from vlm.executor import ExecutionEngine
from vlm.models import ExecutionPlan, FileOperation
2026-02-09 17:43:35 +08:00
@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
def test_rollback_quarantine_operation(self, tmp_path):
"""Test that rollback restores quarantined files via QuarantineManager."""
library_root = tmp_path / "library"
library_root.mkdir()
(library_root / "movie").mkdir()
config = Config(library_root=library_root, quarantine_dir=".quarantine")
engine = ExecutionEngine(logger=logging.getLogger("test_executor"), config=config)
original_path = library_root / "movie" / "Duplicate (2020).mkv"
original_path.write_text("movie content")
plan = ExecutionPlan(
plan_id=str(uuid4()),
created_at=datetime.now(),
operations=[
FileOperation(
operation_type="quarantine",
source_path=original_path,
destination_path=None,
reason="duplicate",
has_conflict=False,
conflict_reason=None,
),
],
summary={"quarantine": 1},
)
results, _, rollback_log = engine.execute_plan(plan, mode="execute", confirmed=True)
assert results[0].success
assert not original_path.exists()
quarantine_path = config.library_root / "movie" / ".quarantine" / "Duplicate (2020).mkv"
assert quarantine_path.exists()
rollback_results, rollback_summary = engine.rollback(rollback_log)
assert rollback_summary["successful"] == 1
assert rollback_summary["failed"] == 0
assert original_path.exists()
assert original_path.read_text() == "movie content"
assert not quarantine_path.exists()