Files
James VeitchandClaude Sonnet 5 f16da579bc feat(ui): add output-text previews and live retry status to comfydv nodes
Fixes a real gap discovered this session: ChatCompletion/FormatString's
"ui": {"text": [...]} return value was never rendered anywhere — no JS in
this repo ever implemented an onExecuted handler to read it, so the
retry-status banner from the previous commit was invisible in practice.
RandomChoice never returned a ui payload at all (OUTPUT_NODE was False).

- src/js/preview_text.js: read-only STRING preview widget for
  ChatCompletion/FormatString/RandomChoice, mirroring ComfyUI core's own
  PreviewAny node/JS extension (confirmed via the actual bundled frontend
  source, not guessed) rather than inventing a new convention.
- RandomChoice: OUTPUT_NODE=True + a ui.text preview of its arbitrary-typed
  output (str/number passthrough, else JSON, else str() — same fallback
  chain as core's PreviewAny). IS_CHANGED still returns the raw picked
  value, not the new ui-wrapped dict, so change-detection is unaffected.
- Live retry feedback: chat()/chat_structured() gain an on_status
  callback, invoked at each retry boundary with a human-readable status
  (_llm/retry.py's format_retry_status/format_recovered_status).
  ChatCompletion wires this to ComfyUI's own PromptServer.send_progress_text
  (the same first-party mechanism core's gaussian-splat/image nodes use)
  rather than a hand-rolled custom event — the frontend already renders a
  live "progressText" widget for it, so no additional JS was needed for
  the live half of this feature.

Verified live against a real Ollama backend (docker-compose.dev.yml +
Playwright): forced a genuine refusal/retry via custom_phrases, confirmed
both the final preview widget and the live progress widget render
correctly on ChatCompletion, and confirmed FormatString/RandomChoice's
preview widgets render too. 410 tests passing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 23:43:13 +01:00

75 lines
2.6 KiB
Python

"""Tests for comfydv.random_choice.RandomChoice.
Covers the UI-preview addition (OUTPUT_NODE=True + a "ui": {"text": [...]}
return, mirroring ChatCompletion/FormatString so all three get a visible
text preview via src/js/preview_text.js) without changing IS_CHANGED's
change-detection semantics.
"""
import json
from comfydv.random_choice import RandomChoice, _preview_text
def test_output_node_is_true():
assert RandomChoice.OUTPUT_NODE is True
def test_random_choice_returns_ui_result_dict():
ret = RandomChoice().random_choice(input1="a", seed=42)
assert isinstance(ret, dict)
assert "ui" in ret
assert "result" in ret
assert ret["result"] == ("a",)
def test_ui_text_matches_the_chosen_value_for_a_string():
ret = RandomChoice().random_choice(input1="hello", seed=42)
assert ret["ui"]["text"] == ["hello"]
def test_ui_text_for_a_number_is_stringified():
ret = RandomChoice().random_choice(input1=7, seed=42)
assert ret["ui"]["text"] == ["7"]
assert ret["result"] == (7,)
def test_seed_pins_the_choice_deterministically():
ret1 = RandomChoice().random_choice(input1="a", input2="b", input3="c", seed=42)
ret2 = RandomChoice().random_choice(input1="a", input2="b", input3="c", seed=42)
assert ret1["result"] == ret2["result"]
def test_is_changed_returns_raw_pick_not_ui_wrapped_dict():
"""Regression guard: IS_CHANGED must keep returning the same shape it
did before the ui-preview addition (the raw picked value), not the new
{"ui": ..., "result": ...} dict random_choice() now returns — otherwise
ComfyUI's change-detection comparison would be comparing dicts full of
UI-only text noise instead of the actual output value."""
result = RandomChoice.IS_CHANGED(input1="only-choice", seed=42)
assert result == "only-choice"
class TestPreviewText:
def test_string_passthrough(self):
assert _preview_text("hello") == "hello"
def test_number_stringified(self):
assert _preview_text(42) == "42"
assert _preview_text(3.14) == "3.14"
assert _preview_text(True) == "True"
def test_list_json_dumped(self):
assert json.loads(_preview_text([1, 2, 3])) == [1, 2, 3]
def test_unserializable_falls_back_to_str(self):
class Weird:
def __repr__(self):
return "<Weird thing>"
# json.dumps(default=str) actually succeeds here (falls back to
# str() per-value), so this exercises the "else JSON" branch
# rather than the outer except — confirms it never raises either
# way.
assert "<Weird thing>" in _preview_text(Weird())