| |
| """Tests for ``easy_ImageBatch`` insert modes and schema. |
| |
| The node imports ``torch`` at module load, but its *planners* |
| (``plan_offset_placements`` for insert placement, ``plan_source_base_fill`` |
| for the insert-over-source background) are pure Python and never touch torch |
| — they just compute which frame lands at which output index. CI installs |
| only ``pytest`` / ``aiohttp`` / ``Pillow`` (no torch), so we stub the module |
| to import the node torch-free and test the placement logic directly. |
| """ |
| from __future__ import annotations |
|
|
| import sys |
| import types |
|
|
| import pytest |
|
|
| |
| |
| sys.modules.setdefault("torch", types.ModuleType("torch")) |
|
|
| |
| |
| |
| |
| |
| _torch = sys.modules["torch"] |
| _FAKE_TORCH = not hasattr(_torch, "__version__") |
| if _FAKE_TORCH: |
| class _FakeTensor: |
| """Minimal stand-in: a list of opaque per-frame tags + an H/W/C shape.""" |
|
|
| def __init__(self, data, hwc=(2, 2, 3)): |
| self._data = list(data) |
| self._hwc = tuple(hwc) |
| self.device = "cpu" |
| self.dtype = "float32" |
|
|
| @property |
| def shape(self): |
| return (len(self._data),) + self._hwc |
|
|
| def __len__(self): |
| return len(self._data) |
|
|
| def __getitem__(self, key): |
| if isinstance(key, int): |
| return self._data[key] |
| if isinstance(key, slice): |
| return _FakeTensor(self._data[key], self._hwc) |
| if isinstance(key, (list, tuple)): |
| return _FakeTensor([self._data[i] for i in key], self._hwc) |
| raise TypeError(key) |
|
|
| def __setitem__(self, key, value): |
| if isinstance(key, int): |
| self._data[key] = value |
| else: |
| raise TypeError(key) |
|
|
| def __rsub__(self, other): |
| return self |
|
|
| def _fake_full(shape, fill_value, device=None, dtype=None): |
| return _FakeTensor([("placeholder", fill_value)] * shape[0], shape[1:]) |
|
|
| def _fake_ones(shape, device=None, dtype=None): |
| return _FakeTensor([1.0] * shape[0], shape[1:]) |
|
|
| _torch.float32 = "float32" |
| _torch.full = _fake_full |
| _torch.ones = _fake_ones |
| else: |
| _FakeTensor = None |
|
|
| _GRAY = ("placeholder", 0.5) |
|
|
|
|
| def _source(n): |
| """A fake source_batch whose frame k (0-based) is the tag ``f"src{k+1}"``.""" |
| return _FakeTensor([f"src{k + 1}" for k in range(n)]) |
|
|
|
|
| requires_fake_torch = pytest.mark.skipif( |
| not _FAKE_TORCH, reason="behaviour tests rely on the fake-tensor stub" |
| ) |
|
|
| from k_easy_image_batch import ( |
| easy_ImageBatch, |
| parse_frame_tokens, |
| plan_offset_placements, |
| plan_slot_overwrites, |
| plan_source_base_fill, |
| ) |
|
|
|
|
| def _frames(plan): |
| """VFX frame numbers placed, in order.""" |
| return [vfx for _src, _out, vfx in plan.placements] |
|
|
|
|
| def test_basic_offset_maps_packed_frames_to_listed_positions(): |
| |
| plan = plan_offset_placements([1, 10, 19, 30, 40], source_count=5, total_frames=41, cut_start_frame=1) |
| assert plan.placements == [ |
| (0, 0, 1), |
| (1, 9, 10), |
| (2, 18, 19), |
| (3, 29, 30), |
| (4, 39, 40), |
| ] |
| assert plan.outside_cut == [] |
| assert plan.unused_source == 0 |
| assert plan.missing_positions == [] |
|
|
|
|
| def test_pairing_is_by_ascending_position_not_list_order(): |
| |
| |
| plan = plan_offset_placements([40, 1, 19], source_count=3, total_frames=41, cut_start_frame=1) |
| assert _frames(plan) == [1, 19, 40] |
| assert [src for src, _out, _vfx in plan.placements] == [0, 1, 2] |
|
|
|
|
| def test_more_source_frames_than_positions_reports_unused(): |
| plan = plan_offset_placements([1, 10, 19], source_count=5, total_frames=41, cut_start_frame=1) |
| assert _frames(plan) == [1, 10, 19] |
| assert plan.unused_source == 2 |
| assert plan.missing_positions == [] |
|
|
|
|
| def test_fewer_source_frames_than_positions_reports_missing(): |
| plan = plan_offset_placements([1, 10, 19, 30], source_count=2, total_frames=41, cut_start_frame=1) |
| assert _frames(plan) == [1, 10] |
| assert plan.unused_source == 0 |
| assert plan.missing_positions == [19, 30] |
|
|
|
|
| def test_positions_outside_cut_window_are_dropped(): |
| |
| |
| plan = plan_offset_placements([1, 15, 40], source_count=3, total_frames=20, cut_start_frame=10) |
| assert _frames(plan) == [15] |
| assert plan.outside_cut == [1, 40] |
|
|
|
|
| def test_duplicate_positions_are_deduped(): |
| plan = plan_offset_placements([10, 10, 19], source_count=3, total_frames=41, cut_start_frame=1) |
| assert _frames(plan) == [10, 19] |
|
|
|
|
| def test_empty_position_list_places_nothing(): |
| plan = plan_offset_placements([], source_count=5, total_frames=41, cut_start_frame=1) |
| assert plan.placements == [] |
| assert plan.unused_source == 5 |
|
|
|
|
| def test_cut_start_offsets_output_indices(): |
| |
| plan = plan_offset_placements([41, 63], source_count=2, total_frames=81, cut_start_frame=41) |
| assert plan.placements == [(0, 0, 41), (1, 22, 63)] |
|
|
|
|
| def test_source_base_fill_full_window_maps_one_to_one(): |
| |
| placements, fallback = plan_source_base_fill(total_frames=5, cut_start_frame=1, base_len=10) |
| assert placements == [(0, 0), (1, 1), (2, 2), (3, 3), (4, 4)] |
| assert fallback == 0 |
|
|
|
|
| def test_source_base_fill_offsets_with_cut_start(): |
| |
| placements, fallback = plan_source_base_fill(total_frames=3, cut_start_frame=41, base_len=121) |
| assert placements == [(0, 40), (1, 41), (2, 42)] |
| assert fallback == 0 |
|
|
|
|
| def test_source_base_fill_beyond_source_falls_back_to_placeholder(): |
| |
| placements, fallback = plan_source_base_fill(total_frames=5, cut_start_frame=1, base_len=2) |
| assert placements == [(0, 0), (1, 1)] |
| assert fallback == 3 |
|
|
|
|
| def test_source_base_fill_cut_starts_past_source_end(): |
| |
| placements, fallback = plan_source_base_fill(total_frames=4, cut_start_frame=50, base_len=10) |
| assert placements == [] |
| assert fallback == 4 |
|
|
|
|
| def test_slot_overwrites_basic_placement(): |
| |
| placements, outside = plan_slot_overwrites([(0, 4)], total_frames=24, cut_start_frame=1) |
| assert placements == [(0, 3, 4)] |
| assert outside == [] |
|
|
|
|
| def test_slot_overwrites_higher_slot_wins_collision(): |
| |
| |
| placements, outside = plan_slot_overwrites( |
| [(0, 10), (2, 10)], total_frames=24, cut_start_frame=1 |
| ) |
| assert placements == [(2, 9, 10)] |
| assert outside == [] |
|
|
|
|
| def test_slot_overwrites_higher_slot_wins_regardless_of_input_order(): |
| |
| placements, _ = plan_slot_overwrites( |
| [(2, 10), (0, 10)], total_frames=24, cut_start_frame=1 |
| ) |
| assert placements == [(2, 9, 10)] |
|
|
|
|
| def test_slot_overwrites_outside_cut_reported(): |
| |
| placements, outside = plan_slot_overwrites( |
| [(0, 2), (1, 15)], total_frames=20, cut_start_frame=10 |
| ) |
| assert placements == [(1, 5, 15)] |
| assert outside == [2] |
|
|
|
|
| def test_slot_overwrites_cut_start_offsets_output_indices(): |
| placements, _ = plan_slot_overwrites( |
| [(0, 41), (1, 63)], total_frames=81, cut_start_frame=41 |
| ) |
| assert placements == [(0, 0, 41), (1, 22, 63)] |
|
|
|
|
| def test_slot_overwrites_sorted_by_output_index(): |
| |
| placements, _ = plan_slot_overwrites( |
| [(0, 20), (1, 5)], total_frames=24, cut_start_frame=1 |
| ) |
| assert [out for _s, out, _v in placements] == [4, 19] |
|
|
|
|
| def test_slot_overwrites_empty_input_places_nothing(): |
| placements, outside = plan_slot_overwrites([], total_frames=10, cut_start_frame=1) |
| assert placements == [] |
| assert outside == [] |
|
|
|
|
| def test_input_types_exposes_keyframes_insert_with_tooltip(): |
| spec = easy_ImageBatch.INPUT_TYPES() |
| assert "keyframes_insert" in spec["optional"], "insert-mode input must be declared" |
| |
| |
| assert "keyframe_batch" in spec["optional"] |
| assert "deprecated" in spec["optional"]["keyframe_batch"][1]["tooltip"].lower() |
| entry = spec["optional"]["keyframes_insert"] |
| assert entry[0] == "IMAGE" |
| tooltip = entry[1]["tooltip"].lower() |
| assert "insert" in tooltip |
| assert "source_frames" in tooltip |
|
|
|
|
| def test_input_types_exposes_width_height_fallback_at_end(): |
| spec = easy_ImageBatch.INPUT_TYPES() |
| |
| |
| |
| for key in ("width", "height"): |
| assert key in spec["optional"], f"{key} fallback widget must be optional" |
| assert key not in spec["required"] |
| assert spec["optional"][key][0] == "INT" |
| assert spec["optional"][key][1]["default"] == 512 |
| |
| assert list(spec["optional"])[-2:] == ["width", "height"] |
|
|
|
|
| def test_registration_exports_unchanged(): |
| from k_easy_image_batch import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS |
|
|
| assert NODE_CLASS_MAPPINGS["easy_ImageBatch"] is easy_ImageBatch |
| assert NODE_DISPLAY_NAME_MAPPINGS["easy_ImageBatch"] == "Easy Image Batch (Koolook)" |
|
|
|
|
| |
|
|
|
|
| @requires_fake_torch |
| def test_select_slot_overwrites_list_pick_at_same_frame(): |
| |
| node = easy_ImageBatch() |
| image_batch, _alpha, selected, frames = node.create_batch( |
| total_frames=24, cut_start_frame=1, placeholder_color="Gray", |
| invert_alpha=False, source_frames="5", |
| image1_frame=5, source_batch=_source(24), image1=_FakeTensor(["slotA"]), |
| ) |
| assert image_batch[4] == "slotA" |
| assert frames == "5" |
| assert len(selected) == 1 |
|
|
|
|
| @requires_fake_torch |
| def test_select_higher_slot_wins_at_same_frame(): |
| |
| node = easy_ImageBatch() |
| image_batch, _alpha, _sel, _frames = node.create_batch( |
| total_frames=24, cut_start_frame=1, placeholder_color="Gray", |
| invert_alpha=False, source_frames="", |
| image1_frame=10, image1=_FakeTensor(["slot1"]), |
| image2=_FakeTensor(["slot2"]), image2_frame=10, |
| ) |
| assert image_batch[9] == "slot2" |
|
|
|
|
| @requires_fake_torch |
| def test_insert_slot_composites_on_top_of_inserts(): |
| |
| node = easy_ImageBatch() |
| image_batch, _alpha, _sel, _frames = node.create_batch( |
| total_frames=24, cut_start_frame=1, placeholder_color="Gray", |
| invert_alpha=False, source_frames="5 7 20", |
| image1_frame=5, keyframes_insert=_FakeTensor(["ins1", "ins2", "ins3"]), |
| image1=_FakeTensor(["slotA"]), |
| ) |
| assert image_batch[4] == "slotA" |
| assert image_batch[6] == "ins2" |
| assert image_batch[19] == "ins3" |
|
|
|
|
| @requires_fake_torch |
| def test_select_empty_list_passes_source_through_with_no_phantom_picks(): |
| |
| |
| node = easy_ImageBatch() |
| image_batch, _alpha, selected, frames = node.create_batch( |
| total_frames=24, cut_start_frame=1, placeholder_color="Gray", |
| invert_alpha=False, source_frames="", |
| image1_frame=5, source_batch=_source(24), |
| image2_frame=9, image3_frame=13, image4_frame=17, |
| ) |
| assert [image_batch[i] for i in range(24)] == [f"src{k + 1}" for k in range(24)] |
| assert frames == "" |
| assert len(selected) == 0 |
|
|
|
|
| @requires_fake_torch |
| def test_select_nonempty_list_ignores_unwired_slot_defaults(): |
| |
| |
| node = easy_ImageBatch() |
| image_batch, _alpha, _sel, frames = node.create_batch( |
| total_frames=24, cut_start_frame=1, placeholder_color="Gray", |
| invert_alpha=False, source_frames="5 8 20", |
| image1_frame=5, source_batch=_source(24), |
| image2_frame=9, image3_frame=13, image4_frame=17, |
| ) |
| assert frames == "5, 8, 20" |
| assert image_batch[4] == "src5" |
| assert image_batch[7] == "src8" |
| assert image_batch[19] == "src20" |
| assert image_batch[8] == _GRAY |
|
|
|
|
| @requires_fake_torch |
| def test_selected_image_batch_is_picks_plus_slots_slots_win_overlap(): |
| |
| |
| node = easy_ImageBatch() |
| _img, _alpha, selected, frames = node.create_batch( |
| total_frames=24, cut_start_frame=1, placeholder_color="Gray", |
| invert_alpha=False, source_frames="5 8", |
| image1_frame=5, source_batch=_source(24), |
| image1=_FakeTensor(["slotA"]), |
| image2=_FakeTensor(["slotB"]), image2_frame=12, |
| ) |
| assert frames == "5, 8, 12" |
| assert [selected[i] for i in range(len(selected))] == ["slotA", "src8", "slotB"] |
|
|
|
|
| @requires_fake_torch |
| def test_selected_image_batch_includes_inserts_and_slots_in_insert_mode(): |
| |
| node = easy_ImageBatch() |
| _img, _alpha, selected, frames = node.create_batch( |
| total_frames=24, cut_start_frame=1, placeholder_color="Gray", |
| invert_alpha=False, source_frames="5 7 20", |
| image1_frame=4, keyframes_insert=_FakeTensor(["ins1", "ins2", "ins3"]), |
| image1=_FakeTensor(["slotA"]), |
| ) |
| assert frames == "4, 5, 7, 20" |
| assert [selected[i] for i in range(len(selected))] == ["slotA", "ins1", "ins2", "ins3"] |
|
|
|
|
| |
|
|
|
|
| def test_parse_frame_tokens_expands_inclusive_ranges(): |
| values, bad = parse_frame_tokens("1-5, 7, 9, 14-17") |
| assert values == [1, 2, 3, 4, 5, 7, 9, 14, 15, 16, 17] |
| assert bad == [] |
|
|
|
|
| def test_parse_frame_tokens_mixed_separators(): |
| values, bad = parse_frame_tokens("1-3 7\n10") |
| assert values == [1, 2, 3, 7, 10] |
| assert bad == [] |
|
|
|
|
| def test_parse_frame_tokens_single_value_range(): |
| values, bad = parse_frame_tokens("7-7") |
| assert values == [7] |
| assert bad == [] |
|
|
|
|
| def test_parse_frame_tokens_descending_range_is_bad(): |
| values, bad = parse_frame_tokens("5-1, 8") |
| assert values == [8] |
| assert bad == ["5-1"] |
|
|
|
|
| def test_parse_frame_tokens_non_integer_tokens_are_bad(): |
| values, bad = parse_frame_tokens("3, foo, 5-x, 9") |
| assert values == [3, 9] |
| assert bad == ["foo", "5-x"] |
|
|
|
|
| @requires_fake_torch |
| def test_select_accepts_range_syntax_in_list(): |
| node = easy_ImageBatch() |
| _img, _alpha, _sel, frames = node.create_batch( |
| total_frames=24, cut_start_frame=1, placeholder_color="Gray", |
| invert_alpha=False, source_frames="1-3, 5", |
| image1_frame=4, source_batch=_source(24), |
| ) |
| assert frames == "1, 2, 3, 5" |
|
|
|
|
| @requires_fake_torch |
| def test_insert_accepts_range_syntax_in_positions(): |
| node = easy_ImageBatch() |
| _img, _alpha, _sel, frames = node.create_batch( |
| total_frames=24, cut_start_frame=1, placeholder_color="Gray", |
| invert_alpha=False, source_frames="5-7", |
| image1_frame=4, keyframes_insert=_FakeTensor(["a", "b", "c"]), |
| ) |
| assert frames == "5, 6, 7" |
|
|
|
|
| |
|
|
|
|
| @requires_fake_torch |
| def test_passthrough_shorter_source_keeps_covered_and_selects_gap(): |
| |
| |
| node = easy_ImageBatch() |
| image_batch, alpha, selected, frames = node.create_batch( |
| total_frames=24, cut_start_frame=1, placeholder_color="Gray", |
| invert_alpha=False, source_frames="", |
| image1_frame=4, source_batch=_source(17), |
| ) |
| |
| assert image_batch[16] == "src17" |
| assert image_batch[17] == _GRAY |
| |
| assert alpha[0] == 0.0 and alpha[16] == 0.0 |
| assert alpha[17] == 1.0 and alpha[23] == 1.0 |
| |
| assert frames == "18, 19, 20, 21, 22, 23, 24" |
| assert len(selected) == 7 |
| assert selected[0] == _GRAY |
|
|
|
|
| @requires_fake_torch |
| def test_passthrough_full_coverage_selects_nothing(): |
| |
| node = easy_ImageBatch() |
| image_batch, alpha, selected, frames = node.create_batch( |
| total_frames=24, cut_start_frame=1, placeholder_color="Gray", |
| invert_alpha=False, source_frames="", |
| image1_frame=4, source_batch=_source(24), |
| ) |
| assert [image_batch[i] for i in range(24)] == [f"src{k + 1}" for k in range(24)] |
| assert alpha[0] == 0.0 and alpha[23] == 0.0 |
| assert frames == "" |
| assert len(selected) == 0 |
|
|
|
|
| @requires_fake_torch |
| def test_passthrough_slot_in_gap_counts_as_kept_not_gap(): |
| |
| |
| node = easy_ImageBatch() |
| _img, alpha, _sel, frames = node.create_batch( |
| total_frames=24, cut_start_frame=1, placeholder_color="Gray", |
| invert_alpha=False, source_frames="", |
| image1_frame=20, source_batch=_source(17), image1=_FakeTensor(["slotA"]), |
| ) |
| assert alpha[19] == 0.0 |
| assert frames == "18, 19, 21, 22, 23, 24" |
|
|
|
|
| @requires_fake_torch |
| def test_passthrough_gap_with_cut_start_offset(): |
| |
| |
| node = easy_ImageBatch() |
| image_batch, alpha, _sel, frames = node.create_batch( |
| total_frames=10, cut_start_frame=41, placeholder_color="Gray", |
| invert_alpha=False, source_frames="", |
| image1_frame=4, source_batch=_source(45), |
| ) |
| assert image_batch[0] == "src41" and image_batch[4] == "src45" |
| assert image_batch[5] == _GRAY |
| assert alpha[0] == 0.0 and alpha[4] == 0.0 |
| assert alpha[5] == 1.0 and alpha[9] == 1.0 |
| assert frames == "46, 47, 48, 49, 50" |
|
|
|
|
| |
|
|
|
|
| def test_parse_frame_tokens_rejects_overlong_range(): |
| values, bad = parse_frame_tokens("1-99999999, 7") |
| assert values == [7] |
| assert bad == ["1-99999999"] |
|
|
|
|
| def test_parse_frame_tokens_allows_large_bounded_range(): |
| values, bad = parse_frame_tokens("1-100") |
| assert len(values) == 100 and bad == [] |
|
|
|
|
| def test_parse_frame_tokens_range_span_boundary(): |
| |
| ok, bad = parse_frame_tokens("1-8192") |
| assert len(ok) == 8192 and bad == [] |
| over, bad_over = parse_frame_tokens("1-8193") |
| assert over == [] and bad_over == ["1-8193"] |
|
|
|
|
| @requires_fake_torch |
| def test_keyframe_batch_alias_routes_to_insert_mode(): |
| |
| node = easy_ImageBatch() |
| _img, _alpha, _sel, frames = node.create_batch( |
| total_frames=24, cut_start_frame=1, placeholder_color="Gray", |
| invert_alpha=False, source_frames="5 7 20", |
| image1_frame=4, keyframe_batch=_FakeTensor(["a", "b", "c"]), |
| ) |
| assert frames == "5, 7, 20" |
|
|
|
|
| @requires_fake_torch |
| def test_keyframes_insert_wins_over_deprecated_alias(): |
| node = easy_ImageBatch() |
| _img, _alpha, selected, _frames = node.create_batch( |
| total_frames=24, cut_start_frame=1, placeholder_color="Gray", |
| invert_alpha=False, source_frames="5", |
| image1_frame=4, |
| keyframes_insert=_FakeTensor(["new"]), keyframe_batch=_FakeTensor(["old"]), |
| ) |
| assert selected[0] == "new" |
|
|
|
|
| @requires_fake_torch |
| def test_nonempty_list_without_image_source_warns_and_returns_clean(capsys): |
| |
| |
| node = easy_ImageBatch() |
| image_batch, _alpha, selected, frames = node.create_batch( |
| total_frames=5, cut_start_frame=1, placeholder_color="Gray", |
| invert_alpha=False, source_frames="2 3", |
| image1_frame=4, width=8, height=8, |
| ) |
| assert len(image_batch) == 5 |
| assert frames == "" and len(selected) == 0 |
| assert "source_frames is set but no image source" in capsys.readouterr().out |
|
|