| """Pure-function coverage for scripts/loop_audio.py. |
| |
| Anchored by the PR #185 multi-agent review which surfaced three |
| silent-failure HIGHs on the audio-source-derivation path: |
| |
| * derive_audio_state collapsed missing-Director and unwired-VAE |
| into the same label; |
| * extract_multilines used dict-insertion order, so a shorter needle |
| declared first could silently shadow a longer match; |
| * parse_timeline returned segments verbatim, so a string-valued |
| `start` field would crash the renderer at `start / fps` or quietly |
| break `<` comparisons in video_segment_has_audio. |
| |
| These tests pin each fix plus the surrounding helpers so future edits |
| can't silently regress. |
| """ |
| from __future__ import annotations |
|
|
| import importlib.util |
| import json |
| import sys |
| from pathlib import Path |
|
|
| import pytest |
|
|
| |
| _SCRIPT = Path(__file__).resolve().parents[2] / "scripts" / "loop_audio.py" |
| sys.path.insert(0, str(_SCRIPT.parent)) |
| _spec = importlib.util.spec_from_file_location("loop_audio", _SCRIPT) |
| assert _spec is not None and _spec.loader is not None |
| loop_audio = importlib.util.module_from_spec(_spec) |
| _spec.loader.exec_module(loop_audio) |
|
|
|
|
| |
|
|
|
|
| def _director( |
| *, |
| node_type: str = "LTXDirector__koolook", |
| use_custom_audio: bool = False, |
| audio_vae_link: int | None = None, |
| timeline: dict | None = None, |
| epsilon: float = 0.001, |
| fps: int = 24, |
| ) -> dict: |
| """Build a minimal Koolook Director node dict matching the saved |
| widget order (DIRECTOR_WIDX). Only fields the helpers under test |
| actually read are populated; the rest are placeholders.""" |
| import json as _json |
| timeline_str = _json.dumps(timeline) if timeline is not None else "" |
| wv = [ |
| "", |
| 120, |
| 5, |
| timeline_str, |
| "", |
| "120", |
| epsilon, |
| "1.00", |
| use_custom_audio, |
| fps, |
| "seconds", |
| 0, |
| 0, |
| "maintain aspect ratio", |
| 32, |
| 18, |
| "", |
| ] |
| return { |
| "type": node_type, |
| "widgets_values": wv, |
| "inputs": [ |
| {"name": "audio_vae", "link": audio_vae_link}, |
| {"name": "use_custom_audio", "link": None}, |
| ], |
| } |
|
|
|
|
| |
|
|
|
|
| @pytest.mark.parametrize( |
| "node_type", |
| ["LTXDirector__koolook", "LTXDirector__koolook_v1_3_2", "LTXDirector"], |
| ) |
| def test_extract_director_accepts_supported_director_ids(node_type): |
| node = _director(node_type=node_type) |
| assert loop_audio.extract_director([node]) is node |
|
|
|
|
| def test_extract_director_prefers_koolook_over_upstream(): |
| upstream = _director(node_type="LTXDirector") |
| koolook = _director(node_type="LTXDirector__koolook") |
| assert loop_audio.extract_director([upstream, koolook]) is koolook |
|
|
|
|
| def test_extract_director_prefers_guide_wired_upstream_over_idle_koolook(): |
| upstream = {"id": 10, **_director(node_type="LTXDirector")} |
| upstream["outputs"] = [{"name": "guide_data", "links": [100]}] |
| koolook = {"id": 20, **_director(node_type="LTXDirector__koolook")} |
| koolook["outputs"] = [{"name": "guide_data", "links": []}] |
| reroute = { |
| "id": 30, |
| "type": "Reroute", |
| "inputs": [{"name": "", "link": 100}], |
| "outputs": [{"name": "", "links": [101]}], |
| } |
| guide = { |
| "id": 40, |
| "type": "LTXDirectorGuide", |
| "inputs": [{"name": "guide_data", "link": 101}], |
| } |
|
|
| assert ( |
| loop_audio.extract_director( |
| [koolook, upstream, reroute, guide], |
| [ |
| [100, 10, 4, 30, 0, "GUIDE_DATA"], |
| [101, 30, 0, 40, 4, "GUIDE_DATA"], |
| ], |
| ) |
| is upstream |
| ) |
|
|
|
|
| def test_director_widget_uses_saved_director_widget_order(): |
| node = { |
| "type": "LTXDirector__koolook", |
| "inputs": [ |
| {"name": "duration_frames", "widget": {"name": "duration_frames"}}, |
| {"name": "epsilon", "widget": {"name": "epsilon"}}, |
| {"name": "use_custom_audio", "widget": {"name": "use_custom_audio"}}, |
| ], |
| "widgets_values": [ |
| "", |
| 144, |
| 6, |
| "", |
| "", |
| "", |
| 0.004, |
| "1.00,1.00,1.00", |
| True, |
| 24, |
| ], |
| } |
|
|
| assert loop_audio.director_widget(node, "epsilon") == 0.004 |
| assert loop_audio.director_widget(node, "use_custom_audio") is True |
| assert loop_audio.director_widget(node, "frame_rate") == 24 |
|
|
|
|
| def test_director_widget_keeps_legacy_positional_fallback(): |
| node = _director(epsilon=0.002) |
| assert loop_audio.director_widget(node, "epsilon") == 0.002 |
|
|
|
|
| def test_active_relay_overrides_reads_only_wired_director_input(): |
| director = { |
| "id": 10, |
| **_director(), |
| "inputs": [ |
| {"name": "audio_vae", "link": 99}, |
| {"name": "relay_overrides", "link": 20}, |
| ], |
| } |
| relay_note = _multiline("RELAY_OVERRIDES", '{"video_strength": 1.0}') |
| relay_note["id"] = 5 |
| relay_note["outputs"] = [{"name": "STRING", "links": [20]}] |
|
|
| assert loop_audio.active_relay_overrides( |
| [director, relay_note], |
| [[20, 5, 0, 10, 16, "STRING"]], |
| director, |
| ) == '{"video_strength": 1.0}' |
|
|
|
|
| def test_active_relay_overrides_ignores_unwired_note(): |
| director = { |
| "id": 10, |
| **_director(), |
| "inputs": [ |
| {"name": "audio_vae", "link": 99}, |
| {"name": "relay_overrides", "link": None}, |
| ], |
| } |
| relay_note = _multiline("RELAY_OVERRIDES", '{"video_strength": 1.0}') |
| relay_note["id"] = 5 |
|
|
| assert loop_audio.active_relay_overrides( |
| [director, relay_note], [], director |
| ) == "" |
|
|
|
|
| @pytest.mark.parametrize( |
| "node_type, expected", |
| [ |
| ("LTXDirector__koolook", "Koolook"), |
| ("LTXDirector__koolook_v1_3_2", "Koolook"), |
| ("LTXDirector", "Original upstream"), |
| ], |
| ) |
| def test_director_flavor_labels_supported_directors(node_type, expected): |
| assert loop_audio.director_flavor(_director(node_type=node_type)) == expected |
|
|
|
|
| @pytest.mark.parametrize( |
| "node_type, upstream_version, expected", |
| [ |
| ("LTXDirector__koolook", "", "v1.3.9"), |
| ("LTXDirector__koolook_v1_3_2", "", "v1.3.9"), |
| ("LTXDirector", "1.3.2", "v1.3.2"), |
| ("LTXDirector", "", "(unknown upstream pin)"), |
| ], |
| ) |
| def test_director_pin_tag_labels_lock_version(node_type, upstream_version, expected): |
| assert ( |
| loop_audio.director_pin_tag( |
| _director(node_type=node_type), upstream_version |
| ) |
| == expected |
| ) |
|
|
|
|
| |
|
|
|
|
| @pytest.mark.parametrize( |
| "director_node, timeline, expected", |
| [ |
| |
| |
| |
| (None, {"segments": [], "audioSegments": []}, "(no director)"), |
| |
| ( |
| _director(audio_vae_link=None), |
| {"segments": [], "audioSegments": []}, |
| "off (no VAE)", |
| ), |
| |
| |
| ( |
| _director(audio_vae_link=42, use_custom_audio=False), |
| {"segments": [], "audioSegments": []}, |
| "model-gen", |
| ), |
| |
| |
| ( |
| _director(audio_vae_link=42, use_custom_audio=True), |
| {"segments": [], "audioSegments": [{"start": 0, "length": 100}]}, |
| "custom", |
| ), |
| |
| |
| ( |
| _director(audio_vae_link=42, use_custom_audio=True), |
| {"segments": [], "audioSegments": []}, |
| "custom (empty)", |
| ), |
| ], |
| ) |
| def test_derive_audio_state_all_five_states(director_node, timeline, expected): |
| assert loop_audio.derive_audio_state(director_node, timeline) == expected |
|
|
|
|
| |
|
|
|
|
| def _multiline(title: str, body: str) -> dict: |
| return { |
| "type": "Text Multiline", |
| "title": title, |
| "widgets_values": [body], |
| } |
|
|
|
|
| def test_extract_multilines_keeps_all_hits_for_one_needle(): |
| """Working_Folder_PATH legitimately appears twice on the canvas |
| (project mount + local mirror). Both bodies must come back so the |
| caller can pick by reachability.""" |
| nodes = [ |
| _multiline("Working_Folder_PATH", "W:/projects/foo"), |
| _multiline("Working_Folder_PATH", "e:/local/foo"), |
| ] |
| out = loop_audio.extract_multilines(nodes, ["working_folder"]) |
| assert out["working_folder"] == ["W:/projects/foo", "e:/local/foo"] |
|
|
|
|
| def test_extract_multilines_longest_needle_wins(): |
| """If a future config adds a short substring that's contained in |
| an existing longer one, the longer match should win — regardless |
| of declaration order. The pre-fix loop iterated in dict-insertion |
| order and would have routed both nodes to the shorter needle.""" |
| nodes = [ |
| _multiline("RELAY_OVERRIDES", "{video_strength: 10}"), |
| ] |
| |
| |
| |
| out = loop_audio.extract_multilines( |
| nodes, ["relay", "relay_overrides"] |
| ) |
| assert out["relay_overrides"] == ["{video_strength: 10}"] |
| assert out["relay"] == [] |
|
|
|
|
| def test_extract_multilines_first_match_wins_per_node(): |
| """A node's title can match multiple needles. The loop should |
| record at most one match per node (the longest) to avoid |
| double-counting.""" |
| nodes = [_multiline("NAME overlay - info combined", "one node")] |
| out = loop_audio.extract_multilines( |
| nodes, ["name", "overlay - info"] |
| ) |
| |
| |
| assert out["name"] == [] |
| assert out["overlay - info"] == ["one node"] |
|
|
|
|
| def test_extract_multilines_accepts_prioritized_alias_map(): |
| nodes = [ |
| _multiline("NAME", "old setup name"), |
| _multiline("GLOBAL [ base name ]", "new base name"), |
| _multiline("GLOBAL [ path ] - working folder", "E:/runs"), |
| ] |
| out = loop_audio.extract_multilines( |
| nodes, |
| { |
| "name": ["global [ base name ]", "name"], |
| "working_folder": [ |
| "global [ path ] - working folder", |
| "working_folder", |
| ], |
| }, |
| ) |
| assert out["name"] == ["new base name", "old setup name"] |
| assert out["working_folder"] == ["E:/runs"] |
|
|
|
|
| def test_extract_setup_variables_reads_text_and_primitive_source_nodes(): |
| nodes = [ |
| _multiline("INPUT Path [ EXR ]", "W:/plates"), |
| { |
| "type": "PrimitiveInt", |
| "title": "GLOBAL [ version ]", |
| "widgets_values": [1, "fixed"], |
| }, |
| { |
| "type": "PrimitiveInt", |
| "title": "GLOBAL [ run offset ]", |
| "widgets_values": [7, "fixed"], |
| }, |
| { |
| "type": "GetNode", |
| "title": "Get_GLOBAL [ version ]", |
| "widgets_values": ["GLOBAL [ version ]"], |
| }, |
| { |
| "type": "SetNode", |
| "title": "Set_GLOBAL [ run ]", |
| "widgets_values": ["GLOBAL [ run ]"], |
| }, |
| ] |
| out = loop_audio.extract_setup_variables( |
| nodes, |
| { |
| "input_path_exr": ["input path [ exr ]"], |
| "version": ["global [ version ]"], |
| "run_offset": ["global [ run offset ]"], |
| }, |
| ) |
| assert out["input_path_exr"] == ["W:/plates"] |
| assert out["version"] == ["1"] |
| assert out["run_offset"] == ["7"] |
|
|
|
|
| def test_expected_output_tracking_uses_current_setup_values(): |
| nodes = [ |
| { |
| "type": "Easy_VideoCombine", |
| "widgets_values": { |
| "format": "video/koolook-ASTRA-h264", |
| }, |
| }, |
| { |
| "type": "easy showAnything", |
| "widgets_values": [ |
| '[true, ["E:/old/Previous_h264_v001.mp4"]]', |
| ], |
| }, |
| ] |
| out = loop_audio.expected_output_tracking( |
| nodes, |
| { |
| "working_folder": ["E:/current/renders"], |
| "name": ["Bear_2x-FR_AudioFile-K_Dir"], |
| }, |
| {"version": ["2"]}, |
| ) |
| assert out["folder"] == "E:/current/renders" |
| assert out["name"] == "Bear_2x-FR_AudioFile-K_Dir_h264_v002" |
|
|
|
|
| def test_output_suffix_uses_named_widget_before_positional_fallback(): |
| nodes = [ |
| { |
| "type": "Easy_VideoCombine", |
| "inputs": [ |
| {"name": "frame_rate", "widget": {"name": "frame_rate"}}, |
| {"name": "format", "widget": {"name": "format"}}, |
| {"name": "version", "widget": {"name": "version"}}, |
| ], |
| "widgets_values": [24, "video/ProRes", "999"], |
| }, |
| ] |
| assert loop_audio.output_suffix_from_workflow(nodes) == "ProRes" |
|
|
|
|
| def test_output_suffix_keeps_legacy_positional_fallback(): |
| nodes = [ |
| { |
| "type": "Easy_VideoCombine", |
| "widgets_values": [24, 0, "upscaled", "video/koolook-ASTRA-h264"], |
| }, |
| ] |
| assert loop_audio.output_suffix_from_workflow(nodes) == "h264" |
|
|
|
|
| def test_delivery_card_path_uses_output_folder_and_name(): |
| out = loop_audio.delivery_card_path({ |
| "folder": "E:/current/renders", |
| "name": "Bear_h264_v002", |
| }) |
| assert out == Path("E:/current/renders") / "cards" / "Bear_h264_v002_card.png" |
|
|
|
|
| def test_delivery_card_path_can_include_run_number(): |
| out = loop_audio.delivery_card_path( |
| { |
| "folder": "E:/current/renders", |
| "name": "Bear_h264_v002", |
| }, |
| 7, |
| ) |
| assert out == ( |
| Path("E:/current/renders") |
| / "cards" |
| / "Bear_h264_v002_run007_card.png" |
| ) |
|
|
|
|
| def test_delivery_card_path_skips_when_output_is_unknown(): |
| assert loop_audio.delivery_card_path({"folder": "E:/renders"}) is None |
|
|
|
|
| def test_copy_delivery_card_reports_failure_without_raising(monkeypatch, tmp_path: Path): |
| def fail_copy(_src, _dst): |
| raise OSError("drive unavailable") |
|
|
| monkeypatch.setattr(loop_audio.shutil, "copy2", fail_copy) |
|
|
| status = loop_audio.copy_delivery_card( |
| tmp_path / "card.png", |
| {"folder": str(tmp_path), "name": "Bear_h264_v002"}, |
| ) |
|
|
| assert status == "failed (drive unavailable)" |
|
|
|
|
| def test_copy_delivery_card_creates_missing_cards_folder(tmp_path: Path): |
| card = tmp_path / "source.png" |
| folder = tmp_path / "renders" |
| card.write_text("new", encoding="utf-8") |
|
|
| status = loop_audio.copy_delivery_card( |
| card, |
| {"folder": str(folder), "name": "Bear_h264_v002"}, |
| 7, |
| ) |
|
|
| expected = folder / "cards" / "Bear_h264_v002_run007_card.png" |
| assert status == str(expected) |
| assert expected.read_text(encoding="utf-8") == "new" |
|
|
|
|
| def test_copy_delivery_card_leaves_existing_file_in_place(tmp_path: Path): |
| card = tmp_path / "source.png" |
| folder = tmp_path / "renders" |
| existing = folder / "cards" / "Bear_h264_v002_card.png" |
| card.write_text("new", encoding="utf-8") |
| existing.parent.mkdir(parents=True) |
| existing.write_text("old", encoding="utf-8") |
|
|
| status = loop_audio.copy_delivery_card( |
| card, |
| {"folder": str(folder), "name": "Bear_h264_v002"}, |
| ) |
|
|
| assert status.startswith("exists (left in place:") |
| assert existing.read_text(encoding="utf-8") == "old" |
|
|
|
|
| def test_copy_delivery_card_can_overwrite_existing_file(tmp_path: Path): |
| card = tmp_path / "source.png" |
| folder = tmp_path / "renders" |
| existing = folder / "cards" / "Bear_h264_v002_run007_card.png" |
| card.write_text("new", encoding="utf-8") |
| existing.parent.mkdir(parents=True) |
| existing.write_text("old", encoding="utf-8") |
|
|
| status = loop_audio.copy_delivery_card( |
| card, |
| {"folder": str(folder), "name": "Bear_h264_v002"}, |
| 7, |
| overwrite=True, |
| ) |
|
|
| assert status == str(existing) |
| assert existing.read_text(encoding="utf-8") == "new" |
|
|
|
|
| def test_card_metadata_scrubs_path_bearing_fields(): |
| metadata = loop_audio.card_metadata( |
| 4, |
| "label", |
| Path("run004_workflow.json"), |
| {"name": ["Bear"], "relay_overrides": ["{}"]}, |
| {"input_path_exr": ["W:/projects/client_codename/shot/v003"]}, |
| { |
| "folder": "E:/Jobs/Client/Comfy/Runs", |
| "name": "Bear_h264_v002", |
| "version_tag": "v002", |
| "format_suffix": "h264", |
| }, |
| _director(), |
| {"segments": [], "audioSegments": []}, |
| "custom", |
| {}, |
| "clean", |
| "1.3.2", |
| ) |
|
|
| encoded = json.dumps(metadata) |
| assert "W:/projects" not in encoded |
| assert "client_codename" not in encoded |
| assert "E:/Jobs" not in encoded |
| assert "path-sha256:" in encoded |
|
|
|
|
| def test_card_metadata_does_not_store_boolean_frame_rate(): |
| metadata = loop_audio.card_metadata( |
| 4, |
| "label", |
| Path("run004_workflow.json"), |
| {"name": ["Bear"], "relay_overrides": ["{}"]}, |
| {}, |
| {"folder": "", "name": ""}, |
| _director(fps=True), |
| {"segments": [], "audioSegments": []}, |
| "model-gen", |
| {}, |
| "clean", |
| "1.3.2", |
| ) |
|
|
| assert metadata["director"]["frame_rate"] is None |
|
|
|
|
| def test_sanitize_workflow_for_archive_redacts_absolute_paths(): |
| workflow = { |
| "nodes": [ |
| { |
| "widgets_values": [ |
| "E:/Jobs/Client/Comfy/Runs", |
| "notes\nW:/projects/client_codename/shot/v003\nok", |
| '["e:\\\\G-Drive-BaconX\\\\Jobs\\\\Jeep_Animals\\\\render.json"]', |
| "<PROJECTS>/samsung_goat/vfx/assets", |
| "W:/projects/client_codename/shot/v003", |
| "relative/path/is-kept", |
| "https://example.com/kept", |
| ] |
| } |
| ] |
| } |
|
|
| sanitized = loop_audio.sanitize_workflow_for_archive(workflow) |
| encoded = json.dumps(sanitized) |
|
|
| assert "E:/Jobs" not in encoded |
| assert "W:/projects" not in encoded |
| assert "client_codename" not in encoded |
| assert "samsung_goat" not in encoded |
| assert "relative/path/is-kept" in encoded |
| assert "https://example.com/kept" in encoded |
| assert "path-sha256:" in encoded |
|
|
|
|
| def test_render_notes_scrubs_paths_and_rejects_boolean_fps(): |
| notes = loop_audio.render_notes_md( |
| 5, |
| Path("LTX-23-audio_tests_03.json"), |
| { |
| "name": ["Bear"], |
| "relay_overrides": [""], |
| "overlay - info": [""], |
| "overlay - feedback": [""], |
| "working_folder": ["E:/Jobs/Client/Comfy/Runs"], |
| }, |
| { |
| "input_path_exr": ["W:/projects/client_codename/shot/v003"], |
| "version": ["1"], |
| "run_offset": ["0"], |
| }, |
| _director(fps=True), |
| {"segments": [], "audioSegments": []}, |
| "custom", |
| "", |
| [], |
| {"motion": None, "sync": None, "sharp": None}, |
| {"folder": "E:/Jobs/Client/Comfy/Runs", "name": "Bear_h264_v001"}, |
| ) |
|
|
| assert "E:/Jobs" not in notes |
| assert "W:/projects" not in notes |
| assert "client_codename" not in notes |
| assert "path-sha256:" in notes |
| assert "True fps" not in notes |
| assert "(unknown fps)" in notes |
|
|
|
|
| def test_audio_card_embeds_metadata_payload(tmp_path: Path): |
| from PIL import Image |
|
|
| from make_card_audio import render_audio_card |
|
|
| metadata = { |
| "schema": "koolook.audio_loop.card_metadata.v1", |
| "repo": {"main_sha": "abc1234"}, |
| "output": {"name": "Bear_h264_v002"}, |
| } |
| out = tmp_path / "card.png" |
| render_audio_card( |
| { |
| "run_number": 3, |
| "date": "2026-05-30", |
| "workflow_name": "workflow.json", |
| "name": "Bear", |
| "relay_overrides_raw": "{}", |
| "info_body": "", |
| "feedback_lines": [], |
| "scores": {}, |
| "work_folder": "E:/renders", |
| "output_folder": "E:/renders", |
| "output_name": "Bear_h264_v002", |
| "director_flavor": "Koolook v1.3.9", |
| "audio_src": "custom", |
| "epsilon": 0.001, |
| "frame_rate": 24, |
| "segments": [], |
| "audio_segments": [], |
| "segment_prompt_mode": "none", |
| "metadata": metadata, |
| }, |
| out, |
| ) |
| embedded = json.loads(Image.open(out).info["koolook_audio_loop"]) |
| assert embedded == metadata |
|
|
|
|
| def test_rebuild_state_handles_non_numeric_run_dir_and_bom_workflow(tmp_path: Path): |
| from make_card_audio import _rebuild_state_from_run_dir |
|
|
| run_dir = tmp_path / "run-foo_label" |
| run_dir.mkdir() |
| (run_dir / "workflow.json").write_text( |
| json.dumps({"nodes": []}), |
| encoding="utf-8-sig", |
| ) |
|
|
| state = _rebuild_state_from_run_dir(run_dir) |
|
|
| assert state["run_number"] == 0 |
| assert state["run_label"] == "label" |
|
|
|
|
| def test_rebuild_state_preserves_date_and_splits_repo_sync_metadata(tmp_path: Path): |
| from make_card_audio import _rebuild_state_from_run_dir |
|
|
| run_dir = tmp_path / "run-005_label" |
| run_dir.mkdir() |
| (run_dir / "run005_workflow.json").write_text( |
| json.dumps({"nodes": [], "links": []}), |
| encoding="utf-8", |
| ) |
| (run_dir / "metadata.json").write_text( |
| json.dumps({"run": {"date": "2026-05-01"}}), |
| encoding="utf-8", |
| ) |
| (run_dir / "patch_state.txt").write_text( |
| "\n".join( |
| [ |
| "MAIN SHA : abc1234", |
| "Last dev-sync-audio : def5678 (2026-05-02 11:22)", |
| "Sync scope tag : relay parser", |
| "Sync worktree : ComfyUI-Koolook", |
| "Fork dir status : clean", |
| ] |
| ), |
| encoding="utf-8", |
| ) |
|
|
| state = _rebuild_state_from_run_dir(run_dir) |
| metadata = state["metadata"] |
|
|
| assert state["date"] == "2026-05-01" |
| assert metadata["run"]["date"] == "2026-05-01" |
| assert metadata["repo"]["last_dev_sync_audio"] == "def5678" |
| assert metadata["repo"]["last_dev_sync_at"] == "2026-05-02 11:22" |
|
|
|
|
| def test_extract_multilines_ignores_non_text_multiline_nodes(): |
| nodes = [ |
| {"type": "LTXDirector__koolook_v1_3_2", "title": "NAME"}, |
| _multiline("NAME", "real"), |
| ] |
| out = loop_audio.extract_multilines(nodes, ["name"]) |
| assert out["name"] == ["real"] |
|
|
|
|
| |
|
|
|
|
| def test_parse_timeline_coerces_string_numerics(): |
| """The Comfy frontend sometimes saves numeric segment fields as |
| strings. parse_timeline must coerce them so downstream arithmetic |
| in the renderer (`start / fps`, `<` comparisons) stays type-safe |
| instead of crashing or doing lexical comparisons.""" |
| node = _director(timeline={ |
| "segments": [ |
| {"id": "a", "start": "0", "length": "120", "prompt": "x"}, |
| ], |
| "audioSegments": [], |
| }) |
| tl = loop_audio.parse_timeline(node) |
| seg = tl["segments"][0] |
| assert seg["start"] == 0 |
| assert seg["length"] == 120 |
| assert isinstance(seg["start"], int) |
| assert isinstance(seg["length"], int) |
| |
| assert seg["prompt"] == "x" |
|
|
|
|
| def test_parse_timeline_coerces_float_to_int(): |
| node = _director(timeline={ |
| "segments": [{"start": 1.7, "length": 12.4}], |
| "audioSegments": [], |
| }) |
| tl = loop_audio.parse_timeline(node) |
| |
| |
| assert tl["segments"][0]["start"] == 1 |
| assert tl["segments"][0]["length"] == 12 |
|
|
|
|
| def test_parse_timeline_collapses_bad_values_to_zero(): |
| """A malformed segment shouldn't crash the whole loop — the |
| offending field collapses to 0 and we keep going.""" |
| node = _director(timeline={ |
| "segments": [{"start": "not a number", "length": 100}], |
| "audioSegments": [], |
| }) |
| tl = loop_audio.parse_timeline(node) |
| assert tl["segments"][0]["start"] == 0 |
| assert tl["segments"][0]["length"] == 100 |
|
|
|
|
| def test_parse_timeline_handles_malformed_json(): |
| """Invalid JSON in timeline_data must not raise — the helper |
| returns empty lists so the renderer falls back to "(N=0)" and |
| proceeds.""" |
| bad = _director() |
| bad["widgets_values"][3] = "{not json" |
| assert loop_audio.parse_timeline(bad) == { |
| "segments": [], "audioSegments": [], |
| } |
|
|
|
|
| def test_parse_timeline_handles_missing_director(): |
| assert loop_audio.parse_timeline(None) == { |
| "segments": [], "audioSegments": [], |
| } |
|
|
|
|
| def test_parse_timeline_drops_non_dict_segments(): |
| node = _director(timeline={ |
| "segments": [ |
| {"start": 0, "length": 60}, |
| "not a dict", |
| None, |
| ], |
| "audioSegments": [], |
| }) |
| tl = loop_audio.parse_timeline(node) |
| assert len(tl["segments"]) == 1 |
|
|
|
|
| |
|
|
|
|
| @pytest.mark.parametrize( |
| "video, audio_segs, expected", |
| [ |
| |
| ({"start": 0, "length": 100}, [{"start": 0, "length": 100}], True), |
| |
| ({"start": 0, "length": 100}, [{"start": 50, "length": 50}], True), |
| |
| ({"start": 50, "length": 100}, [{"start": 0, "length": 75}], True), |
| |
| ({"start": 100, "length": 50}, [{"start": 0, "length": 100}], False), |
| |
| ({"start": 0, "length": 50}, [{"start": 100, "length": 50}], False), |
| |
| |
| ({"start": 0, "length": 50}, [{"start": 50, "length": 50}], False), |
| |
| ( |
| {"start": 0, "length": 50}, |
| [{"start": 100, "length": 10}, {"start": 25, "length": 10}], |
| True, |
| ), |
| |
| ({"start": 0, "length": 50}, [], False), |
| ], |
| ) |
| def test_video_segment_has_audio_boundaries(video, audio_segs, expected): |
| assert loop_audio.video_segment_has_audio(video, audio_segs) is expected |
|
|
|
|
| |
|
|
|
|
| @pytest.mark.parametrize( |
| "segments, expected", |
| [ |
| ([], "none"), |
| ([{"prompt": "one prompt"}], "single"), |
| ( |
| [{"prompt": "same prompt"}, {"prompt": "same prompt"}], |
| "same", |
| ), |
| ( |
| [{"prompt": "wide shot"}, {"prompt": "close up"}], |
| "per-segment", |
| ), |
| ( |
| [{"prompt": "wide shot"}, {"prompt": ""}], |
| "missing", |
| ), |
| ], |
| ) |
| def test_segment_prompt_mode_classifies_prompt_sequence(segments, expected): |
| assert loop_audio.segment_prompt_mode(segments) == expected |
|
|
|
|
| |
|
|
|
|
| def test_parse_feedback_extracts_scores_with_lines(): |
| body = ( |
| "Looking solid overall\n" |
| "Sync drifts in the last second\n" |
| "motion: 4/5\n" |
| "sync: 3/5\n" |
| "sharpness: 5/5\n" |
| ) |
| scores, lines = loop_audio.parse_feedback(body) |
| assert scores == {"motion": 4, "sync": 3, "sharp": 5} |
| assert lines == ["Looking solid overall", "Sync drifts in the last second"] |
|
|
|
|
| def test_parse_feedback_preserves_zero_as_score(): |
| """0 is a legitimate score. Pre-fix the log row coerced it to '?' |
| via `or '?'`; parse_feedback itself stores 0 correctly — this test |
| pins that contract so a future refactor can't subtly inject `or 0` |
| semantics.""" |
| scores, _ = loop_audio.parse_feedback("motion: 0/5\nsync: 0\nsharp: 0\n") |
| assert scores == {"motion": 0, "sync": 0, "sharp": 0} |
|
|
|
|
| def test_render_log_row_preserves_zero_scores(): |
| row = loop_audio.render_log_row( |
| 3, |
| _director(), |
| "", |
| "model-gen", |
| {"segments": [], "audioSegments": []}, |
| {"motion": 0, "sync": 0, "sharp": 0}, |
| [], |
| ) |
| assert "M0·S0·Sh0" in row |
|
|
|
|
| def test_render_log_row_records_video_and_audio_segment_counts(): |
| row = loop_audio.render_log_row( |
| 3, |
| _director(), |
| "", |
| "custom", |
| {"segments": [{}, {}], "audioSegments": [{}, {}]}, |
| {"motion": None, "sync": None, "sharp": None}, |
| [], |
| ) |
| assert "| custom | 2v/2a |" in row |
|
|
|
|
| def test_parse_feedback_accepts_sharpness_alias(): |
| """Both 'sharp' and 'sharpness' are accepted axis names. Both map |
| to the 'sharp' key.""" |
| scores, _ = loop_audio.parse_feedback("sharpness: 4\n") |
| assert scores["sharp"] == 4 |
|
|
|
|
| def test_parse_feedback_case_insensitive(): |
| scores, _ = loop_audio.parse_feedback("MOTION: 3\nSync 4\n") |
| assert scores["motion"] == 3 |
| assert scores["sync"] == 4 |
|
|
|
|
| def test_parse_feedback_empty_body_returns_blank_scores(): |
| scores, lines = loop_audio.parse_feedback("") |
| assert scores == {"motion": None, "sync": None, "sharp": None} |
| assert lines == [] |
|
|
|
|
| |
|
|
|
|
| def test_is_input_wired_none_when_director_missing(): |
| assert loop_audio.is_input_wired(None, "audio_vae") is None |
|
|
|
|
| def test_is_input_wired_true_when_link_set(): |
| node = {"inputs": [{"name": "audio_vae", "link": 42}]} |
| assert loop_audio.is_input_wired(node, "audio_vae") is True |
|
|
|
|
| def test_is_input_wired_false_when_link_null(): |
| node = {"inputs": [{"name": "audio_vae", "link": None}]} |
| assert loop_audio.is_input_wired(node, "audio_vae") is False |
|
|
|
|
| def test_is_input_wired_none_when_input_socket_absent(): |
| """An older Director schema might not have the named socket at |
| all — same outcome as 'unwired' at runtime (no audio latent |
| produced), but a distinct value here so callers can tell.""" |
| node = {"inputs": [{"name": "model", "link": 1}]} |
| assert loop_audio.is_input_wired(node, "audio_vae") is None |
|
|
|
|
| |
|
|
|
|
| def test_wrap_path_breaks_on_separator(): |
| out = loop_audio.wrap_path( |
| "e:/G-Drive-BaconX/Jobs/Jeep_Animals/ComfyUI_LTX23/Phase2", |
| max_chars=30, |
| ) |
| |
| for line in out: |
| assert "/" not in line |
| joined = "".join(out) |
| assert joined == "e:\\G-Drive-BaconX\\Jobs\\Jeep_Animals\\ComfyUI_LTX23\\Phase2" |
|
|
|
|
| def test_wrap_path_returns_single_empty_string_for_empty_input(): |
| assert loop_audio.wrap_path("") == [""] |
|
|
|
|
| def test_wrap_path_handles_single_segment_longer_than_max(): |
| """A directory name longer than max_chars still gets its own |
| line — we never split mid-name.""" |
| out = loop_audio.wrap_path("verylongsingledirectoryname", max_chars=10) |
| assert len(out) == 1 |
| assert out[0] == "verylongsingledirectoryname" |
|
|
|
|
| |
|
|
|
|
| def test_pick_existing_path_prefers_real_directory(tmp_path): |
| real = tmp_path / "real" |
| real.mkdir() |
| out = loop_audio.pick_existing_path([ |
| "Z:/never-exists", |
| str(real), |
| "Y:/also-never", |
| ]) |
| assert out == str(real) |
|
|
|
|
| def test_pick_existing_path_falls_back_to_first_nonempty(): |
| out = loop_audio.pick_existing_path(["Z:/never-exists", "Y:/also-never"]) |
| assert out == "Z:/never-exists" |
|
|
|
|
| def test_pick_existing_path_returns_empty_when_all_empty(): |
| assert loop_audio.pick_existing_path(["", " ", "\""]) == "" |
|
|
|
|
| |
|
|
|
|
| def test_first_multiline_returns_first_or_empty(): |
| assert loop_audio.first_multiline({"name": ["foo", "bar"]}, "name") == "foo" |
| assert loop_audio.first_multiline({}, "name") == "" |
| assert loop_audio.first_multiline({"name": []}, "name") == "" |
|
|
|
|
| def test_autogen_label_when_director_missing(): |
| label = loop_audio.autogen_label("Bear_3x", None, "") |
| assert "missing" in label |
| assert "audio-off" in label |
|
|
|
|
| def test_autogen_label_when_director_present(): |
| node = _director(use_custom_audio=True, audio_vae_link=42) |
| label = loop_audio.autogen_label( |
| "Bear_3x", node, '{"video_strength": 10.0}' |
| ) |
| assert "koolook" in label |
| assert "audio-on" in label |
| assert "vstr10.0" in label |
|
|
|
|
| def test_autogen_label_when_director_is_upstream(): |
| label = loop_audio.autogen_label( |
| "Bear_3x", _director(node_type="LTXDirector"), "" |
| ) |
| assert "upstream" in label |
| assert "audio-off" in label |
|
|
|
|
| def test_relay_overrides_txt_marks_upstream_director_inert(): |
| txt = loop_audio.render_relay_overrides_txt( |
| '{"video_strength": 10.0}', |
| _director(node_type="LTXDirector"), |
| ) |
| assert "INERT" in txt |
| assert "LTXDirector" in txt |
|
|
|
|
| def test_next_run_number_reads_folders_and_log(tmp_path): |
| runs = tmp_path / "runs" |
| runs.mkdir() |
| (runs / "run-001_alpha").mkdir() |
| (runs / "log.md").write_text( |
| "| Run | Date |\n" |
| "|---|---|\n" |
| "| 002 | 2026-05-29 |\n", |
| encoding="utf-8", |
| ) |
| assert loop_audio.next_run_number(runs) == 3 |
|
|