File size: 16,569 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
# SPDX-License-Identifier: GPL-3.0-or-later
"""Tests for the loop status pass-through node."""

from __future__ import annotations

import json
import urllib.parse

import pytest

import k_loop_status
from k_loop_status import (
    DEFAULT_SERVER_URL,
    KoolookLoopStatus,
    _post_prompt,
    _resolve_server_url,
    build_status,
    infer_index_node_id,
)


@pytest.fixture(autouse=True)
def _clear_active_queue_keys():
    """Reset the module-global auto-queue dedup set between tests."""
    k_loop_status._ACTIVE_QUEUE_KEYS.clear()
    yield
    k_loop_status._ACTIVE_QUEUE_KEYS.clear()


def test_build_status_formats_one_based_position_and_frame_path():
    assert (
        build_status("write", 2, 4, "N:/out/frame.%04d.exr")
        == "write: 3/4 frame 2 -> N:/out/frame.0002.exr"
    )


def test_build_status_formats_other_padded_frame_patterns():
    assert (
        build_status("write", 12, 20, "N:/out/frame.%05d.exr")
        == "write: 13/20 frame 12 -> N:/out/frame.00012.exr"
    )


def test_report_prints_status_and_passes_value_through(capsys):
    value = object()
    node = KoolookLoopStatus()

    out_value, status = node.report(
        value,
        index=0,
        total=4,
        filepath="N:/out/frame.%04d.exr",
        label="EXR_SAFE",
    )

    assert out_value is value
    assert status == "EXR_SAFE: 1/4 frame 0 -> N:/out/frame.0000.exr"
    assert "[Koolook Loop Status] EXR_SAFE: 1/4" in capsys.readouterr().out


def test_registration_exports():
    from k_loop_status import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS

    assert NODE_CLASS_MAPPINGS["Koolook_LoopStatus"] is KoolookLoopStatus
    assert NODE_DISPLAY_NAME_MAPPINGS["Koolook_LoopStatus"] == "Koolook Loop Status"


def test_input_types_include_queue_controller_settings():
    optional = KoolookLoopStatus.INPUT_TYPES()["optional"]
    hidden = KoolookLoopStatus.INPUT_TYPES()["hidden"]

    assert "auto_queue_next" in optional
    assert "index_node_id" in optional
    assert "server_url" in optional
    assert "max_auto_queue_depth" in optional
    assert "remaining_auto_queue_depth" in optional
    assert hidden["prompt"] == "PROMPT"


def test_infers_index_node_id_from_connected_index_input():
    prompt = {"21": {"inputs": {"index": ["22", 0]}}}

    assert infer_index_node_id(prompt, "21") == "22"


def test_numeric_label_is_treated_as_shifted_index_node_id(capsys):
    node = KoolookLoopStatus()

    _value, status = node.report(
        "image",
        0,
        4,
        label="22",
        auto_queue_next=False,
    )

    assert status == "EXR_SAFE: 1/4 frame 0"
    assert "[Koolook Loop Status] EXR_SAFE: 1/4" in capsys.readouterr().out


def test_depth_guard_raises_before_status_print(capsys):
    node = KoolookLoopStatus()

    with pytest.raises(RuntimeError, match="Refusing to auto-queue"):
        node.report(
            "image",
            0,
            5,
            auto_queue_next=True,
            index_node_id="22",
            max_auto_queue_depth=2,
            prompt={"21": {"inputs": {}}},
        )

    assert "[Koolook Loop Status]" not in capsys.readouterr().out


def test_resolve_server_url_keeps_custom_value(monkeypatch):
    monkeypatch.setattr(
        k_loop_status, "_detect_local_server_url", lambda: "http://127.0.0.1:9999"
    )

    assert _resolve_server_url("http://10.0.0.5:7000") == "http://10.0.0.5:7000"


def test_resolve_server_url_overrides_default_with_detected_port(monkeypatch):
    monkeypatch.setattr(
        k_loop_status, "_detect_local_server_url", lambda: "http://127.0.0.1:8000"
    )

    assert _resolve_server_url(DEFAULT_SERVER_URL) == "http://127.0.0.1:8000"
    assert _resolve_server_url("") == "http://127.0.0.1:8000"


def test_resolve_server_url_falls_back_to_default_when_undetectable(monkeypatch):
    monkeypatch.setattr(k_loop_status, "_detect_local_server_url", lambda: None)

    assert _resolve_server_url(DEFAULT_SERVER_URL) == DEFAULT_SERVER_URL


