File size: 22,615 Bytes
0c85e62
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
# SPDX-License-Identifier: GPL-3.0-or-later
"""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

# Must run before importing the node module (its top-level ``import torch``).
# ``setdefault`` keeps a real torch if one happens to be installed.
sys.modules.setdefault("torch", types.ModuleType("torch"))

# Enrich the stub with the handful of constructors ``create_batch`` uses so the
# *behaviour* (layer compositing, tie-break, passthrough) can be exercised
# torch-free — not just the pure planners. ``_FakeTensor`` wraps a flat list of
# frame-identity tags, so a test can assert *which* frame landed at each output
# index. Skipped automatically if a real torch is installed.
_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):  # 1.0 - alpha; identity is fine for these tests
            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:  # pragma: no cover - real torch present, skip the integration tests
    _FakeTensor = None

_GRAY = ("placeholder", 0.5)  # what _fake_full tags an unfilled frame with


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 (  # noqa: E402  (import after stub)
    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():
    # 5 processed frames, list 1,10,19,30,40, full 41-frame window (cut=1).
    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():
    # Out-of-order list still pairs source[0] -> lowest position, etc., so it
    # round-trips with selected_frames (which is emitted ascending).
    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():
    # output index = vfx - cut_start_frame; window [0, 20).
    # frame 1 -> -9 (out), 15 -> 5 (in), 40 -> 30 (out).
    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():
    # cut_start_frame=41: frame 41 -> idx 0, 63 -> idx 22.
    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():
    # cut_start=1: output i -> source i (source_batch[0] is VFX frame 1).
    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():
    # cut_start=41: output 0 -> source 40 (VFX frame 41), etc.
    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():
    # base only covers 2 frames; the remaining 3 cut frames stay 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():
    # Cut window starts beyond the source entirely → all placeholder.
    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():
    # image1 @ VFX frame 4, cut_start 1 -> output index 3.
    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():
    # image1 (idx 0) and image3 (idx 2) both target VFX frame 10 -> the
    # higher-numbered slot wins, one winner per output index.
    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():
    # Same collision, supplied highest-first: result must still pick image3.
    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():
    # cut_start 10: frame 2 -> output -8 (outside); frame 15 -> 5 (in).
    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():
    # Distinct frames, ascending by output index regardless of slot order.
    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"
    # `keyframe_batch` is retained as a deprecated alias so workflows saved on
    # v0.4.1/v0.4.2 keep loading.
    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()
    # Fallback-only and declared OPTIONAL so they append to the END of the
    # widget list — keeping positional widgets_values of older saved workflows
    # aligned (inserting them earlier shifts placeholder_color/source_frames).
    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
    # They must be the last two optional keys (= last two widgets).
    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)"


# --- Behaviour (layer compositing) tests, run against the fake-tensor stub ---


@requires_fake_torch
def test_select_slot_overwrites_list_pick_at_same_frame():
    # source pick at frame 5 AND image1 wired at frame 5 -> the slot wins.
    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"   # output index 4 == VFX frame 5
    assert frames == "5"
    assert len(selected) == 1


@requires_fake_torch
def test_select_higher_slot_wins_at_same_frame():
    # image1 and image2 both target frame 10 -> the higher-numbered slot wins.
    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():
    # Inserts ins1/ins2/ins3 land at frames 5/7/20; image1 @ frame 5 wins there.
    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"   # slot beats the insert at frame 5
    assert image_batch[6] == "ins2"    # other inserts untouched
    assert image_batch[19] == "ins3"


@requires_fake_torch
def test_select_empty_list_passes_source_through_with_no_phantom_picks():
    # EXE 2: source connected, empty list, no slots wired (defaults 5/9/13/17).
    # Whole batch is the source passthrough; nothing is "placed".
    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 == ""        # background is never counted as placed
    assert len(selected) == 0


@requires_fake_torch
def test_select_nonempty_list_ignores_unwired_slot_defaults():
    # EXE 3: only the listed picks are placed; the unwired slots' default
    # imageN_frame values (9/13/17) do NOT pull phantom frames from source.
    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    # frame 9 stays placeholder, no phantom pick


@requires_fake_torch
def test_selected_image_batch_is_picks_plus_slots_slots_win_overlap():
    # selected_image_batch = union of multiline picks AND slot overwrites,
    # ascending by frame, with the slot winning where they 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"]),          # overlaps the pick at frame 5
        image2=_FakeTensor(["slotB"]), image2_frame=12,  # slot-only position
    )
    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():
    # Same rule under insert mode: selected = inserts + slot overwrites.
    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"]),          # slot-only position at frame 4
    )
    assert frames == "4, 5, 7, 20"
    assert [selected[i] for i in range(len(selected))] == ["slotA", "ins1", "ins2", "ins3"]


# --- Feature: range syntax in the frame list (parse_frame_tokens) ---


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"


# --- Feature: passthrough shorter than output -> keep + gap-as-selection ---


@requires_fake_torch
def test_passthrough_shorter_source_keeps_covered_and_selects_gap():
    # source = 17 frames, output = 24: frames 1-17 are kept (alpha 0/black),
    # frames 18-24 are the gap (alpha 1/white) and become the selection.
    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),
    )
    # image: 1-17 source, 18-24 placeholder
    assert image_batch[16] == "src17"
    assert image_batch[17] == _GRAY
    # alpha (inpaint): covered black (0.0), gap white (1.0)
    assert alpha[0] == 0.0 and alpha[16] == 0.0
    assert alpha[17] == 1.0 and alpha[23] == 1.0
    # selection = the gap frames 18..24 (to inpaint)
    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():
    # source >= output: no gap -> alpha all black (kept), selection empty.
    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():
    # A slot filling a gap frame becomes kept content, excluded from the
    # inpaint selection.
    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                      # frame 20 filled by slot -> kept
    assert frames == "18, 19, 21, 22, 23, 24"    # gap excludes frame 20


@requires_fake_torch
def test_passthrough_gap_with_cut_start_offset():
    # cut_start=41, total=10 -> output is VFX 41..50. source has 45 frames, so
    # VFX 41..45 are covered (output 0..4) and 46..50 are the gap.
    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                       # VFX 46 = gap
    assert alpha[0] == 0.0 and alpha[4] == 0.0           # covered kept
    assert alpha[5] == 1.0 and alpha[9] == 1.0           # gap to inpaint
    assert frames == "46, 47, 48, 49, 50"                # gap selection


# --- Review-round fixes: deprecated alias + input guards ---


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():
    # exactly _MAX_RANGE_SPAN (8192) frames is allowed; one more is rejected.
    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():
    # A workflow saved with the deprecated alias still behaves like an insert.
    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):
    # source_frames set but no source_batch / imageN -> clean placeholder batch,
    # with a warning that the list was ignored (no silent drop).
    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