"""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