- Updated `analysis.json` with a new generation timestamp. - Modified `plan.json` to include a new plan ID and created timestamp, and changed operation types from "no-op" to "quarantine" for specific files needing manual review. - Enhanced the README.md to document the new `--analysis` option for generating execution plans, which now includes a human-readable summary and duplicate handling strategies. - Introduced a new `duplicate_resolve.py` module to manage duplicate file resolution strategies. - Improved the execution engine to support quarantine operations and added rollback functionality for quarantined files. These changes improve the functionality of the Video Library Manager by providing better duplicate management and clearer reporting capabilities.
948 lines
35 KiB
Python
948 lines
35 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 vlm.config import Config
|
|
from vlm.executor import ExecutionEngine
|
|
from 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
|
|
|
|
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()
|