Cross-validation audit by Codex + Opus identified 14 issues: CRITICAL: - Fix _find_subdir_ci() prefix match accepting wrong folders (e.g. Publish_old). Now uses exact case-insensitive match. HIGH: - Add pack-sequence CLI subcommand with unambiguous arg parsing. Deprecate --sequence on pack (nargs='+' consumed the target). - Fix Windows UNC/drive path normalization in bpathlib.make_absolute() - Fix ZIP failure losing traceback + progress bar stuck on error - Fix os.path.commonpath crash on cross-drive paths (new derive_common_project helper) - Fix progress bar saturating at 40% during trace (log scale) MEDIUM: - Add per-shot error isolation in sequence scan (collect all errors) - Add memory warning for >50 files in sequence pack - Improve pack-info.txt with dedup stats - Wrap single Path in list for old operators (type consistency) LOW/INFO: - Document VERSION_RE pattern - Add TASK_CHOICE_ITEMS empty guard 28 new tests in tests/test_sequence_pack.py, 0 regressions. Co-Authored-By: Mario Hawat <mario@autourdeminuit.com> Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
299 lines
9.9 KiB
Python
299 lines
9.9 KiB
Python
"""Tests for the pack-sequence branch features."""
|
|
import os
|
|
import pathlib
|
|
import platform
|
|
import re
|
|
import math
|
|
import sys
|
|
import types
|
|
|
|
import pytest
|
|
|
|
from blender_asset_tracer import bpathlib
|
|
|
|
# operators.py requires bpy (Blender Python), which isn't available outside Blender.
|
|
# We extract testable pure-Python functions by mocking bpy at import time.
|
|
_mock_bpy = types.ModuleType("bpy")
|
|
_mock_bpy.types = types.ModuleType("bpy.types")
|
|
_mock_bpy.props = types.ModuleType("bpy.props")
|
|
_mock_bpy.types.Operator = type("Operator", (), {})
|
|
_mock_bpy.types.PropertyGroup = type("PropertyGroup", (), {})
|
|
_mock_bpy.props.StringProperty = lambda **kw: ""
|
|
_mock_bpy.props.BoolProperty = lambda **kw: False
|
|
_mock_bpy.props.EnumProperty = lambda **kw: ""
|
|
_mock_bpy.props.CollectionProperty = lambda **kw: None
|
|
sys.modules.setdefault("bpy", _mock_bpy)
|
|
sys.modules.setdefault("bpy.types", _mock_bpy.types)
|
|
sys.modules.setdefault("bpy.props", _mock_bpy.props)
|
|
|
|
_mock_extras = types.ModuleType("bpy_extras")
|
|
_mock_io = types.ModuleType("bpy_extras.io_utils")
|
|
_mock_io.ExportHelper = type("ExportHelper", (), {})
|
|
sys.modules.setdefault("bpy_extras", _mock_extras)
|
|
sys.modules.setdefault("bpy_extras.io_utils", _mock_io)
|
|
|
|
from blender_asset_tracer.operators import (
|
|
_find_subdir_ci, find_latest_publishes, VERSION_RE,
|
|
)
|
|
|
|
|
|
# --- Fix #1: _find_subdir_ci exact match ---
|
|
|
|
class TestFindSubdirCI:
|
|
"""Test case-insensitive directory matching."""
|
|
|
|
def test_exact_case_match(self, tmp_path):
|
|
|
|
(tmp_path / "Publish").mkdir()
|
|
result = _find_subdir_ci(tmp_path, "Publish")
|
|
assert result is not None
|
|
assert result.name == "Publish"
|
|
|
|
def test_case_insensitive_match(self, tmp_path):
|
|
|
|
(tmp_path / "PUBLISH").mkdir()
|
|
result = _find_subdir_ci(tmp_path, "publish")
|
|
assert result is not None
|
|
assert result.name == "PUBLISH"
|
|
|
|
def test_no_prefix_fallback(self, tmp_path):
|
|
"""Prefix matches like 'Publish_old' must NOT match 'Publish'."""
|
|
|
|
(tmp_path / "Publish_old").mkdir()
|
|
result = _find_subdir_ci(tmp_path, "Publish")
|
|
assert result is None
|
|
|
|
def test_exact_wins_over_prefix(self, tmp_path):
|
|
|
|
(tmp_path / "Publish_old").mkdir()
|
|
(tmp_path / "Publish").mkdir()
|
|
result = _find_subdir_ci(tmp_path, "Publish")
|
|
assert result is not None
|
|
assert result.name == "Publish"
|
|
|
|
def test_missing_returns_none(self, tmp_path):
|
|
|
|
(tmp_path / "Other").mkdir()
|
|
result = _find_subdir_ci(tmp_path, "Publish")
|
|
assert result is None
|
|
|
|
def test_ignores_files(self, tmp_path):
|
|
|
|
(tmp_path / "Publish").touch() # file, not dir
|
|
result = _find_subdir_ci(tmp_path, "Publish")
|
|
assert result is None
|
|
|
|
|
|
# --- Fix #2: CLI pack-sequence parsing ---
|
|
|
|
class TestCLISequenceParsing:
|
|
"""Test the pack-sequence subcommand argument parsing."""
|
|
|
|
def test_pack_sequence_parses_correctly(self):
|
|
import argparse
|
|
from blender_asset_tracer.cli import pack as cli_pack
|
|
|
|
parser = argparse.ArgumentParser()
|
|
subparsers = parser.add_subparsers()
|
|
cli_pack.add_sequence_parser(subparsers)
|
|
|
|
args = parser.parse_args([
|
|
"pack-sequence", "-t", "output.zip",
|
|
"a.blend", "b.blend", "c.blend",
|
|
])
|
|
assert args.target == "output.zip"
|
|
assert len(args.blendfiles) == 3
|
|
assert args.blendfiles[0] == pathlib.Path("a.blend")
|
|
|
|
def test_pack_sequence_requires_target(self):
|
|
import argparse
|
|
from blender_asset_tracer.cli import pack as cli_pack
|
|
|
|
parser = argparse.ArgumentParser()
|
|
subparsers = parser.add_subparsers()
|
|
cli_pack.add_sequence_parser(subparsers)
|
|
|
|
with pytest.raises(SystemExit):
|
|
parser.parse_args(["pack-sequence", "a.blend", "b.blend"])
|
|
|
|
def test_pack_sequence_requires_files(self):
|
|
import argparse
|
|
from blender_asset_tracer.cli import pack as cli_pack
|
|
|
|
parser = argparse.ArgumentParser()
|
|
subparsers = parser.add_subparsers()
|
|
cli_pack.add_sequence_parser(subparsers)
|
|
|
|
with pytest.raises(SystemExit):
|
|
parser.parse_args(["pack-sequence", "-t", "output.zip"])
|
|
|
|
|
|
# --- Fix #3: Cross-platform path normalization ---
|
|
|
|
class TestCrossPlatformPaths:
|
|
"""Test Windows path handling on Linux."""
|
|
|
|
@pytest.mark.skipif(platform.system() == 'Windows', reason='Linux-only test')
|
|
def test_make_absolute_windows_drive_no_cwd_contamination(self):
|
|
p = pathlib.PurePosixPath('C:/Users/LaCabane/Projects/shot.blend')
|
|
result = bpathlib.make_absolute(p)
|
|
result_str = str(result)
|
|
assert 'C:' in result_str
|
|
# Must NOT prepend CWD
|
|
assert not result_str.startswith('/home')
|
|
assert not result_str.startswith('/tmp')
|
|
|
|
@pytest.mark.skipif(platform.system() == 'Windows', reason='Linux-only test')
|
|
def test_make_absolute_unc_preserved(self):
|
|
p = pathlib.PurePosixPath('//server/share/projects/shot.blend')
|
|
result = bpathlib.make_absolute(p)
|
|
result_str = str(result)
|
|
assert result_str.startswith('//')
|
|
|
|
@pytest.mark.skipif(platform.system() == 'Windows', reason='Linux-only test')
|
|
def test_make_absolute_windows_dotdot_normalized(self):
|
|
p = pathlib.PurePosixPath('C:/Users/LaCabane/../Shared/shot.blend')
|
|
result = bpathlib.make_absolute(p)
|
|
result_str = str(result)
|
|
assert '..' not in result_str
|
|
assert 'C:' in result_str
|
|
|
|
def test_is_windows_path_drive(self):
|
|
assert bpathlib._is_windows_path('C:/foo')
|
|
assert bpathlib._is_windows_path('D:\\bar')
|
|
|
|
def test_is_windows_path_unc(self):
|
|
assert bpathlib._is_windows_path('//server/share')
|
|
assert bpathlib._is_windows_path('\\\\server\\share')
|
|
|
|
def test_is_windows_path_negative(self):
|
|
assert not bpathlib._is_windows_path('/home/user')
|
|
assert not bpathlib._is_windows_path('relative/path')
|
|
|
|
|
|
# --- Fix #5: derive_common_project ---
|
|
|
|
class TestDeriveCommonProject:
|
|
"""Test project root derivation from blend file paths."""
|
|
|
|
def test_single_file(self, tmp_path):
|
|
from blender_asset_tracer.cli.pack import derive_common_project
|
|
bf = tmp_path / "project" / "shot.blend"
|
|
bf.parent.mkdir(parents=True)
|
|
bf.touch()
|
|
result = derive_common_project([bf])
|
|
assert result == bf.parent
|
|
|
|
def test_multiple_files_common_parent(self, tmp_path):
|
|
from blender_asset_tracer.cli.pack import derive_common_project
|
|
(tmp_path / "shots" / "sq01").mkdir(parents=True)
|
|
(tmp_path / "shots" / "sq02").mkdir(parents=True)
|
|
bf1 = tmp_path / "shots" / "sq01" / "a.blend"
|
|
bf2 = tmp_path / "shots" / "sq02" / "b.blend"
|
|
bf1.touch()
|
|
bf2.touch()
|
|
result = derive_common_project([bf1, bf2])
|
|
assert result == tmp_path / "shots"
|
|
|
|
@pytest.mark.skipif(platform.system() != 'Windows', reason='Windows cross-drive test')
|
|
def test_cross_drive_raises(self):
|
|
from blender_asset_tracer.cli.pack import derive_common_project
|
|
bf1 = pathlib.Path("C:/shots/a.blend")
|
|
bf2 = pathlib.Path("D:/shots/b.blend")
|
|
with pytest.raises(ValueError, match="multiple drives"):
|
|
derive_common_project([bf1, bf2])
|
|
|
|
|
|
# --- Fix #6: Progress bar log scaling ---
|
|
|
|
class TestProgressScaling:
|
|
"""Test that progress bar doesn't saturate."""
|
|
|
|
def test_100_assets_below_200(self):
|
|
val = int(400 * (1 - 1 / (1 + math.log1p(100) / 10)))
|
|
assert val < 200
|
|
|
|
def test_1000_assets_below_350(self):
|
|
val = int(400 * (1 - 1 / (1 + math.log1p(1000) / 10)))
|
|
assert val < 350
|
|
|
|
def test_10000_assets_below_400(self):
|
|
val = int(400 * (1 - 1 / (1 + math.log1p(10000) / 10)))
|
|
assert val < 400
|
|
|
|
def test_monotonically_increasing(self):
|
|
prev = 0
|
|
for n in [1, 10, 100, 1000, 10000]:
|
|
val = int(400 * (1 - 1 / (1 + math.log1p(n) / 10)))
|
|
assert val >= prev
|
|
prev = val
|
|
|
|
|
|
# --- Fix #7: Scan error collection ---
|
|
|
|
class TestScanErrorCollection:
|
|
"""Test that scan errors are collected, not silently skipped."""
|
|
|
|
def test_unreadable_shot_collected_as_error(self, tmp_path):
|
|
|
|
# Create a root with one readable and one unreadable shot
|
|
root = tmp_path / "sequence"
|
|
root.mkdir()
|
|
(root / "shot01" / "03_ANIMATION" / "Publish").mkdir(parents=True)
|
|
blend = root / "shot01" / "03_ANIMATION" / "Publish" / "scene_v01.blend"
|
|
blend.touch()
|
|
|
|
# Make shot02 unreadable
|
|
bad_shot = root / "shot02"
|
|
bad_shot.mkdir()
|
|
bad_shot.chmod(0o000)
|
|
|
|
try:
|
|
publishes, errors = find_latest_publishes(str(root), 'LCPROD')
|
|
# shot01 should be found
|
|
assert len(publishes) == 1
|
|
assert publishes[0][0] == "shot01"
|
|
# shot02 should be in errors
|
|
assert len(errors) == 1
|
|
assert errors[0][0] == "shot02"
|
|
finally:
|
|
bad_shot.chmod(0o755) # cleanup
|
|
|
|
def test_clean_scan_no_errors(self, tmp_path):
|
|
|
|
root = tmp_path / "sequence"
|
|
root.mkdir()
|
|
(root / "shot01" / "03_ANIMATION" / "Publish").mkdir(parents=True)
|
|
(root / "shot01" / "03_ANIMATION" / "Publish" / "scene_v01.blend").touch()
|
|
publishes, errors = find_latest_publishes(str(root), 'LCPROD')
|
|
assert len(publishes) == 1
|
|
assert len(errors) == 0
|
|
|
|
|
|
# --- Fix #13: VERSION_RE ---
|
|
|
|
class TestVersionRegex:
|
|
"""Test version number extraction from filenames."""
|
|
|
|
def test_matches_standard(self):
|
|
|
|
m = VERSION_RE.search("scene_v02.blend")
|
|
assert m is not None
|
|
assert m.group(1) == "02"
|
|
|
|
def test_matches_case_insensitive(self):
|
|
|
|
m = VERSION_RE.search("SCENE_V10.BLEND")
|
|
assert m is not None
|
|
assert m.group(1) == "10"
|
|
|
|
def test_no_match_without_v(self):
|
|
|
|
m = VERSION_RE.search("scene_02.blend")
|
|
assert m is None
|
|
|
|
def test_no_match_dotversion(self):
|
|
|
|
m = VERSION_RE.search("scene_v02.1.blend")
|
|
assert m is None
|