diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index cd5ed59..f538ffb 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -13,7 +13,8 @@ jobs: with: enable-cache: true - run: uv sync - - run: uv run pytest + # カバレッジはジョブログでの可視化のみ (バッジ・ゲートは設けない) + - run: uv run pytest --cov=structured_renamer --cov-report=term # Tier 2: bpy wheel (5.0.1 / Python 3.11) による実 bpy テスト。 # ローカルの Blender 5.0.1 と同一バージョン。wheel は ~300MB のため diff --git a/dev-note/plans/2026-06-12-milestone.md b/dev-note/plans/2026-06-12-milestone.md index 9a55b59..afd33a5 100644 --- a/dev-note/plans/2026-06-12-milestone.md +++ b/dev-note/plans/2026-06-12-milestone.md @@ -90,12 +90,21 @@ 物理ファイルの実リネームは1回だけ、Undo 不能はサマリーに明示 - [x] `__tmp_` スワップのサービス層テスト(A↔B 入れ替えで .001 なし・残留なし) -### 2. テストの残り -- [ ] **Phase 2.5**: facade / factory の PropertyGroup フェイクテスト - (現状 23% / 31%。UI 同期レイヤ改修と同時が効率的) -- [ ] Tier 2 ターゲット網羅の続き: ShapeKey / Strip / File / Collection - (ID 型は構造同一のため優先度低) -- [ ] props.py 正規表現修正(A-1-4)の自動テスト(Phase 2.5 の枠で) +### 2. テストの残り(2026-06-13 発注 #1 で完了) +- [x] **Phase 2.5**: facade / factory の PropertyGroup フェイクテスト + → ダックタイピング代役(tests/unit/fakes.py)による Tier 1 で + **facade 94% / factory 100%**(test_pattern_facade.py 21件 / + test_pattern_factory.py 13件)。設計判断と経緯は + testing/01-phase-plan.md の Phase 2.5 を参照 +- [x] Tier 2 ターゲット網羅の続き: ShapeKey / Strip / Collection / Material + (ID 型代表)→ test_shapekey_target.py / test_strip_target.py / + test_collection_target.py / test_id_targets.py 追記。 + File はヘッドレスで FileSelectEntry を生成できないため、純関数 + (サニタイズ・Windows 予約名・拡張子 allowlist)を Tier 1 で固定 + (test_file_target_functions.py) +- [x] props.py 正規表現修正(A-1-4)の自動テスト + → tests/blender/test_props_uniqueness.py(addon_utils.enable で実物 + prefs.patterns を駆動。「Name.001.001 増殖」の反転テスト含む6件) ### 3. 負債返済(05章 A-3 / Phase 5) - [ ] dead code 削除 約2,400行: class_diagram_generator / screen_utils / diff --git a/dev-note/testing/01-phase-plan.md b/dev-note/testing/01-phase-plan.md index 6466322..ede853f 100644 --- a/dev-note/testing/01-phase-plan.md +++ b/dev-note/testing/01-phase-plan.md @@ -101,6 +101,57 @@ push で CI が緑 → 未確認(次回 push 時に確認)。 core/utils → target 層全体を import する(text_element は register() 内で 遅延 import — 不統一。遅延化すれば要素層→target 層の結合ごと消える) +## Phase 2.5 — facade / factory(PropertyGroup フェイク)と残りターゲット + +Phase 2 で除外した UI 同期装置の固定と、Tier 2 ターゲット網羅の続き +(2026-06-13 発注 #1 で実施)。 + +- [x] PropertyGroup のダックタイピング代役を fakes.py に追加 + (FakeElementProperty / FakePatternProperty / FakePrefs / make_fake_context)。 + **フェイク戦略の根拠**: factory は入力を hasattr/getattr でしか読まず + (factory.py:103-141)、facade が prefs に求めるのも patterns / + get_active_pattern() の2点だけ (facade.py:48-49, 121, 150, 179)。 + Tier 2(addon_utils.enable)案は不採用 — 実行時間と CI 依存を増やさず、 + PropertyGroup 実物を通る経路は A-1-4 回帰テストが別途カバーする +- [x] test_pattern_factory.py(13件): ElementConfig 変換(`.values()` を持つ + コレクションの名前リスト化含む)・境界検出のパターン単位一括判定・ + BlenderCounter 自動注入・「pattern.validate() エラーは握りつぶすのに + 要素レベルの設定不備は ValueError 素通し」という非対称の固定 +- [x] test_pattern_facade.py(21件): 初期同期・CRUD・synchronize/force_resync・ + 例外握りつぶし系(アクティブ ID 未キャッシュ → None、壊れたパターンの + スキップ、prefs 不在 context での構築は KeyError で死ぬ、等) +- [x] props.py A-1-4 の回帰テスト → tests/blender/test_props_uniqueness.py(6件)。 + PropertyGroup メソッド内のロジックのため Tier 2 で addon_utils.enable + (default_set=True)→ 実物 prefs.patterns を駆動。「Name.001.001 増殖」の + 反転テストを含む。終了時に必ず disable、userpref は保存しない +- [x] File ターゲットの純関数を Tier 1 で固定(test_file_target_functions.py、 + 32件)— FileSelectEntry はヘッドレス生成不可のため本体は Tier 3(手動)領域 +- [x] CI の unit ジョブにカバレッジサマリー追加(`--cov` のジョブログ表示のみ。 + バッジ・ゲートは設けない) + +**受け入れ条件**: facade / factory のカバレッジ 70%以上 → **達成** +(facade 23%→**94%** / factory 31%→**100%**。 +Tier 1 = 166 passed / 6 xfailed, 0.3s / Tier 2 = 39 passed, 0.2s) + +**Phase 2.5 の新発見**: +1. factory.create_pattern は `pattern.validate()` のエラーを error ログだけで + 握りつぶし、欠陥パターンをそのまま返す(factory.py:49-53 — raise が + コメントアウト)。numeric_counter 欠落はここを素通りして後段 parse_name の + StopIteration(model.py:78-83 FIXME)に到達し得る +2. 一方、要素レベルの設定不備(text の items 空など)は registry.create_element + の ValueError(registry.py:156-157)が factory.py:68 の + `except (KeyError, TypeError)` に捕まらず呼び出し元へ素通しする — 非対称 +3. アドオン prefs が引けない context では PatternFacade の構築自体が + get_prefs の KeyError で死ぬ(facade.py:121 経由に例外処理がない)。 + context=None なら warning ログだけで空キャッシュ構築に成功する — 不統一 +4. addon_utils.enable/disable のサイクルは Blender 終了時に + 「Not freed memory blocks: 1(61 bytes)」を残す(exit code への影響なし、 + テスト結果にも無害。test_props_uniqueness.py 追加で観測) +5. facade の未カバー6行(facade.py:156-158, 161, 190-191)のうち + `_remove_deleted_patterns` の削除ループは、先行する + `_synchronize_modified_patterns` 内の整合性ループ(facade.py:136-139)が + 常に先に削除するため実質到達不能の dead path(検証レビューで確認) + ## Phase 3 — 実 bpy 層(Tier 2) - [x] ローカル実行系: `tests/blender/run.sh`(隔離レシピ込み)→ `runner.py` が @@ -116,11 +167,12 @@ push で CI が緑 → 未確認(次回 push 時に確認)。 MRO だけの問題」も併せて証明。xfail(strict) の反転テストも配置済み - [x] `.001` 相互作用: 引き継ぎ分岐は Tier 1 (test_counter_takeover.py)、 実 bpy.data の重複挙動・ID.rename の3モードは Tier 2 (test_id_targets.py) -- [ ] `__tmp_` 二段リネーム: スワップケース — RenameService 全体の組み立て - (collector + context)が必要なため**保留**。Phase 4 で一括化を実装する際に - サービス層テストとして併せて書くのが効率的 -- [ ] 各ターゲットの網羅 — Object / Bone / PoseBone 実施済み。残り(Material, - Mesh 等の ID 型は構造同一のため優先度低、ShapeKey / Strip / File は順次) +- [x] `__tmp_` 二段リネーム: スワップケース → A-2 のサービス層完成時に実施済み + (tests/blender/test_rename_service.py の TestTmpSwap — A↔B 入れ替えで + .001 なし・`__tmp_` 残留なし) +- [x] 各ターゲットの網羅 — Object / Bone / PoseBone / EditBone 実施済み。 + 2026-06-13 発注 #1 で ShapeKey / Strip / Collection / Material(ID 型代表)を + 追加(Phase 2.5 参照)。File はヘッドレス制約により純関数を Tier 1 で固定 **受け入れ条件**: `pytest -m blender` がローカル(Blender 内)で緑 → **達成**(13 passed / 1 xfailed, 0.13s)。MRO バグテストが「バグを記録」した状態で緑 → **達成**。 diff --git a/tests/blender/test_collection_target.py b/tests/blender/test_collection_target.py new file mode 100644 index 0000000..1a1946e --- /dev/null +++ b/tests/blender/test_collection_target.py @@ -0,0 +1,70 @@ +""" +Tier 2: CollectionRenameTarget の名前操作と名前空間。 + +Collection は ID 型なので set_name は core/contracts/base.py:342-343 の ID.rename 経路を +通り、Object と同じ enum 文字列 ("RENAMED_NO_COLLISION" 等) を返す。 +名前空間は bpy.data.collections 全体 (collection.py:24-28)、 +キーは "collections" 固定 (collection.py:18)。 +""" + +import bpy +import pytest + +from structured_renamer.targets.collection import CollectionRenameTarget + +pytestmark = pytest.mark.blender + + +@pytest.fixture() +def two_collections(): + """STDRT_ColA / STDRT_ColB の2コレクション(シーンにはリンクしない)""" + a = bpy.data.collections.new("STDRT_ColA") + b = bpy.data.collections.new("STDRT_ColB") + yield a, b + for col in (a, b): + try: + bpy.data.collections.remove(col) + except ReferenceError: + pass # テスト側で削除済み + + +class TestCollectionRenameTarget: + def test_get_and_set_name(self, two_collections): + a, _ = two_collections + target = CollectionRenameTarget(a, bpy.context) + assert target.get_name() == "STDRT_ColA" + result = target.set_name("STDRT_ColA_Renamed") + assert a.name == "STDRT_ColA_Renamed" + assert result == "RENAMED_NO_COLLISION" # ID.rename 経路 (core/contracts/base.py:342-343) + + def test_create_namespace_sees_all_collections(self, two_collections): + """名前空間は bpy.data.collections 全体 (collection.py:24-28)。 + シーンにリンクしていないコレクションも見える = シーン非依存。 + """ + a, b = two_collections + target = CollectionRenameTarget(a, bpy.context) + namespace = target.create_namespace() + assert a.name in namespace + assert b.name in namespace + + def test_namespace_key_is_global(self, two_collections): + """キーは "collections" 固定 (collection.py:18) — ファイル全体で1名前空間""" + a, _ = two_collections + target = CollectionRenameTarget(a, bpy.context) + assert target.get_namespace_key() == "collections" + + +class TestCollectionCollisionBehavior: + def test_collision_with_mode_never_gets_dot_suffix(self, two_collections): + """characterization (Blender 5.0.1 で検証): Object と同型 — + ID.rename(mode='NEVER') は衝突時に拒否せず、自分に .001 を付けて + 'RENAMED_COLLISION_ADJUSTED' を返す (test_id_targets.py の + test_collision_with_mode_never_gets_dot_suffix と同じ機構の + Collection 版。analyze/03章 §2.4)。 + """ + a, b = two_collections + target = CollectionRenameTarget(a, bpy.context) + result = target.set_name(b.name) # 衝突する名前を要求 + assert a.name == f"{b.name}.001", "拒否ではなく .001 付きで実行される" + assert b.name == "STDRT_ColB", "相手の名前は守られる" + assert result == "RENAMED_COLLISION_ADJUSTED" diff --git a/tests/blender/test_id_targets.py b/tests/blender/test_id_targets.py index 6316b87..5965426 100644 --- a/tests/blender/test_id_targets.py +++ b/tests/blender/test_id_targets.py @@ -8,11 +8,25 @@ import bpy import pytest +from structured_renamer.targets.material import MaterialRenameTarget from structured_renamer.targets.object import ObjectRenameTarget pytestmark = pytest.mark.blender +@pytest.fixture() +def two_materials(): + """STDRT_MatA / STDRT_MatB の2マテリアル""" + a = bpy.data.materials.new("STDRT_MatA") + b = bpy.data.materials.new("STDRT_MatB") + yield a, b + for mat in (a, b): + try: + bpy.data.materials.remove(mat) + except ReferenceError: + pass # テスト側で削除済み + + class TestBlenderNamespaceBehavior: """前提となる Blender 自体の挙動の固定 (research_namespace.md の追試)""" @@ -75,3 +89,29 @@ def test_force_rename_steals_the_name(self, two_objects): assert a.name == "STDRT_B" assert b.name == "STDRT_B.001" assert result == "RENAMED_COLLISION_FORCED" + + +class TestMaterialRenameTarget: + """ID 型代表その2: Material は Object と構造同一 (ID.rename 経路 + + bpy.data.materials 全体の名前空間) であることの最小確認。 + 詳細な衝突挙動は ID 共通機構として TestObjectRenameTarget 側で固定済み。 + """ + + def test_get_and_set_name(self, two_materials): + a, _ = two_materials + target = MaterialRenameTarget(a, bpy.context) + assert target.get_name() == "STDRT_MatA" + result = target.set_name("STDRT_MatA_Renamed") + assert a.name == "STDRT_MatA_Renamed" + assert result == "RENAMED_NO_COLLISION" # Object と同じ ID.rename 経路 + + def test_create_namespace_and_key(self, two_materials): + """名前空間は bpy.data.materials 全体 (material.py:20-24)、 + キーは "materials" 固定 (material.py:14)。 + """ + a, b = two_materials + target = MaterialRenameTarget(a, bpy.context) + namespace = target.create_namespace() + assert a.name in namespace + assert b.name in namespace + assert target.get_namespace_key() == "materials" diff --git a/tests/blender/test_props_uniqueness.py b/tests/blender/test_props_uniqueness.py new file mode 100644 index 0000000..d8ed2b2 --- /dev/null +++ b/tests/blender/test_props_uniqueness.py @@ -0,0 +1,85 @@ +""" +Tier 2: NamingPatternProperty.add_element の表示名ユニーク化 (A-1-4 の回帰テスト)。 + +2026-06-12 修正 (commit 35690ab, 05章 A-1-4): props.py:376,381 の +r"(.+)\\.(\\d+)$" は raw 文字列内のリテラルバックスラッシュで "Name.001" に +永遠にマッチせず、重複表示名が "Name.001.001" と増殖していた。 + +経路が PropertyGroup メソッド内のため、addon_utils.enable で実際に登録された +prefs.patterns 上で検証する (Phase 2.5)。userpref は保存しない +(--factory-startup + 後始末で他テスト・ユーザー設定を汚さない)。 +""" + +import addon_utils +import bpy +import pytest + +from structured_renamer.addon import ADDON_ID + +pytestmark = pytest.mark.blender + + +@pytest.fixture(scope="module") +def addon_prefs(): + """アドオンを有効化して prefs を返す。 + + default_set=True が必須 (False だと prefs エントリが作られず register が + KeyError で一部失敗する — dev-note/plans/2026-06-12-milestone.md の知見)。 + モジュール終了時に必ず disable して素の状態に戻す。 + """ + addon_utils.enable(ADDON_ID, default_set=True) + entry = bpy.context.preferences.addons.get(ADDON_ID) + assert entry is not None, "アドオンの有効化に失敗" + yield entry.preferences + addon_utils.disable(ADDON_ID, default_set=True) + + +@pytest.fixture() +def pattern(addon_prefs): + """テスト専用パターンを prefs.patterns に追加し、終了時に確実に除去する""" + p = addon_prefs.patterns.add() + p.id = "STDRT_props_uniqueness" + yield p + for i, q in enumerate(addon_prefs.patterns): + if q.id == "STDRT_props_uniqueness": + addon_prefs.patterns.remove(i) + break + + +class TestAddElementDisplayNameUniqueness: + def test_first_name_is_kept_as_is(self, pattern): + elem = pattern.add_element("e1", "text", "Name") + assert elem.display_name == "Name" + + def test_duplicate_gets_dot_001(self, pattern): + pattern.add_element("e1", "text", "Name") + elem = pattern.add_element("e2", "text", "Name") + assert elem.display_name == "Name.001" + + def test_third_duplicate_increments_to_002(self, pattern): + pattern.add_element("e1", "text", "Name") + pattern.add_element("e2", "text", "Name") + elem = pattern.add_element("e3", "text", "Name") + assert elem.display_name == "Name.002" + + def test_dotted_duplicate_increments_instead_of_growing(self, pattern): + """A-1-4 の核心: 既に .NNN 付きの名前を重複追加したとき、修正前は + 正規表現が一致せず 'Name.001.001' に増殖していた (props.py:373-376)。 + 正しくは base 'Name' の最大連番 + 1 = 'Name.002'""" + pattern.add_element("e1", "text", "Name") + pattern.add_element("e2", "text", "Name") # → Name.001 + elem = pattern.add_element("e3", "text", "Name.001") + assert elem.display_name == "Name.002" + + def test_increment_starts_after_existing_max_index(self, pattern): + """characterization: 連番は既存の最大値 + 1 から (props.py:378-384)。 + 欠番 (.002〜.004) は再利用されない""" + pattern.add_element("e1", "text", "Name.001") + pattern.add_element("e2", "text", "Name.005") + elem = pattern.add_element("e3", "text", "Name.001") + assert elem.display_name == "Name.006" + + def test_unrelated_names_do_not_collide(self, pattern): + pattern.add_element("e1", "text", "Prefix") + elem = pattern.add_element("e2", "text", "Suffix") + assert elem.display_name == "Suffix" diff --git a/tests/blender/test_shapekey_target.py b/tests/blender/test_shapekey_target.py new file mode 100644 index 0000000..27107f5 --- /dev/null +++ b/tests/blender/test_shapekey_target.py @@ -0,0 +1,81 @@ +""" +Tier 2: ShapeKeyRenameTarget (KeyBlock) の名前操作と名前空間。 + +KeyBlock は ID ではない (bpy.types.ID を継承しない) ため、set_name は +core/contracts/base.py:348-349 の直代入経路を通り、戻り値は常に "RENAMED"。 +名前空間は KeyBlock が属する Key データブロック内の key_blocks 名集合 +(shapekey.py:41-45)、キーは "shape_keys_{Keyデータブロック名}" +(shapekey.py:36-39)。 +""" + +import bpy +import pytest + +from structured_renamer.targets.shapekey import ShapeKeyRenameTarget + +pytestmark = pytest.mark.blender + + +@pytest.fixture() +def shape_key_object(): + """シェイプキー3つ (Basis / STDRT_KeyA / STDRT_KeyB) を持つメッシュオブジェクト。 + + shape_key_add はシーンへのリンク無しでも動く (headless で確認済み)。 + Key データブロックは bpy.data.shape_keys から直接 remove できないが、 + メッシュを消せば道連れで解放される。 + """ + mesh = bpy.data.meshes.new("STDRT_ShapeKeyMesh") + mesh.from_pydata([(0, 0, 0), (1, 0, 0), (0, 1, 0)], [], [(0, 1, 2)]) + obj = bpy.data.objects.new("STDRT_ShapeKeyObj", mesh) + obj.shape_key_add(name="Basis") + obj.shape_key_add(name="STDRT_KeyA") + obj.shape_key_add(name="STDRT_KeyB") + + yield obj, mesh.shape_keys + + try: + bpy.data.objects.remove(obj) + except ReferenceError: + pass + try: + bpy.data.meshes.remove(mesh) + except ReferenceError: + pass + + +class TestShapeKeyRenameTarget: + def test_get_and_set_name(self, shape_key_object): + """KeyBlock は非 ID 型なので直代入経路 (core/contracts/base.py:348-349) を通り 'RENAMED'""" + _, key = shape_key_object + target = ShapeKeyRenameTarget(key.key_blocks["STDRT_KeyA"], bpy.context) + assert target.get_name() == "STDRT_KeyA" + result = target.set_name("STDRT_KeyA_Renamed") + assert result == "RENAMED" + assert key.key_blocks.get("STDRT_KeyA_Renamed") is not None + + def test_create_namespace_is_key_blocks_of_same_key(self, shape_key_object): + """名前空間 = 同じ Key データブロック内の key_blocks 名集合 (shapekey.py:41-45)""" + _, key = shape_key_object + target = ShapeKeyRenameTarget(key.key_blocks["STDRT_KeyA"], bpy.context) + assert target.create_namespace() == {"Basis", "STDRT_KeyA", "STDRT_KeyB"} + + def test_namespace_key_is_per_key_datablock(self, shape_key_object): + """キーは "shape_keys_{Keyデータブロック名}" (shapekey.py:36-39)""" + _, key = shape_key_object + target = ShapeKeyRenameTarget(key.key_blocks["STDRT_KeyA"], bpy.context) + assert target.get_namespace_key() == f"shape_keys_{key.name}" + + +class TestShapeKeyCollisionBehavior: + def test_collision_gets_dot_suffix_silently(self, shape_key_object): + """characterization (Blender 5.0.1 で検証): KeyBlock 名の直代入衝突は + Blender が黙って .001 を付け、戻り値は常に 'RENAMED'。 + ボーンと同型の「拒否が報告されない」経路 (core/contracts/base.py:343-344 の else 側、 + analyze/03章 §2.4 の apply 層報告欠如) をシェイプキーでも固定する。 + """ + _, key = shape_key_object + target = ShapeKeyRenameTarget(key.key_blocks["STDRT_KeyB"], bpy.context) + result = target.set_name("STDRT_KeyA") # 既存名と衝突 + assert result == "RENAMED" # 衝突調整は報告されない + assert target.get_name() == "STDRT_KeyA.001" + assert key.key_blocks["STDRT_KeyA"] is not None # 相手の名前は守られる diff --git a/tests/blender/test_strip_target.py b/tests/blender/test_strip_target.py new file mode 100644 index 0000000..a10dddd --- /dev/null +++ b/tests/blender/test_strip_target.py @@ -0,0 +1,92 @@ +""" +Tier 2: StripRenameTarget (シーケンスエディタのストリップ) の名前操作と名前空間。 + +Blender 5.0.1 では Sequence 系 API は完全に Strip 系へ改名済み +(sequence_editor.strips / strips_all のみ存在し、sequences / sequences_all は無い)。 +strip.py:69-76 の hasattr チェーンはこの差異を吸収し、5.0.1 では +strips_all 分岐に解決される。Strip は ID ではないため set_name は +strip.py:45-53 の直代入経路で、戻り値は "RENAMED"。 +""" + +import bpy +import pytest + +from structured_renamer.targets.strip import StripRenameTarget + +pytestmark = pytest.mark.blender + + +def _strip_collection(seq_editor): + """ストリップ作成用コレクション (5.0.1: strips / 4.4未満: sequences)""" + if hasattr(seq_editor, "strips"): + return seq_editor.strips + return seq_editor.sequences + + +@pytest.fixture() +def two_strips(): + """COLOR エフェクトストリップ2本 (STDRT_StripA / STDRT_StripB)。 + + new_effect の引数名は Blender 5.0 で frame_end → length に変わっている。 + 後始末は sequence_editor_clear() でシーケンスエディタごと破棄する。 + """ + scene = bpy.context.scene + seq_editor = scene.sequence_editor_create() + strips = _strip_collection(seq_editor) + a = strips.new_effect( + name="STDRT_StripA", type="COLOR", channel=1, frame_start=1, length=24 + ) + b = strips.new_effect( + name="STDRT_StripB", type="COLOR", channel=2, frame_start=1, length=24 + ) + + yield a, b + + scene.sequence_editor_clear() + + +class TestStripRenameTarget: + def test_get_and_set_name(self, two_strips): + """Strip は非 ID 型なので直代入経路 (strip.py:45-53) を通り 'RENAMED'""" + a, _ = two_strips + target = StripRenameTarget(a, bpy.context) + assert target.get_name() == "STDRT_StripA" + result = target.set_name("STDRT_StripA_Renamed") + assert result == "RENAMED" + assert a.name == "STDRT_StripA_Renamed" + + def test_create_namespace_sees_all_strips_in_scene(self, two_strips): + """名前空間 = 同一シーン内の全ストリップ名 (strip.py:60-82)。 + NOTE: 5.0.1 では hasattr チェーンが strips_all 分岐 (strip.py:71) に + 解決される(テスト自体は分岐を識別できない。事実はプローブで確認済み)。 + """ + a, b = two_strips + target = StripRenameTarget(a, bpy.context) + namespace = target.create_namespace() + assert a.name in namespace + assert b.name in namespace + assert namespace == {"STDRT_StripA", "STDRT_StripB"} + + def test_namespace_key_is_per_scene(self, two_strips): + """キーは "sequences_{シーン名}" (strip.py:55-58)。 + Strip の id_data はシーンなので _scene にはコンテキスト経由でなく + 所属シーンが入る (strip.py:34-36)。 + """ + a, _ = two_strips + target = StripRenameTarget(a, bpy.context) + assert target.get_namespace_key() == f"sequences_{bpy.context.scene.name}" + + +class TestStripCollisionBehavior: + def test_collision_gets_dot_suffix_silently(self, two_strips): + """characterization (Blender 5.0.1 で検証): ストリップ名の直代入衝突は + Blender が黙って .001 を付け、戻り値は常に 'RENAMED'。 + ボーン・シェイプキーと同型の「拒否が報告されない」経路 + (analyze/03章 §2.4 の apply 層報告欠如) をストリップでも固定する。 + """ + a, b = two_strips + target = StripRenameTarget(b, bpy.context) + result = target.set_name(a.name) # 既存名と衝突 + assert result == "RENAMED" # 衝突調整は報告されない + assert b.name == f"{a.name}.001" + assert a.name == "STDRT_StripA" # 相手の名前は守られる diff --git a/tests/unit/fakes.py b/tests/unit/fakes.py index 27eeafe..accb050 100644 --- a/tests/unit/fakes.py +++ b/tests/unit/fakes.py @@ -6,6 +6,7 @@ テストの fake も名義型を継承せず、必要なメソッドだけを実装する。 """ +from types import SimpleNamespace from typing import Callable, Optional @@ -44,3 +45,116 @@ def get_namespace_key(self) -> str: def create_namespace(self): self.create_namespace_calls += 1 return self._namespace_names + + +# --------------------------------------------------------------------------- +# PropertyGroup 系の代役 (Phase 2.5: facade / factory 用) +# +# PatternFactory は入力を hasattr/getattr でしか読まず (factory.py:103-141)、 +# PatternFacade が prefs から読むのは patterns と get_active_pattern() だけ +# (facade.py:48-49, 121, 150, 179) なので、属性を持つ素朴なオブジェクトで +# PropertyGroup の代役が務まる。 +# --------------------------------------------------------------------------- + + +class FakeNameItem: + """bpy_prop_collection の要素の代役 (.name だけを持つ)""" + + def __init__(self, name: str): + self.name = name + + +class FakeBpyCollection: + """bpy_prop_collection の代役 — `.values()` を持つことが本質。 + + PatternFactory は「.values() を持つ値」を Blender のコレクションとみなし + [item.name for item in value.values()] に変換する (factory.py:138-141)。 + その判定と変換を成立させる最小実装。 + """ + + def __init__(self, names): + self._items = [FakeNameItem(n) for n in names] + + def values(self): + return list(self._items) + + +class FakeElementProperty: + """NamingElementProperty (ui/props.py:73) の代役。 + + 型固有フィールド (items, padding, count_up 等) はキーワード引数で + そのまま属性になる。factory は element_class.config_fields にある名前を + hasattr で探すだけなので、渡さなかったフィールドは単に無視される。 + """ + + def __init__( + self, + element_type: str, + id: str, + order: int = 0, + enabled: bool = True, + separator: str = "UNDERSCORE", + **fields, + ): + self.element_type = element_type + self.id = id + self.order = order + self.enabled = enabled + self.separator = separator + for key, value in fields.items(): + setattr(self, key, value) + + +class FakePatternProperty: + """NamingPatternProperty (ui/props.py:256) の代役。 + + facade / factory が読むのは id / modified / elements / + enable_boundary_detection のみ (facade.py:124-146, factory.py:86-92)。 + """ + + def __init__( + self, + id: str, + elements=(), + modified: bool = True, + enable_boundary_detection: bool = False, + ): + self.id = id + self.elements = list(elements) + self.modified = modified + self.enable_boundary_detection = enable_boundary_detection + + +class FakePrefs: + """StructuredRenamerPreferences (preferences.py:57) の代役。 + 代役対象は patterns (preferences.py:101) と + get_active_pattern (preferences.py:138) のみ。 + + patterns コレクションと get_active_pattern() の最小契約のみ実装。 + 実物の get_active_pattern はインデックス不正時に補正するが、 + facade 側は「None が返り得る」ことしか前提にしない (facade.py:50-51)。 + """ + + def __init__(self, patterns=(), active_index: int = 0): + self.patterns = list(patterns) + self.active_index = active_index + + def get_active_pattern(self): + if 0 <= self.active_index < len(self.patterns): + return self.patterns[self.active_index] + return None + + +def make_fake_context(prefs): + """get_prefs(context) が通る最小のコンテキストを作る。 + + get_prefs は context.preferences.addons[ADDON_ID].preferences を + 辿るだけ (addon.py:76-93)。ADDON_ID はルートパッケージ名なので + ハードコードせず実物から取る。 + """ + from structured_renamer.addon import ADDON_ID + + addon_entry = SimpleNamespace(preferences=prefs) + return SimpleNamespace( + preferences=SimpleNamespace(addons={ADDON_ID: addon_entry}) + ) diff --git a/tests/unit/test_file_target_functions.py b/tests/unit/test_file_target_functions.py new file mode 100644 index 0000000..2f1455b --- /dev/null +++ b/tests/unit/test_file_target_functions.py @@ -0,0 +1,111 @@ +""" +File ターゲットの純関数群(サニタイズ / バリデーション / 拡張子 allowlist)の +単体テスト (targets/file.py:114-178)。 + +FileSelectEntry はヘッドレスでは生成できないため、FileRenameTarget 本体は +Tier 3(手動)領域。ここではファイルシステムに触る前の安全策ロジックだけを +Tier 1 で固定する(発注書 2026-06-13 の代替方針)。 +""" + +import pytest + +from structured_renamer.targets.file import ( + DEFAULT_ALLOWED_EXTENSIONS, + is_extension_allowed, + is_valid_filename, + sanitize_filename, +) + + +class TestSanitizeFilename: + def test_clean_name_is_unchanged(self): + assert sanitize_filename("Texture_Albedo-01") == "Texture_Albedo-01" + + @pytest.mark.parametrize("char", list('<>:"/\\|?*')) + def test_invalid_chars_are_replaced_with_underscore(self, char): + assert sanitize_filename(f"a{char}b") == "a_b" + + def test_control_chars_are_replaced(self): + # \x00-\x1f も無効文字扱い (file.py:24) + assert sanitize_filename("a\x00b\x1fc") == "a_b_c" + + def test_leading_and_trailing_spaces_and_dots_are_stripped(self): + assert sanitize_filename(" .name. ") == "name" + + def test_empty_input_falls_back_to_unnamed(self): + assert sanitize_filename("") == "unnamed" + + def test_input_reduced_to_nothing_falls_back_to_unnamed(self): + # 全部ドットと空白 → strip で空になる経路 (file.py:130-135) + assert sanitize_filename(" .. ") == "unnamed" + + @pytest.mark.parametrize("reserved", ["CON", "con", "Com1", "LPT9", "nul"]) + def test_windows_reserved_names_get_prefixed(self, reserved): + """予約名は大文字小文字を問わずアンダースコアが前置される + (file.py:137-139)""" + assert sanitize_filename(reserved) == f"_{reserved}" + + def test_reserved_name_with_suffix_is_not_reserved(self): + # "CON_backup" は予約名ではない(完全一致判定) + assert sanitize_filename("CON_backup") == "CON_backup" + + +class TestIsValidFilename: + def test_normal_name_is_valid(self): + ok, msg = is_valid_filename("render_final") + assert ok is True + assert msg == "" + + def test_empty_name_is_invalid(self): + ok, msg = is_valid_filename("") + assert ok is False + assert "empty" in msg + + def test_invalid_char_is_rejected(self): + ok, _ = is_valid_filename("a/b") + assert ok is False + + def test_reserved_name_is_rejected_case_insensitively(self): + ok, _ = is_valid_filename("aux") + assert ok is False + + def test_overlong_name_is_rejected(self): + ok, msg = is_valid_filename("x" * 256) + assert ok is False + assert "long" in msg + + def test_255_chars_is_still_valid(self): + # 境界値: 拒否は「255 文字超」(file.py:160) + ok, _ = is_valid_filename("x" * 255) + assert ok is True + + def test_validation_does_not_sanitize(self): + """characterization: is_valid_filename は判定のみで変換しない。 + サニタイズは set_name 側が別途行う (file.py:282-287)""" + ok, _ = is_valid_filename("a FakePatternProperty: + """validate() が通る最小構成のパターン PropertyGroup 代役""" + return FakePatternProperty( + id=pattern_id, + elements=[ + FakeElementProperty( + "text", id="base", order=0, items=FakeBpyCollection(["Foo"]) + ), + FakeElementProperty( + "numeric_counter", + id="counter", + order=1, + padding=2, + count_up=True, + ), + ], + modified=modified, + ) + + +def make_facade(*pattern_props, active_index: int = 0): + """fake prefs 入りの context で facade を作るヘルパー""" + prefs = FakePrefs(list(pattern_props), active_index=active_index) + return PatternFacade(make_fake_context(prefs)), prefs + + +class TestInitialization: + def test_empty_cache_triggers_synchronization(self): + """キャッシュが空なら __init__ が prefs から同期する (facade.py:38-40)""" + facade, _ = make_facade(make_pattern_prop("pat-a")) + pattern = facade.get_pattern("pat-a") + assert pattern is not None + assert pattern.id == "pat-a" + + def test_populated_cache_skips_synchronization(self): + """characterization: キャッシュが非空だと __init__ は同期しない + (facade.py:38-40)。先行 facade の後に prefs へ追加されたパターンは、 + 明示的に synchronize_patterns() するまで見えない""" + facade1, prefs = make_facade(make_pattern_prop("pat-a")) + assert facade1.get_pattern("pat-a") is not None + + prefs.patterns.append(make_pattern_prop("pat-b")) + facade2 = PatternFacade(make_fake_context(prefs)) + assert facade2.get_pattern("pat-b") is None + + def test_construction_without_addon_prefs_raises(self): + """characterization: アドオン prefs が引けない context では、空キャッシュ + からの構築が get_prefs の KeyError (addon.py:93) で**そのまま死ぬ**。 + synchronize_patterns に prefs 取得の例外処理がない (facade.py:121)""" + from types import SimpleNamespace + + ctx = SimpleNamespace(preferences=SimpleNamespace(addons={})) + with pytest.raises(KeyError): + PatternFacade(ctx) + + def test_none_context_constructs_with_empty_cache(self): + """characterization: context が falsy の場合、同期は warning ログだけで + スキップされ (facade.py:112-114)、空キャッシュの facade が出来る""" + facade = PatternFacade(None) + assert facade.get_all_patterns() == [] + + +class TestGetActivePattern: + def test_returns_cached_pattern_for_active_prefs_entry(self): + facade, _ = make_facade( + make_pattern_prop("pat-a"), make_pattern_prop("pat-b"), active_index=1 + ) + active = facade.get_active_pattern() + assert active is not None + assert active.id == "pat-b" + + def test_returns_none_when_no_active_pattern(self): + # patterns 空 = 実物 prefs が None を返す唯一の構成 (preferences.py:138-149 + # は patterns 非空なら index を 0 に補正するため、範囲外 index では再現しない) + facade, _ = make_facade() + assert facade.get_active_pattern() is None + + def test_returns_none_when_active_id_is_not_cached(self): + """characterization: アクティブ ID がキャッシュに無い場合、KeyError は + error ログ + None に握りつぶされる (facade.py:53-55)。呼び出し側は + 「未同期」と「アクティブ無し」を区別できない""" + facade, _ = make_facade(make_pattern_prop("pat-a")) + facade.delete_pattern("pat-a") # prefs には残るがキャッシュから消す + assert facade.get_active_pattern() is None + + +class TestCrud: + def test_create_pattern_registers_in_cache(self): + facade, _ = make_facade(make_pattern_prop("pat-a")) + created = facade.create_pattern(make_pattern_prop("pat-new")) + assert created.id == "pat-new" + assert facade.get_pattern("pat-new") is created + + def test_get_pattern_missing_returns_none(self): + facade, _ = make_facade(make_pattern_prop("pat-a")) + assert facade.get_pattern("ghost") is None + + def test_delete_pattern_removes_from_cache(self): + facade, _ = make_facade(make_pattern_prop("pat-a")) + facade.delete_pattern("pat-a") + assert facade.get_pattern("pat-a") is None + + def test_delete_missing_pattern_is_silent(self): + """存在しない ID の削除は KeyError を握りつぶす (facade.py:93-98)""" + facade, _ = make_facade(make_pattern_prop("pat-a")) + facade.delete_pattern("ghost") # 例外にならない + + def test_get_all_patterns_returns_everything(self): + facade, _ = make_facade( + make_pattern_prop("pat-a"), make_pattern_prop("pat-b") + ) + assert {p.id for p in facade.get_all_patterns()} == {"pat-a", "pat-b"} + + +class TestUpdatePattern: + def test_update_replaces_cache_entry_and_clears_modified_flag(self): + """update_pattern は作り直し (同一性が変わる) + modified リセット + (facade.py:77-89)""" + facade, prefs = make_facade(make_pattern_prop("pat-a")) + before = facade.get_pattern("pat-a") + + prop = prefs.patterns[0] + prop.modified = True + facade.update_pattern(prop) + + after = facade.get_pattern("pat-a") + assert after is not before + assert prop.modified is False + + +class TestSynchronizePatterns: + def test_new_pattern_in_prefs_is_added(self): + facade, prefs = make_facade(make_pattern_prop("pat-a")) + prefs.patterns.append(make_pattern_prop("pat-b")) + facade.synchronize_patterns() + assert facade.get_pattern("pat-b") is not None + + def test_modified_pattern_is_recreated(self): + facade, prefs = make_facade(make_pattern_prop("pat-a")) + before = facade.get_pattern("pat-a") + prefs.patterns[0].modified = True + facade.synchronize_patterns() + assert facade.get_pattern("pat-a") is not before + + def test_unmodified_pattern_is_left_untouched(self): + """modified=False かつキャッシュ済みなら再生成しない (facade.py:141-146)""" + facade, _ = make_facade(make_pattern_prop("pat-a")) + before = facade.get_pattern("pat-a") # 初回同期で modified=False 済み + facade.synchronize_patterns() + assert facade.get_pattern("pat-a") is before + + def test_pattern_removed_from_prefs_is_purged(self): + facade, prefs = make_facade( + make_pattern_prop("pat-a"), make_pattern_prop("pat-b") + ) + del prefs.patterns[1] + facade.synchronize_patterns() + assert facade.get_pattern("pat-b") is None + assert facade.get_pattern("pat-a") is not None + + def test_broken_pattern_is_skipped_and_others_survive(self): + """characterization: 同期中の例外はパターン単位で握りつぶされ + (facade.py:129-133)、他のパターンの同期は続行される。 + items 空の text 要素は ValueError を出す経路 (registry.py:156-157)""" + broken = FakePatternProperty( + id="pat-broken", + elements=[ + FakeElementProperty( + "text", id="base", order=0, items=FakeBpyCollection([]) + ) + ], + ) + facade, _ = make_facade(broken, make_pattern_prop("pat-ok")) + assert facade.get_pattern("pat-ok") is not None + assert facade.get_pattern("pat-broken") is None + + +class TestForceResync: + def test_cache_is_rebuilt_from_prefs(self): + """force_resync はキャッシュ全捨て + prefs 全再生成 (facade.py:168-192)。 + prefs に無い stale エントリは消え、全パターンの同一性が変わる""" + facade, _ = make_facade(make_pattern_prop("pat-a")) + before = facade.get_pattern("pat-a") + facade.create_pattern(make_pattern_prop("pat-stale")) # prefs に無い + + facade.force_resync() + + assert facade.get_pattern("pat-stale") is None + after = facade.get_pattern("pat-a") + assert after is not None + assert after is not before + + def test_broken_pattern_is_skipped_and_others_survive(self): + """再生成中の例外もパターン単位で握りつぶされる (facade.py:184-188)""" + facade, prefs = make_facade(make_pattern_prop("pat-a")) + prefs.patterns.append( + FakePatternProperty( + id="pat-broken", + elements=[ + FakeElementProperty( + "text", id="base", order=0, items=FakeBpyCollection([]) + ) + ], + ) + ) + facade.force_resync() + assert facade.get_pattern("pat-a") is not None + assert facade.get_pattern("pat-broken") is None + + def test_clear_cache_empties_everything(self): + facade, _ = make_facade(make_pattern_prop("pat-a")) + facade.clear_cache() + assert facade.get_all_patterns() == [] diff --git a/tests/unit/test_pattern_factory.py b/tests/unit/test_pattern_factory.py new file mode 100644 index 0000000..de88219 --- /dev/null +++ b/tests/unit/test_pattern_factory.py @@ -0,0 +1,200 @@ +# pyright: reportArgumentType=false +# (製品コードの流儀に合わせ PropertyGroup をダックタイピング代役で渡すため) +""" +PatternFactory(PropertyGroup → ElementConfig → NamingPattern 変換)の単体テスト。 + +Phase 2.5: PropertyGroup を入力に取るため Tier 1 から外されていた領域 +(dev-note/testing/01-phase-plan.md Phase 2 の判定)。factory は入力を +hasattr/getattr でしか読まない (factory.py:103-141) ため、fakes.py の +ダックタイピング代役で実 Blender なしに固定する。 +""" + +import pytest + +from structured_renamer.core.element.registry import ElementRegistry +from structured_renamer.core.pattern.factory import PatternFactory +from structured_renamer.elements.counter_element import BlenderCounter, NumericCounter +from structured_renamer.elements.text_element import TextElement + +from fakes import FakeBpyCollection, FakeElementProperty, FakePatternProperty + +# factory が実際に解決する本番のタイプ名で登録する (製品では各モジュールの +# register() が行う)。シングルトンのため後始末必須 +_REAL_TYPES = { + "text": TextElement, + "numeric_counter": NumericCounter, + "blender_counter": BlenderCounter, +} + + +@pytest.fixture() +def factory(): + reg = ElementRegistry.get_instance() + for name, cls in _REAL_TYPES.items(): + reg.register_element_type(name, cls) + yield PatternFactory(reg) + # シングルトンのため後始末(他テストへの漏れ防止) + for name in _REAL_TYPES: + reg._element_types.pop(name, None) + + +def text_prop(id="base", order=0, items=("Foo",), **kw) -> FakeElementProperty: + return FakeElementProperty( + "text", id=id, order=order, items=FakeBpyCollection(items), **kw + ) + + +def counter_prop(id="counter", order=1, **kw) -> FakeElementProperty: + return FakeElementProperty( + "numeric_counter", id=id, order=order, padding=2, count_up=True, **kw + ) + + +def valid_pattern_prop(pattern_id="pat", **kw) -> FakePatternProperty: + """validate() が通る最小構成 (text + numeric_counter。blender_counter は + factory が自動注入する)""" + return FakePatternProperty( + id=pattern_id, elements=[text_prop(), counter_prop()], **kw + ) + + +class TestConvertToElementConfig: + def test_required_fields_are_mapped(self, factory): + elem = text_prop(id="base", order=2, enabled=False, separator="DASH") + config = factory._convert_to_element_config(elem, with_boundary=True) + assert config.type == "text" + assert config.id == "base" + assert config.order == 2 + assert config.enabled is False + assert config.separator == "DASH" + assert config.with_boundary is True + + def test_bpy_collection_is_converted_to_name_list(self, factory): + """`.values()` を持つコレクションは [item.name, ...] に変換される + (factory.py:138-141)""" + elem = text_prop(items=("A", "B")) + config = factory._convert_to_element_config(elem, with_boundary=True) + assert config.items == ["A", "B"] + + def test_plain_list_is_passed_through(self, factory): + """`.values()` を持たない値はそのまま渡る""" + elem = FakeElementProperty("text", id="base", order=0, items=["A", "B"]) + config = factory._convert_to_element_config(elem, with_boundary=True) + assert config.items == ["A", "B"] + + def test_unknown_element_type_returns_none(self, factory): + """characterization: 未登録タイプは error ログ + None + (factory.py:111-113)。例外にならず、呼び出し側で黙って脱落する""" + elem = FakeElementProperty("ghost_type", id="x", order=0) + assert factory._convert_to_element_config(elem, with_boundary=True) is None + + +class TestBoundaryDetection: + """with_boundary の決定はパターン単位の一括判定 (factory.py:86-90): + 「境界検出オフ かつ NONE セパレータ要素あり」のときだけ全要素オフ""" + + def _boundaries(self, factory, pattern_prop): + return [ + c.with_boundary for c in factory._create_elements_config(pattern_prop) + ] + + def test_disabled_detection_with_none_separator_turns_all_off(self, factory): + prop = FakePatternProperty( + id="p", + elements=[text_prop(separator="NONE"), counter_prop()], + enable_boundary_detection=False, + ) + assert self._boundaries(factory, prop) == [False, False] + + def test_disabled_detection_without_none_separator_stays_on(self, factory): + prop = FakePatternProperty( + id="p", + elements=[text_prop(), counter_prop()], + enable_boundary_detection=False, + ) + assert self._boundaries(factory, prop) == [True, True] + + def test_enabled_detection_with_none_separator_stays_on(self, factory): + prop = FakePatternProperty( + id="p", + elements=[text_prop(separator="NONE"), counter_prop()], + enable_boundary_detection=True, + ) + assert self._boundaries(factory, prop) == [True, True] + + +class TestCreatePattern: + def test_returns_pattern_with_sorted_elements(self, factory): + """要素は order でソートされる (factory.py:78)""" + prop = FakePatternProperty( + id="pat-sort", + elements=[text_prop(id="late", order=5), counter_prop(id="early", order=0)], + ) + pattern = factory.create_pattern(prop) + assert pattern.id == "pat-sort" + assert [e.id for e in pattern.elements] == [ + "early", + "late", + "blender_counter", # 自動注入分は order=1000 で最後 + ] + + def test_blender_counter_is_auto_injected(self, factory): + """BlenderCounter が無いパターンには必ず注入される (factory.py:71-76)。 + これは parse_name の必須不変条件 (model.py:78-83 — 欠けると + StopIteration) を満たすため""" + pattern = factory.create_pattern(valid_pattern_prop()) + injected = [e for e in pattern.elements if e.element_type == "blender_counter"] + assert len(injected) == 1 + assert injected[0].order == 1000 + assert injected[0].enabled is False + + def test_existing_blender_counter_is_not_duplicated(self, factory): + prop = FakePatternProperty( + id="pat-bc", + elements=[ + text_prop(), + counter_prop(), + FakeElementProperty("blender_counter", id="bc", order=999, padding=3), + ], + ) + pattern = factory.create_pattern(prop) + assert ( + len([e for e in pattern.elements if e.element_type == "blender_counter"]) + == 1 + ) + + def test_unknown_type_element_is_silently_dropped(self, factory): + """characterization: 未知タイプの要素は None 化 (factory.py:111-113) + → create_element(None) の TypeError が握りつぶされ (factory.py:68-69)、 + パターンからただ消える。ユーザーへの通知経路はない""" + prop = FakePatternProperty( + id="pat-drop", + elements=[ + text_prop(), + counter_prop(), + FakeElementProperty("ghost_type", id="ghost", order=2), + ], + ) + pattern = factory.create_pattern(prop) + assert "ghost" not in [e.id for e in pattern.elements] + + def test_pattern_validation_errors_are_swallowed(self, factory): + """characterization: pattern.validate() のエラーは error ログのみで + 握りつぶされ、欠陥パターンがそのまま返る (factory.py:49-53 — + raise はコメントアウト)。numeric_counter 欠落はここを素通りし、 + 後段 parse_name の StopIteration (model.py:78-83 FIXME) に到達し得る""" + prop = FakePatternProperty(id="pat-broken", elements=[text_prop()]) + pattern = factory.create_pattern(prop) + assert pattern.validate(), "validate() はエラーを返す構成のはず" + assert pattern.id == "pat-broken" # それでもパターンは返る + + def test_invalid_element_config_raises_valueerror(self, factory): + """characterization: 要素レベルの設定不備 (items 空) は + registry.create_element の ValueError (registry.py:156-157) が + factory.py:68 の except (KeyError, TypeError) に**捕まらず**素通しで + 上がる。パターンレベル validate (上のテスト) との非対称""" + prop = FakePatternProperty( + id="pat-invalid", elements=[text_prop(items=()), counter_prop()] + ) + with pytest.raises(ValueError): + factory.create_pattern(prop)