refactor review-plan safety and validation

This commit is contained in:
windyboy
2026-04-07 11:00:47 +08:00
parent fb128c70d6
commit 0a6bddcc7e
19 changed files with 1246 additions and 628 deletions
+19 -1
View File
@@ -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")
+56
View File
@@ -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
View File
@@ -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."""
+34
View File
@@ -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()
+108
View File
@@ -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
View File
@@ -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()
+48
View File
@@ -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"