refactor review-plan safety and validation
This commit is contained in:
@@ -4,7 +4,7 @@ import pytest
|
||||
from pathlib import Path
|
||||
|
||||
from vlm.models import MovieIdentity
|
||||
from vlm.duplicate_resolve import choose_keep_index
|
||||
from vlm.duplicate_resolve import DuplicateResolutionError, choose_keep_index
|
||||
|
||||
|
||||
def _mi(title: str = "Test", year: int | None = 2020) -> MovieIdentity:
|
||||
@@ -218,3 +218,21 @@ class TestOtherStrategies:
|
||||
]
|
||||
idx = choose_keep_index(items, "by_reputation_quality_time", quality_comparison=qc)
|
||||
assert idx == 1
|
||||
|
||||
|
||||
def test_by_quality_requires_aligned_quality_data():
|
||||
items = [(Path("/a.mkv"), _mi()), (Path("/b.mkv"), _mi())]
|
||||
|
||||
with pytest.raises(
|
||||
DuplicateResolutionError,
|
||||
match="requires quality data aligned with duplicate items",
|
||||
):
|
||||
choose_keep_index(items, "by_quality", quality_comparison=[{"path": "/a.mkv"}])
|
||||
|
||||
|
||||
def test_unknown_strategy_raises_duplicate_resolution_error():
|
||||
items = [(Path("/a.mkv"), _mi()), (Path("/b.mkv"), _mi())]
|
||||
|
||||
with pytest.raises(DuplicateResolutionError, match="Unsupported duplicate strategy"):
|
||||
choose_keep_index(items, "unexpected")
|
||||
|
||||
|
||||
@@ -946,3 +946,59 @@ class TestRollbackExecution:
|
||||
assert original_path.exists()
|
||||
assert original_path.read_text() == "movie content"
|
||||
assert not quarantine_path.exists()
|
||||
|
||||
|
||||
def test_execute_plan_continues_after_unexpected_operation_exception(temp_test_dir):
|
||||
logger = logging.getLogger("test_executor")
|
||||
logger.setLevel(logging.DEBUG)
|
||||
engine = ExecutionEngine(logger=logger)
|
||||
|
||||
source_ok = temp_test_dir["test_file1"]
|
||||
destination_ok = temp_test_dir["dest_dir"] / "moved1.mp4"
|
||||
source_broken = temp_test_dir["test_file2"]
|
||||
destination_broken = temp_test_dir["dest_dir"] / "broken.mkv"
|
||||
|
||||
operations = [
|
||||
FileOperation(
|
||||
operation_type="move",
|
||||
source_path=source_broken,
|
||||
destination_path=destination_broken,
|
||||
reason="broken operation",
|
||||
has_conflict=False,
|
||||
conflict_reason=None,
|
||||
),
|
||||
FileOperation(
|
||||
operation_type="move",
|
||||
source_path=source_ok,
|
||||
destination_path=destination_ok,
|
||||
reason="healthy operation",
|
||||
has_conflict=False,
|
||||
conflict_reason=None,
|
||||
),
|
||||
]
|
||||
plan = ExecutionPlan(
|
||||
plan_id=str(uuid4()),
|
||||
created_at=datetime.now(timezone.utc),
|
||||
operations=operations,
|
||||
summary={"move": 2},
|
||||
)
|
||||
|
||||
original_execute_operation = engine.execute_operation
|
||||
|
||||
def flaky_execute_operation(operation, mode):
|
||||
if operation.source_path == source_broken:
|
||||
raise RuntimeError("boom")
|
||||
return original_execute_operation(operation, mode)
|
||||
|
||||
engine.execute_operation = flaky_execute_operation # type: ignore[method-assign]
|
||||
|
||||
results, summary, _ = engine.execute_plan(plan, mode="execute", confirmed=True)
|
||||
|
||||
assert len(results) == 2
|
||||
assert results[0].success is False
|
||||
assert "Unexpected failure during move: boom" == results[0].error_message
|
||||
assert results[1].success is True
|
||||
assert summary["failed"] == 1
|
||||
assert summary["successful"] == 1
|
||||
assert source_broken.exists()
|
||||
assert destination_ok.exists()
|
||||
|
||||
+99
-1
@@ -8,11 +8,15 @@ import pytest
|
||||
|
||||
from vlm.io import (
|
||||
_video_file_from_record,
|
||||
execution_plan_from_record,
|
||||
execution_plan_to_record,
|
||||
load_analysis_json,
|
||||
load_execution_plan,
|
||||
load_identities_json,
|
||||
save_execution_plan,
|
||||
save_identities_json,
|
||||
)
|
||||
from vlm.models import VideoFile
|
||||
from vlm.models import ExecutionPlan, FileOperation, VideoFile
|
||||
|
||||
|
||||
class TestVideoFileFromRecord:
|
||||
@@ -105,6 +109,100 @@ class TestVideoFileFromRecord:
|
||||
assert vf.bitrate_kbps is None
|
||||
|
||||
|
||||
class TestExecutionPlanIo:
|
||||
"""Tests for the validated typed execution plan boundary."""
|
||||
|
||||
def test_execution_plan_record_round_trip(self):
|
||||
plan = ExecutionPlan(
|
||||
plan_id="plan-123",
|
||||
created_at=datetime(2026, 4, 7, 12, 0, tzinfo=timezone.utc),
|
||||
operations=[
|
||||
FileOperation(
|
||||
operation_type="move",
|
||||
source_path=Path("/library/movie/source.mkv"),
|
||||
destination_path=Path("/library/movie/target.mkv"),
|
||||
reason="move movie",
|
||||
has_conflict=False,
|
||||
conflict_reason=None,
|
||||
)
|
||||
],
|
||||
summary={"total": 1, "move": 1, "rename": 0, "quarantine": 0, "no-op": 0},
|
||||
summary_by_reason={"move movie": 1},
|
||||
human_summary="计划已生成",
|
||||
metadata={"analysis_source": "analysis.json"},
|
||||
)
|
||||
|
||||
record = execution_plan_to_record(plan)
|
||||
loaded = execution_plan_from_record(record)
|
||||
|
||||
assert loaded.plan_id == plan.plan_id
|
||||
assert loaded.created_at == plan.created_at
|
||||
assert loaded.operations[0].source_path == plan.operations[0].source_path
|
||||
assert loaded.operations[0].destination_path == plan.operations[0].destination_path
|
||||
assert loaded.summary == plan.summary
|
||||
assert loaded.summary_by_reason == plan.summary_by_reason
|
||||
assert loaded.human_summary == plan.human_summary
|
||||
assert loaded.metadata == plan.metadata
|
||||
|
||||
def test_load_execution_plan_normalizes_naive_timestamp(self, tmp_path):
|
||||
plan_path = tmp_path / "plan.json"
|
||||
plan_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"vlm_schema_version": "1.0",
|
||||
"plan_id": "plan-naive",
|
||||
"created_at": "2026-04-07T12:00:00",
|
||||
"operations": [
|
||||
{
|
||||
"operation_type": "no-op",
|
||||
"source_path": "/library/movie/source.mkv",
|
||||
"destination_path": None,
|
||||
"reason": "manual review",
|
||||
"has_conflict": False,
|
||||
"conflict_reason": None,
|
||||
}
|
||||
],
|
||||
"summary": {"total": 1, "move": 0, "rename": 0, "quarantine": 0, "no-op": 1},
|
||||
"summary_by_reason": {"manual review": 1},
|
||||
"human_summary": "summary",
|
||||
"metadata": {},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
loaded = load_execution_plan(plan_path)
|
||||
|
||||
assert loaded.created_at.tzinfo == timezone.utc
|
||||
assert loaded.created_at.isoformat() == "2026-04-07T12:00:00+00:00"
|
||||
|
||||
def test_save_execution_plan_writes_validated_schema(self, tmp_path):
|
||||
plan = ExecutionPlan(
|
||||
plan_id="plan-save",
|
||||
created_at=datetime(2026, 4, 7, 13, 0, tzinfo=timezone.utc),
|
||||
operations=[
|
||||
FileOperation(
|
||||
operation_type="quarantine",
|
||||
source_path=Path("/library/movie/duplicate.mkv"),
|
||||
destination_path=None,
|
||||
reason="duplicate",
|
||||
has_conflict=False,
|
||||
conflict_reason=None,
|
||||
)
|
||||
],
|
||||
summary={"total": 1, "move": 0, "rename": 0, "quarantine": 1, "no-op": 0},
|
||||
)
|
||||
|
||||
output_path = tmp_path / "plan.json"
|
||||
save_execution_plan(plan, output_path)
|
||||
saved = json.loads(output_path.read_text(encoding="utf-8"))
|
||||
|
||||
assert saved["vlm_schema_version"] == "1.0"
|
||||
assert saved["plan_id"] == "plan-save"
|
||||
assert saved["operations"][0]["operation_type"] == "quarantine"
|
||||
assert saved["operations"][0]["source_path"] == "/library/movie/duplicate.mkv"
|
||||
|
||||
|
||||
class TestIdentitiesJsonVersioning:
|
||||
"""Tests for identities.json schema versioning."""
|
||||
|
||||
|
||||
@@ -101,3 +101,37 @@ def test_executor_blocks_unsafe_destination_even_with_manual_plan(tmp_path):
|
||||
assert not results[0].success
|
||||
assert "outside library root" in (results[0].error_message or "")
|
||||
assert summary["failed"] == 1
|
||||
|
||||
|
||||
def test_executor_blocks_unsafe_source_even_with_manual_plan(tmp_path):
|
||||
library_root = tmp_path / "library"
|
||||
library_root.mkdir(parents=True, exist_ok=True)
|
||||
outside_root = tmp_path / "outside"
|
||||
outside_root.mkdir(parents=True, exist_ok=True)
|
||||
source = outside_root / "Sample.mkv"
|
||||
source.write_text("sample")
|
||||
|
||||
destination = library_root / "movie" / "Sample.mkv"
|
||||
operation = FileOperation(
|
||||
operation_type="move",
|
||||
source_path=source,
|
||||
destination_path=destination,
|
||||
reason="unsafe test",
|
||||
has_conflict=False,
|
||||
conflict_reason=None,
|
||||
)
|
||||
plan = ExecutionPlan(
|
||||
plan_id=str(uuid4()),
|
||||
created_at=datetime.now(timezone.utc),
|
||||
operations=[operation],
|
||||
summary={"total": 1, "move": 1, "rename": 0, "quarantine": 0, "no-op": 0},
|
||||
)
|
||||
|
||||
engine = ExecutionEngine(config=Config(library_root=library_root))
|
||||
results, summary, _ = engine.execute_plan(plan, mode="execute", confirmed=True)
|
||||
|
||||
assert not results[0].success
|
||||
assert "Unsafe source outside library root" in (results[0].error_message or "")
|
||||
assert summary["failed"] == 1
|
||||
assert source.exists()
|
||||
assert not destination.exists()
|
||||
|
||||
@@ -614,6 +614,114 @@ def test_generate_plan_with_analysis_by_reputation_missing_scores_uses_fallback_
|
||||
assert "评分依据不足" in plan.human_summary
|
||||
|
||||
|
||||
|
||||
|
||||
def test_generate_plan_with_analysis_by_quality_missing_quality_data_marks_manual_review(config):
|
||||
"""Missing quality entries should not silently keep the first duplicate."""
|
||||
config.duplicate_keep = "by_quality"
|
||||
p_low = Path("/mnt/nas/videos/movie/Test.2020.720p.WEB-DL.mkv")
|
||||
p_high = Path("/mnt/nas/videos/movie/Test.2020.1080p.BluRay.mkv")
|
||||
vf_low = VideoFile(
|
||||
path=p_low,
|
||||
filename="Test.2020.720p.WEB-DL.mkv",
|
||||
size_bytes=1000000,
|
||||
modified_timestamp=datetime.now(timezone.utc),
|
||||
category="movie",
|
||||
)
|
||||
vf_high = VideoFile(
|
||||
path=p_high,
|
||||
filename="Test.2020.1080p.BluRay.mkv",
|
||||
size_bytes=2000000,
|
||||
modified_timestamp=datetime.now(timezone.utc),
|
||||
category="movie",
|
||||
)
|
||||
identity = MovieIdentity(
|
||||
title="Test",
|
||||
year=2020,
|
||||
confidence=0.9,
|
||||
needs_review=False,
|
||||
original_filename="Test.2020.mkv",
|
||||
)
|
||||
|
||||
plan = generate_plan(
|
||||
[(vf_low, identity), (vf_high, identity)],
|
||||
config,
|
||||
analysis_data={
|
||||
"metadata": {"source_identities": "identities.json"},
|
||||
"completeness": [],
|
||||
"duplicates": [
|
||||
{
|
||||
"identity": {"type": "movie", "title": "Test", "year": 2020},
|
||||
"files": [str(p_low), str(p_high)],
|
||||
"quality_comparison": [
|
||||
{"path": str(p_low), "resolution": "1280x720", "size_bytes": 1000000},
|
||||
],
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
assert all(op.operation_type == "no-op" for op in plan.operations)
|
||||
assert "Duplicate group needs manual review" in plan.operations[0].reason
|
||||
issues = plan.metadata["validation_snapshot"]["duplicate_resolution_issues"]
|
||||
assert len(issues) == 1
|
||||
assert "missing quality comparison entries" in issues[0]["reason"]
|
||||
assert "转人工复核" in plan.human_summary
|
||||
|
||||
|
||||
def test_generate_plan_normalizes_duplicate_paths_before_matching(config):
|
||||
"""Duplicate matching should survive harmless path-format differences."""
|
||||
config.duplicate_keep = "by_quality"
|
||||
p_720 = Path("/mnt/nas/videos/movie/Test.2020.720p.WEB-DL.mkv")
|
||||
p_1080 = Path("/mnt/nas/videos/movie/Test.2020.1080p.BluRay.mkv")
|
||||
vf_720 = VideoFile(
|
||||
path=p_720,
|
||||
filename="Test.2020.720p.WEB-DL.mkv",
|
||||
size_bytes=1000000,
|
||||
modified_timestamp=datetime.now(timezone.utc),
|
||||
category="movie",
|
||||
)
|
||||
vf_1080 = VideoFile(
|
||||
path=p_1080,
|
||||
filename="Test.2020.1080p.BluRay.mkv",
|
||||
size_bytes=2000000,
|
||||
modified_timestamp=datetime.now(timezone.utc),
|
||||
category="movie",
|
||||
)
|
||||
identity = MovieIdentity(
|
||||
title="Test",
|
||||
year=2020,
|
||||
confidence=0.9,
|
||||
needs_review=False,
|
||||
original_filename="Test.2020.mkv",
|
||||
)
|
||||
|
||||
normalized_low = str(p_720.parent / "." / p_720.name)
|
||||
normalized_high = str(p_1080.parent / "." / p_1080.name)
|
||||
plan = generate_plan(
|
||||
[(vf_720, identity), (vf_1080, identity)],
|
||||
config,
|
||||
analysis_data={
|
||||
"metadata": {"source_identities": "identities.json"},
|
||||
"completeness": [],
|
||||
"duplicates": [
|
||||
{
|
||||
"identity": {"type": "movie", "title": "Test", "year": 2020},
|
||||
"files": [normalized_low, normalized_high],
|
||||
"quality_comparison": [
|
||||
{"path": normalized_low, "resolution": "1280x720", "size_bytes": 1000000},
|
||||
{"path": normalized_high, "resolution": "1920x1080", "size_bytes": 2000000},
|
||||
],
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
assert plan.summary["quarantine"] == 1
|
||||
assert plan.operations[0].operation_type == "quarantine"
|
||||
assert plan.operations[1].operation_type == "move"
|
||||
|
||||
|
||||
def test_generate_plan_human_summary_marks_disc_files_as_high_risk(config):
|
||||
"""Disc/part files should be listed with a conservative risk note in summary."""
|
||||
config.duplicate_keep = "by_reputation"
|
||||
|
||||
+15
-13
@@ -82,28 +82,30 @@ class TestQuarantineManager:
|
||||
assert expected_quarantine_path.read_text() == "test content"
|
||||
|
||||
def test_quarantine_anime_file_rejected(self, manager, config):
|
||||
"""Test that quarantining anime files is rejected."""
|
||||
"""Test that quarantining anime files returns a failed result."""
|
||||
# Create a test anime file
|
||||
anime_file = config.library_root / "anime" / "Test Anime.mkv"
|
||||
anime_file.write_text("test content")
|
||||
|
||||
# Attempt to quarantine should raise ValueError
|
||||
with pytest.raises(ValueError, match="Quarantine not supported for category 'anime'"):
|
||||
manager.quarantine_file(anime_file)
|
||||
|
||||
|
||||
result = manager.quarantine_file(anime_file)
|
||||
|
||||
assert result.success is False
|
||||
assert "Quarantine not supported for category 'anime'" in (result.error_message or "")
|
||||
|
||||
# Verify file was not moved
|
||||
assert anime_file.exists()
|
||||
|
||||
|
||||
def test_quarantine_other_file_rejected(self, manager, config):
|
||||
"""Test that quarantining other files is rejected."""
|
||||
"""Test that quarantining other files returns a failed result."""
|
||||
# Create a test other file
|
||||
other_file = config.library_root / "other" / "Test File.mkv"
|
||||
other_file.write_text("test content")
|
||||
|
||||
# Attempt to quarantine should raise ValueError
|
||||
with pytest.raises(ValueError, match="Quarantine not supported for category 'other'"):
|
||||
manager.quarantine_file(other_file)
|
||||
|
||||
|
||||
result = manager.quarantine_file(other_file)
|
||||
|
||||
assert result.success is False
|
||||
assert "Quarantine not supported for category 'other'" in (result.error_message or "")
|
||||
|
||||
# Verify file was not moved
|
||||
assert other_file.exists()
|
||||
|
||||
|
||||
@@ -137,6 +137,54 @@ class TestScanLibrary:
|
||||
assert len(result) == 1
|
||||
assert result[0].path == visible_file
|
||||
|
||||
def test_scan_keeps_partial_find_results_when_find_exits_nonzero(self, tmp_path):
|
||||
"""Non-zero find exits should keep partial stdout and log the contract."""
|
||||
movie_dir = tmp_path / "movie"
|
||||
movie_dir.mkdir()
|
||||
visible_file = movie_dir / "visible.mp4"
|
||||
visible_file.touch()
|
||||
|
||||
fake_stdout = f"{visible_file}\0".encode()
|
||||
|
||||
with patch('subprocess.Popen') as mock_popen, patch("vlm.scanner.logger.warning") as mock_warning:
|
||||
process = MagicMock()
|
||||
process.communicate.return_value = (fake_stdout, b"Permission denied")
|
||||
process.returncode = 1
|
||||
mock_popen.return_value = process
|
||||
|
||||
config = Config(library_root=tmp_path)
|
||||
result = scan_library(tmp_path, config, include_video_metadata=False)
|
||||
|
||||
warning_messages = [
|
||||
call.args[0] % call.args[1:] if len(call.args) > 1 else call.args[0]
|
||||
for call in mock_warning.call_args_list
|
||||
]
|
||||
assert len(result) == 1
|
||||
assert result[0].path == visible_file
|
||||
assert any("using 1 partial scan result" in message for message in warning_messages)
|
||||
assert any("Permission denied" in message for message in warning_messages)
|
||||
|
||||
def test_scan_returns_empty_when_find_exits_nonzero_without_stdout(self, tmp_path):
|
||||
"""Non-zero find exits without stdout should produce an empty result deterministically."""
|
||||
(tmp_path / "movie").mkdir()
|
||||
|
||||
with patch('subprocess.Popen') as mock_popen, patch("vlm.scanner.logger.warning") as mock_warning:
|
||||
process = MagicMock()
|
||||
process.communicate.return_value = (b"", b"Permission denied")
|
||||
process.returncode = 1
|
||||
mock_popen.return_value = process
|
||||
|
||||
config = Config(library_root=tmp_path)
|
||||
result = scan_library(tmp_path, config, include_video_metadata=False)
|
||||
|
||||
warning_messages = [
|
||||
call.args[0] % call.args[1:] if len(call.args) > 1 else call.args[0]
|
||||
for call in mock_warning.call_args_list
|
||||
]
|
||||
assert result == []
|
||||
assert any("produced no scan results" in message for message in warning_messages)
|
||||
assert any("Permission denied" in message for message in warning_messages)
|
||||
|
||||
def test_scan_falls_back_when_find_is_unavailable(self, tmp_path):
|
||||
"""Test scan_library falls back to recursive scanning if find is unavailable."""
|
||||
movie_dir = tmp_path / "movie"
|
||||
|
||||
Reference in New Issue
Block a user