commit remaining modified project files
This commit is contained in:
@@ -232,3 +232,112 @@ def test_review_plan_show_all_overrides_preview_limit(tmp_path):
|
||||
assert " - [2] " in result.output
|
||||
assert " - [3] " in result.output
|
||||
assert "more high-risk operations" not in result.output
|
||||
|
||||
|
||||
def test_review_plan_tui_requires_interactive_terminal(tmp_path):
|
||||
"""--tui should fail when stdin/stdout are not TTY (e.g. CliRunner)."""
|
||||
config_path = tmp_path / "config.yaml"
|
||||
_write_config(config_path, tmp_path / "library")
|
||||
|
||||
plan_path = tmp_path / "plan.json"
|
||||
operations = [
|
||||
{
|
||||
"operation_type": "move",
|
||||
"source_path": str(tmp_path / "a.mkv"),
|
||||
"destination_path": str(tmp_path / "b.mkv"),
|
||||
"reason": "organize",
|
||||
"has_conflict": False,
|
||||
"conflict_reason": None,
|
||||
},
|
||||
]
|
||||
_write_plan(
|
||||
plan_path,
|
||||
operations=operations,
|
||||
summary={"total": 1, "move": 1, "rename": 0, "quarantine": 0, "no-op": 0},
|
||||
)
|
||||
|
||||
output_csv = tmp_path / "review.csv"
|
||||
runner = CliRunner()
|
||||
result = _invoke_review_plan(
|
||||
runner,
|
||||
config_path,
|
||||
["--input", str(plan_path), "--output", str(output_csv), "--tui"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "TTY" in result.output or "interactive terminal" in result.output
|
||||
|
||||
|
||||
def test_review_plan_tui_stubbed_success(tmp_path, monkeypatch):
|
||||
"""With TTY check stubbed and run_plan_review_tui stubbed, CLI should succeed."""
|
||||
config_path = tmp_path / "config.yaml"
|
||||
_write_config(config_path, tmp_path / "library")
|
||||
|
||||
plan_path = tmp_path / "plan.json"
|
||||
operations = [
|
||||
{
|
||||
"operation_type": "move",
|
||||
"source_path": str(tmp_path / "a.mkv"),
|
||||
"destination_path": str(tmp_path / "b.mkv"),
|
||||
"reason": "organize",
|
||||
"has_conflict": False,
|
||||
"conflict_reason": None,
|
||||
},
|
||||
]
|
||||
_write_plan(
|
||||
plan_path,
|
||||
operations=operations,
|
||||
summary={"total": 1, "move": 1, "rename": 0, "quarantine": 0, "no-op": 0},
|
||||
)
|
||||
|
||||
monkeypatch.setattr("vlm.cli._review_plan_tui_streams_ok", lambda: True)
|
||||
monkeypatch.setattr("vlm.review_tui.run_plan_review_tui", lambda ctx: 0)
|
||||
|
||||
output_csv = tmp_path / "review.csv"
|
||||
runner = CliRunner()
|
||||
result = _invoke_review_plan(
|
||||
runner,
|
||||
config_path,
|
||||
["--input", str(plan_path), "--output", str(output_csv), "--tui"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Saved manual review CSV" in result.output
|
||||
assert "Plan overview:" not in result.output
|
||||
|
||||
|
||||
def test_review_plan_tui_stubbed_abort(tmp_path, monkeypatch):
|
||||
"""TUI return code 1 should surface as CLI failure."""
|
||||
config_path = tmp_path / "config.yaml"
|
||||
_write_config(config_path, tmp_path / "library")
|
||||
|
||||
plan_path = tmp_path / "plan.json"
|
||||
operations = [
|
||||
{
|
||||
"operation_type": "move",
|
||||
"source_path": str(tmp_path / "a.mkv"),
|
||||
"destination_path": str(tmp_path / "b.mkv"),
|
||||
"reason": "organize",
|
||||
"has_conflict": False,
|
||||
"conflict_reason": None,
|
||||
},
|
||||
]
|
||||
_write_plan(
|
||||
plan_path,
|
||||
operations=operations,
|
||||
summary={"total": 1, "move": 1, "rename": 0, "quarantine": 0, "no-op": 0},
|
||||
)
|
||||
|
||||
monkeypatch.setattr("vlm.cli._review_plan_tui_streams_ok", lambda: True)
|
||||
monkeypatch.setattr("vlm.review_tui.run_plan_review_tui", lambda ctx: 1)
|
||||
|
||||
output_csv = tmp_path / "review.csv"
|
||||
runner = CliRunner()
|
||||
result = _invoke_review_plan(
|
||||
runner,
|
||||
config_path,
|
||||
["--input", str(plan_path), "--output", str(output_csv), "--tui"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "aborted" in result.output.lower()
|
||||
|
||||
@@ -72,10 +72,10 @@ class TestDuplicateQualityWithMetadata:
|
||||
loaded = load_identities_json(identities_file)
|
||||
|
||||
# Convert to analysis input
|
||||
movie_identities, series_identities, video_files = identities_to_analysis_input(loaded)
|
||||
movie_pairs, series_pairs = identities_to_analysis_input(loaded)
|
||||
|
||||
# Create identity-file pairs
|
||||
identity_file_pairs = list(zip(movie_identities, video_files))
|
||||
identity_file_pairs = movie_pairs
|
||||
|
||||
# Detect duplicates
|
||||
duplicates = detect_duplicates(identity_file_pairs)
|
||||
@@ -151,10 +151,10 @@ class TestDuplicateQualityWithMetadata:
|
||||
loaded = load_identities_json(identities_file)
|
||||
|
||||
# Convert to analysis input
|
||||
movie_identities, series_identities, video_files = identities_to_analysis_input(loaded)
|
||||
movie_pairs, series_pairs = identities_to_analysis_input(loaded)
|
||||
|
||||
# Create identity-file pairs
|
||||
identity_file_pairs = list(zip(movie_identities, video_files))
|
||||
identity_file_pairs = movie_pairs
|
||||
|
||||
# Detect duplicates
|
||||
duplicates = detect_duplicates(identity_file_pairs)
|
||||
@@ -231,10 +231,10 @@ class TestDuplicateQualityWithMetadata:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
loaded = load_identities_json(identities_file)
|
||||
movie_identities, series_identities, video_files = identities_to_analysis_input(loaded)
|
||||
movie_pairs, series_pairs = identities_to_analysis_input(loaded)
|
||||
|
||||
# Create identity-file pairs for series
|
||||
identity_file_pairs = list(zip(series_identities, video_files))
|
||||
identity_file_pairs = series_pairs
|
||||
|
||||
# Detect duplicates
|
||||
duplicates = detect_duplicates(identity_file_pairs)
|
||||
|
||||
@@ -8,6 +8,7 @@ import pytest
|
||||
|
||||
from vlm.io import (
|
||||
_video_file_from_record,
|
||||
load_analysis_json,
|
||||
load_identities_json,
|
||||
save_identities_json,
|
||||
)
|
||||
@@ -260,3 +261,72 @@ class TestIdentitiesJsonRoundTrip:
|
||||
assert series["video_metadata"]["size_bytes"] == 500000000
|
||||
assert series["video_metadata"]["resolution"] == "1920x1080"
|
||||
assert series["video_metadata"]["codec"] == "h264"
|
||||
|
||||
|
||||
class TestJsonSchemaValidation:
|
||||
"""Tests for runtime schema validation on JSON artifacts."""
|
||||
|
||||
def test_save_identities_rejects_invalid_movie_shape(self, tmp_path):
|
||||
identities_file = tmp_path / "identities.json"
|
||||
invalid_data = {
|
||||
"vlm_schema_version": "2.0",
|
||||
"metadata": {
|
||||
"generated": "2024-02-13T12:00:00",
|
||||
"source_inventory": "test_inventory.csv",
|
||||
"total_files": 1,
|
||||
},
|
||||
"movies": [
|
||||
{
|
||||
"path": "/library/movie/Movie1 (2024).mkv",
|
||||
"filename": "Movie1 (2024).mkv",
|
||||
"category": "movie",
|
||||
"title": "Movie1",
|
||||
"year": 2024,
|
||||
"confidence": "high",
|
||||
"needs_review": False,
|
||||
}
|
||||
],
|
||||
"series": [],
|
||||
"anime": [],
|
||||
"other": [],
|
||||
}
|
||||
|
||||
with pytest.raises(ValueError, match="confidence"):
|
||||
save_identities_json(invalid_data, identities_file)
|
||||
|
||||
def test_load_analysis_rejects_missing_required_fields(self, tmp_path):
|
||||
analysis_file = tmp_path / "analysis.json"
|
||||
analysis_file.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"vlm_schema_version": "1.0",
|
||||
"metadata": {"generated": "2024-01-01T00:00:00"},
|
||||
"completeness": [
|
||||
{
|
||||
"series_title": "Show",
|
||||
"season": 1,
|
||||
"episodes_found": [1],
|
||||
"episodes_missing": [2],
|
||||
}
|
||||
],
|
||||
"duplicates": [
|
||||
{
|
||||
"identity": {
|
||||
"type": "movie",
|
||||
"title": "Test",
|
||||
"year": 2024,
|
||||
"season": None,
|
||||
"episodes": [],
|
||||
},
|
||||
"files": ["/library/movie/Test (2024).mkv"],
|
||||
"quality_comparison": [],
|
||||
}
|
||||
],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
loaded = load_analysis_json(analysis_file)
|
||||
assert loaded["metadata"]["generated"] == "2024-01-01T00:00:00"
|
||||
assert loaded["duplicates"][0]["identity"]["title"] == "Test"
|
||||
|
||||
+65
-1
@@ -1,5 +1,6 @@
|
||||
"""Unit tests for plan generator."""
|
||||
|
||||
import json
|
||||
import pytest
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
@@ -12,7 +13,7 @@ from vlm.models import (
|
||||
SeriesIdentity,
|
||||
VideoFile,
|
||||
)
|
||||
from vlm.planner import generate_plan
|
||||
from vlm.planner import generate_plan, load_plan
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -1255,3 +1256,66 @@ def test_series_rejected_by_review_generates_noop(config):
|
||||
plan = generate_plan([(video_file, identity)], config)
|
||||
assert plan.operations[0].operation_type == "no-op"
|
||||
assert "rejected" in plan.operations[0].reason.lower()
|
||||
|
||||
|
||||
def test_save_and_load_plan_validates_schema(tmp_path):
|
||||
"""Saved plan artifacts should round-trip through schema validation."""
|
||||
plan = ExecutionPlan(
|
||||
plan_id="plan-123",
|
||||
created_at=datetime(2026, 4, 2, 12, 0, tzinfo=timezone.utc),
|
||||
operations=[
|
||||
FileOperation(
|
||||
operation_type="move",
|
||||
source_path=Path("/mnt/nas/videos/movie/source.mkv"),
|
||||
destination_path=Path("/mnt/nas/videos/movie/target.mkv"),
|
||||
reason="move movie",
|
||||
has_conflict=False,
|
||||
)
|
||||
],
|
||||
summary={"move": 1, "rename": 0, "noop": 0, "delete": 0},
|
||||
summary_by_reason={"move movie": 1},
|
||||
human_summary="计划已生成",
|
||||
metadata={"analysis_source": "analysis.json"},
|
||||
)
|
||||
|
||||
output_path = tmp_path / "plan.json"
|
||||
from vlm.planner import save_plan
|
||||
|
||||
save_plan(plan, output_path)
|
||||
loaded = load_plan(output_path)
|
||||
|
||||
assert loaded.plan_id == plan.plan_id
|
||||
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_plan_rejects_invalid_schema(tmp_path):
|
||||
"""Invalid plan artifacts should fail validation before deserialization."""
|
||||
invalid_path = tmp_path / "invalid-plan.json"
|
||||
invalid_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"vlm_schema_version": "1.0",
|
||||
"plan_id": "plan-123",
|
||||
"created_at": "2026-04-02T12:00:00+00:00",
|
||||
"operations": [
|
||||
{
|
||||
"operation_type": "move",
|
||||
"source_path": "/mnt/nas/videos/movie/source.mkv",
|
||||
"destination_path": "/mnt/nas/videos/movie/target.mkv",
|
||||
"has_conflict": False,
|
||||
}
|
||||
],
|
||||
"summary": {"move": 1},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="plan JSON.operations\\[0\\]\\.reason"):
|
||||
load_plan(invalid_path)
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Tests for vlm.review_display helpers."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from vlm.review_display import (
|
||||
build_csv_rows,
|
||||
format_paths_for_detail,
|
||||
review_row_status_symbol,
|
||||
risk_flags_to_labels,
|
||||
)
|
||||
|
||||
|
||||
def test_risk_flags_to_labels():
|
||||
assert "需人工判断" in risk_flags_to_labels("manual_review")
|
||||
assert "目标冲突" in risk_flags_to_labels("conflict")
|
||||
assert risk_flags_to_labels("") == ""
|
||||
assert risk_flags_to_labels("unknown_flag") == "unknown_flag"
|
||||
|
||||
|
||||
def test_format_paths_for_detail_relative():
|
||||
root = Path("/media/lib")
|
||||
text = format_paths_for_detail(
|
||||
str(root / "a" / "f.mkv"),
|
||||
str(root / "b" / "f.mkv"),
|
||||
root,
|
||||
)
|
||||
assert "来源:" in text
|
||||
assert "f.mkv" in text
|
||||
assert "目标:" in text
|
||||
|
||||
|
||||
def test_build_csv_rows_merges_operation_type():
|
||||
rows = [
|
||||
{
|
||||
"index": "1",
|
||||
"operation_type": "move",
|
||||
"risk_flags": "manual_review",
|
||||
"source_path": "/x",
|
||||
"destination_path": "/y",
|
||||
"reason": "r",
|
||||
}
|
||||
]
|
||||
merged = build_csv_rows(rows, {1: "no-op"})
|
||||
assert merged[0]["operation_type"] == "no-op"
|
||||
assert merged[0]["risk_flags"] == "manual_review"
|
||||
|
||||
|
||||
def test_review_row_status_symbol():
|
||||
initial = {1: "move"}
|
||||
current = {1: "move"}
|
||||
assert review_row_status_symbol(current, initial, 1) == "·"
|
||||
current2 = {1: "no-op"}
|
||||
assert review_row_status_symbol(current2, initial, 1) == "✗"
|
||||
current3 = {1: "move"}
|
||||
initial3 = {1: "no-op"}
|
||||
assert review_row_status_symbol(current3, initial3, 1) == "✓"
|
||||
Reference in New Issue
Block a user