Files
ComfyAssets-ComfyUI_PromptM…/tests/test_ingestion_resolver_profile.py
Vito 36564b4767 feat(parse): one generation-parameter parser in Python; scan links what the resolver cannot decide (#253)
* feat(parse): port the browser generation-parameter parser to Python

Adds utils/generation_params.py, a line-for-line port of
extractGenerationParams in web/js/comfy-metadata.js, the parser behind
the gallery, admin and metadata pages. The scan and live monitor can use
it so every surface reads prompts the same way. The JavaScript parser is
unchanged.

The port reproduces the JavaScript value semantics the result depends on
(property order, truthiness, String(), trim(), strict equality, Map keys,
JSON.parse number rounding, and the inputs on which it throws), and runs
its recursive tracing on an explicit stack so crafted graphs cannot
exhaust the Python stack.

Tests:
- behaviour tests for each tracing rule, with JavaScript results as
  expected values, including the visit-limit boundary;
- golden vectors of the JavaScript parser's results for every named case
  and every PNG fixture, so the comparison runs without Node;
- a live comparison against the JavaScript parser when Node is present;
- a randomized differential test over 9,000 generated graphs and
  workflows, coherent and corrupted, including inputs where the
  JavaScript throws.

Five deliberate porting mistakes (trim, truthiness, prepend handling,
visit limit, pair slot) each fail the tests. On a 38,602-file output
folder, all 18,491 files with a workflow give identical results.

* fix(scan): link the traced prompt when the pair resolver cannot decide

The output scan skipped every file the pair resolver called ambiguous or
unresolved, so workflows where several samplers share one prompt (a base
pass, an upscale pass and a detailer) never reached the Library from a
scan. It now links the positive prompt the shared parser traces for
those files, the prompt the inspector and the legacy pages show. Files
the resolver decides are stored as before, and graphs without a
recognized sampler keep the legacy extractor (#252).

Read-only scans of a 38,602-file output folder into temporary databases:
- links: 1,500 before, 7,954 after; none of the 1,500 dropped or changed;
- of the added links that differ from the pre-redesign scan, 1,275
  replace a number, a LoRA tag, a fragment or a truncated prompt that
  scan stored.

* refactor(parse): return a uniform pair from the SameValueZero key
2026-10-19 20:56:51 -07:00

93 lines
3.6 KiB
Python

"""Ingestion keeps the default resolver profile; only inspection routes moved (D1).
The live monitor and the admin scan write pairs and links to the user's
database, so their resolver semantics stay byte-for-byte today's. f03 (506
executable nodes) separates the profiles: the default profile's 500-node gate
leaves it unresolved, while the inspection profile resolves it.
"""
import logging
import unittest
from pathlib import Path
from unittest.mock import Mock, patch
from utils import prompt_pair_resolver as resolver
try:
from tests import inspection_parity as parity
except ImportError: # pragma: no cover - discover -s tests puts tests/ first
import inspection_parity as parity
GATED = "Resolution limit reached"
def _f03_metadata():
return {"prompt": parity.build("f03")["prompt"]}
class _Spy:
"""Wrap the real resolver, recording each call's profile and outcome."""
def __init__(self):
self.calls = []
def __call__(self, metadata, **kwargs):
result = resolver.resolve_prompt_pair(metadata, **kwargs)
self.calls.append((kwargs.get("profile", resolver.DEFAULT_PROFILE), result))
return result
def assert_default_and_gated(self, case):
case.assertTrue(self.calls, "the ingestion path resolved the metadata")
for profile, result in self.calls:
case.assertIs(profile, resolver.DEFAULT_PROFILE)
case.assertEqual((result["state"], result["reason"]), ("unresolved", GATED))
class TestIngestionKeepsDefaultProfile(unittest.TestCase):
def test_f03_separates_the_profiles(self):
metadata = _f03_metadata()
default = resolver.resolve_prompt_pair(metadata)
self.assertEqual((default["state"], default["reason"]), ("unresolved", GATED))
inspection = resolver.resolve_prompt_pair(
metadata, profile=resolver.INSPECTION_PROFILE
)
self.assertEqual(inspection["state"], "resolved")
def test_live_monitor_lookup_and_linking_use_the_default_profile(self):
from utils.image_monitor import ImageGenerationHandler
handler = ImageGenerationHandler.__new__(ImageGenerationHandler)
handler.db_manager = Mock()
handler.db_manager.get_prompt_by_hash.return_value = None
handler.logger = logging.getLogger("test.ingestion-profile")
spy, metadata = _Spy(), _f03_metadata()
with patch("utils.image_monitor.resolve_prompt_pair", spy):
handler._find_prompt_from_metadata(metadata)
handler.link_image_to_prompt("/out/f03.png", {"id": 1}, metadata)
self.assertEqual(len(spy.calls), 2)
spy.assert_default_and_gated(self)
handler.db_manager.link_image_to_prompt.assert_called_once()
def test_admin_scan_ingestion_uses_the_default_profile(self):
from py.api import admin
mixin = admin.AdminRoutesMixin.__new__(admin.AdminRoutesMixin)
mixin.db = Mock()
mixin.db.get_prompt_by_hash.return_value = None
mixin.db.save_prompt.return_value = 7
spy = _Spy()
with patch.object(admin, "resolve_prompt_pair", spy):
status = mixin._ingest_scanned_file(Path("/out/f03.png"), _f03_metadata())
spy.assert_default_and_gated(self)
# The resolver leaves f03 unresolved; the scan links the prompt the
# browser parser traces, as the inspector shows it.
self.assertEqual(status, "added")
self.assertEqual(
mixin.db.save_prompt.call_args.args[0], "a red fox in snow, detailed fur"
)
mixin.db.link_image_to_prompt.assert_called_once()
if __name__ == "__main__":
unittest.main()