Files
dl-organizer/tests/test_executor.py
T

949 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, timezone
2026-02-09 17:43:35 +08:00
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(timezone.utc),
2026-02-09 17:43:35 +08:00
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)
2026-02-13 13:36:39 +08:00
verbose_engine = ExecutionEngine(logger=execution_engine.logger, verbose_operations=True)
verbose_engine.execute_plan(sample_plan, mode="dry-run")
2026-02-09 17:43:35 +08:00
# 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(timezone.utc),
2026-02-09 17:43:35 +08:00
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(timezone.utc),
2026-02-09 17:43:35 +08:00
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
2026-02-13 13:36:39 +08:00
assert summary["failed"] == 0
assert summary["skipped"] == 1
2026-02-09 17:43:35 +08:00
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(timezone.utc),
2026-02-09 17:43:35 +08:00
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(timezone.utc),
2026-02-09 17:43:35 +08:00
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(timezone.utc),
2026-02-09 17:43:35 +08:00
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
2026-02-13 13:36:39 +08:00
assert summary["failed"] == 0
2026-02-09 17:43:35 +08:00
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(timezone.utc),
2026-02-09 17:43:35 +08:00
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(timezone.utc),
2026-02-09 17:43:35 +08:00
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, timezone
2026-02-09 17:43:35 +08:00
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(timezone.utc),
2026-02-09 17:43:35 +08:00
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(timezone.utc),
2026-02-09 17:43:35 +08:00
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(timezone.utc),
2026-02-09 17:43:35 +08:00
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(timezone.utc),
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()