def test_resolve_server_url_blank_falls_back_to_default_when_undetectable(monkeypatch):
    """Blank input must behave like the default when detection is unavailable."""
    monkeypatch.setattr(k_loop_status, "_detect_local_server_url", lambda: None)

    assert _resolve_server_url("") == DEFAULT_SERVER_URL


def test_resolve_server_url_auto_triggers_detection(monkeypatch):
    """The `auto` sentinel (the widget default) auto-detects, case-insensitively."""
    monkeypatch.setattr(
        k_loop_status, "_detect_local_server_url", lambda: "http://127.0.0.1:8000"
    )

    assert _resolve_server_url("auto") == "http://127.0.0.1:8000"
    assert _resolve_server_url("AUTO") == "http://127.0.0.1:8000"
    assert _resolve_server_url("  auto  ") == "http://127.0.0.1:8000"


def test_resolve_server_url_auto_falls_back_to_default_when_undetectable(monkeypatch):
    monkeypatch.setattr(k_loop_status, "_detect_local_server_url", lambda: None)

    assert _resolve_server_url("auto") == DEFAULT_SERVER_URL


def test_server_url_widget_defaults_to_auto():
    optional = KoolookLoopStatus.INPUT_TYPES()["optional"]

    assert optional["server_url"][1]["default"] == "auto"


def test_compose_server_url_brackets_ipv6_literal():
    assert k_loop_status._compose_server_url("::1", 8000) == "http://[::1]:8000"
    assert (
        k_loop_status._compose_server_url("2001:db8::1", 8000)
        == "http://[2001:db8::1]:8000"
    )
    # Idempotent: an already-bracketed literal must not be double-bracketed.
    assert k_loop_status._compose_server_url("[::1]", 8000) == "http://[::1]:8000"


def test_compose_server_url_ipv6_result_passes_validation():
    """A bracketed IPv6 URL must parse through the pre-queue validator."""
    url = k_loop_status._compose_server_url("::1", 8000)

    k_loop_status._validate_http_url(url)  # must not raise
    assert urllib.parse.urlsplit(url).port == 8000


def test_compose_server_url_remaps_bind_all_to_localhost():
    assert k_loop_status._compose_server_url("0.0.0.0", 8000) == "http://127.0.0.1:8000"  # nosec B104
    assert k_loop_status._compose_server_url("::", 8000) == "http://127.0.0.1:8000"
    # ComfyUI's bare `--listen` is the comma-joined "all IPv4 and IPv6" value.
    assert (
        k_loop_status._compose_server_url("0.0.0.0,::", 8000)  # nosec B104
        == "http://127.0.0.1:8000"
    )
    # Order-independent: a bind-all member anywhere in the list routes to loopback.
    assert (
        k_loop_status._compose_server_url("::,0.0.0.0", 8000)  # nosec B104
        == "http://127.0.0.1:8000"
    )
    # No bind-all member: the first concrete host is used verbatim.
    assert (
        k_loop_status._compose_server_url("10.0.0.5,192.168.1.9", 8000)
        == "http://10.0.0.5:8000"
    )


def test_compose_server_url_keeps_ipv4_hostname_and_defaults_blank():
    assert k_loop_status._compose_server_url("127.0.0.1", 8000) == "http://127.0.0.1:8000"
    assert k_loop_status._compose_server_url("localhost", 8000) == "http://localhost:8000"
    assert k_loop_status._compose_server_url("", 8000) == "http://127.0.0.1:8000"


def test_probe_uses_detected_port_not_stale_default(monkeypatch):
    """Auto-queue on a non-default port must probe the running server."""
    monkeypatch.setattr(
        k_loop_status, "_detect_local_server_url", lambda: "http://127.0.0.1:8000"
    )
    probed = {}
    monkeypatch.setattr(
        k_loop_status, "_probe_server", lambda url: probed.update(url=url)
    )
    # Don't actually spawn the queue thread; we only care about the probe target.
    class _NoopThread:
        def __init__(self, *args, **kwargs):
            pass

        def start(self):
            pass

    monkeypatch.setattr(k_loop_status.threading, "Thread", _NoopThread)

    node = KoolookLoopStatus()
    node.report(
        "image",
        0,
        4,
        auto_queue_next=True,
        index_node_id="22",
        server_url=DEFAULT_SERVER_URL,
        prompt={"21": {"inputs": {}}, "22": {"inputs": {}}},
        unique_id="21",
    )

    assert probed["url"] == "http://127.0.0.1:8000"


def test_status_only_does_not_resolve_server_url(monkeypatch):
    """With auto-queue off, the node must not detect/probe the server URL."""
    called = {"detect": False}

    def _flag_detect():
        called["detect"] = True
        return None

    monkeypatch.setattr(k_loop_status, "_detect_local_server_url", _flag_detect)

    KoolookLoopStatus().report("image", 0, 4, auto_queue_next=False)

    assert called["detect"] is False


def test_stale_index_node_id_self_heals_from_connected_index(monkeypatch):
    """A shifted index_node_id like '0' falls back to the connected index node."""
    monkeypatch.setattr(
        k_loop_status, "_detect_local_server_url", lambda: "http://127.0.0.1:8000"
    )
    monkeypatch.setattr(k_loop_status, "_probe_server", lambda url: None)
    captured = {}

    class _NoopThread:
        def __init__(self, *args, **kwargs):
            captured.update(kwargs.get("kwargs", {}))

        def start(self):
            pass

    monkeypatch.setattr(k_loop_status.threading, "Thread", _NoopThread)

    prompt = {
        "21": {"inputs": {"index": ["22", 0]}},
        "22": {"inputs": {"value": 0}},
    }
    _value, status = KoolookLoopStatus().report(
        "image",
        0,
        4,
        auto_queue_next=True,
        index_node_id="0",
        prompt=prompt,
        unique_id="21",
    )

    assert status.startswith("loop: 1/4")
    assert captured["index_node_id"] == "22"


def test_unknown_index_node_id_raises_synchronously():
    """An index_node_id with no matching node and no connection fails up front."""
    node = KoolookLoopStatus()

    with pytest.raises(RuntimeError, match="is not a node in this workflow"):
        node.report(
            "image",
            0,
            4,
            auto_queue_next=True,
            index_node_id="0",
            prompt={"21": {"inputs": {}}},
            unique_id="21",
        )


def test_as_bool_coerces_saved_string_booleans():
    assert k_loop_status._as_bool("true") is True
    assert k_loop_status._as_bool(" True ") is True
    assert k_loop_status._as_bool("1") is True
    assert k_loop_status._as_bool("yes") is True
    assert k_loop_status._as_bool("on") is True
    assert k_loop_status._as_bool("false") is False
    assert k_loop_status._as_bool("no") is False
    assert k_loop_status._as_bool("off") is False
    assert k_loop_status._as_bool("0") is False
    assert k_loop_status._as_bool("") is False
    assert k_loop_status._as_bool(True) is True
    assert k_loop_status._as_bool(0) is False


def test_resolve_index_node_id_prefers_configured_when_present():
    prompt = {"543": {"class_type": "easy int", "inputs": {"value": 0}}}

    node_id, note = k_loop_status.resolve_index_node_id(prompt, "21", "543")

    assert node_id == "543"
    assert "using configured easy int node 543" in note


def test_resolve_index_node_id_falls_back_from_stale_manual_id():
    prompt = {
        "21": {"inputs": {"index": ["543", 0]}},
        "543": {"class_type": "easy int", "inputs": {"value": 0}},
    }

    node_id, note = k_loop_status.resolve_index_node_id(prompt, "21", "22")

    assert node_id == "543"
    assert "configured index node '22' is not in this prompt" in note
    assert "easy int node 543" in note


def test_resolve_index_node_id_infers_when_blank():
    prompt = {"21": {"inputs": {"index": ["543", 0]}}, "543": {"inputs": {}}}

    node_id, note = k_loop_status.resolve_index_node_id(prompt, "21", "")

    assert node_id == "543"
    assert note.startswith("using connected")


def test_resolve_index_node_id_returns_empty_when_unresolvable():
    assert k_loop_status.resolve_index_node_id({"21": {"inputs": {}}}, "21", "") == ("", "")


def test_describe_prompt_node_without_class_type_is_not_doubled():
    assert k_loop_status._describe_prompt_node({"22": {"inputs": {}}}, "22") == "node 22"
    assert (
        k_loop_status._describe_prompt_node({"22": {"_meta": {"title": "Frame"}}}, "22")
        == "Frame node 22"
    )


def test_resolve_index_node_id_fallback_does_not_override_connected():
    """A recovered numeric label is last-resort; the connected wire still wins."""
    prompt = {
        "21": {"inputs": {"index": ["543", 0]}},
        "543": {"class_type": "easy int", "inputs": {"value": 0}},
        "22": {"class_type": "easy int", "inputs": {"value": 0}},
    }

    node_id, note = k_loop_status.resolve_index_node_id(prompt, "21", "", fallback_id="22")

    assert node_id == "543"
    assert "connected easy int node 543" in note


def test_resolve_index_node_id_uses_fallback_when_nothing_else_resolves():
    node_id, note = k_loop_status.resolve_index_node_id(None, None, "", fallback_id="22")

    assert node_id == "22"
    assert "recovered node 22" in note


def test_numeric_label_does_not_override_connected_index(monkeypatch):
    """User scenario: a node id stuck in `label` must not beat the wired index."""
    monkeypatch.setattr(k_loop_status, "_detect_local_server_url", lambda: None)
    monkeypatch.setattr(k_loop_status, "_probe_server", lambda url: None)
    captured = {}

    class _NoopThread:
        def __init__(self, *args, **kwargs):
            captured.update(kwargs.get("kwargs", {}))

        def start(self):
            pass

    monkeypatch.setattr(k_loop_status.threading, "Thread", _NoopThread)

    prompt = {
        "21": {"inputs": {"index": ["543", 0]}},
        "543": {"class_type": "easy int", "inputs": {"value": 0}},
        "22": {"class_type": "easy int", "inputs": {"value": 0}},
    }
    _value, status = KoolookLoopStatus().report(
        "image",
        0,
        2,
        label="22",
        auto_queue_next=True,
        prompt=prompt,
        unique_id="21",
    )

    assert captured["index_node_id"] == "543"
    assert status.startswith("EXR_SAFE: 1/2")


def test_resolve_index_node_id_explicit_override_beats_connected_wire():
    """An explicit, valid index_node_id is a power-user override and wins over the wire."""
    prompt = {
        "21": {"inputs": {"index": ["543", 0]}},
        "543": {"class_type": "easy int", "inputs": {"value": 0}},
        "99": {"class_type": "easy int", "inputs": {"value": 0}},
    }

    node_id, note = k_loop_status.resolve_index_node_id(prompt, "21", "99")

    assert node_id == "99"
    assert "using configured easy int node 99" in note


def test_string_false_auto_queue_does_not_queue():
    """A saved 'false' string must not auto-queue (bool('false') is truthy)."""
    node = KoolookLoopStatus()

    _value, status = node.report(
        "image",
        0,
        4,
        auto_queue_next="false",
        index_node_id="22",
        prompt={"21": {"inputs": {}}},
        unique_id="21",
    )

    assert status == "loop: 1/4 frame 0"


def test_string_true_auto_queue_logs_detected_index_node(monkeypatch, capsys):
    """Saved 'true' enables auto-queue; the chosen index node class/id is logged."""
    monkeypatch.setattr(k_loop_status, "_detect_local_server_url", lambda: None)
    monkeypatch.setattr(k_loop_status, "_probe_server", lambda url: None)
    captured = {}

    class _NoopThread:
        def __init__(self, *args, **kwargs):
            captured.update(kwargs.get("kwargs", {}))

        def start(self):
            pass

    monkeypatch.setattr(k_loop_status.threading, "Thread", _NoopThread)

    prompt = {
        "21": {"inputs": {"index": ["543", 0]}},
        "543": {"class_type": "easy int", "inputs": {"value": 0}},
    }
    _value, status = KoolookLoopStatus().report(
        "image",
        0,
        2,
        auto_queue_next="true",
        index_node_id="22",
        prompt=prompt,
        unique_id="21",
    )

    assert status.startswith("loop: 1/2")
    assert captured["index_node_id"] == "543"
    assert "easy int node 543" in capsys.readouterr().out


def test_post_prompt_rejects_error_payload(monkeypatch):
    class Response:
        def __enter__(self):
            return self

        def __exit__(self, *_args):
            return False

        def read(self):
            return json.dumps({"error": "bad prompt"}).encode("utf-8")

    monkeypatch.setattr("urllib.request.urlopen", lambda *_args, **_kwargs: Response())

    with pytest.raises(RuntimeError, match="rejected child prompt"):
        _post_prompt("http://127.0.0.1:8188", {})


def test_post_prompt_rejects_non_http_url():
    with pytest.raises(RuntimeError, match=r"Only http\(s\)"):
        _post_prompt("file:///tmp/comfy.sock", {})