Files
Claude f7419f2156 feat(manager): rebuild the Test Runner as a scenario workbench
The Test Runner page is now a workbench on the real engine: a rail of
saved scenarios, a stack builder that lines library modules and bundles
up like a Context node, pinned values, a seed range, and results in
Variables, Outputs, Samples and Warnings tabs with a per-seed trace.

Every run goes through POST /wp/api/test/run, so the numbers match what
the canvas produces for the same seeds. Send to Test Runner opens an
unsaved quick run and runs it straight away.

The old single-module sampler and its in-browser simulator are removed:
the /wp/api/test route, api.test, TestRequest/TestResponse and
utils/resolver.ts.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xwq95EvrKu6WgE9D5rahKy
2026-09-26 02:15:17 +00:00

182 lines
7.5 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Walker + SnapshotEntry tests. Mirrors spec §2.4–2.10 + §6 (5.5.0).
The walker is the single shared lazy-walk implementation used by:
- wp_api/test_runner.py:_build_catalog (scenario stack roots)
- wp_api/modules.py:embed_bundle (user pick roots)
NEVER called from wp_nodes/context_node.py — graph runs use pre-walked
snapshots embedded in workflow JSON."""
from __future__ import annotations
import importlib
import sys
def test_snapshot_entry_canonical_shape():
"""SnapshotEntry has the 7 fields locked in spec §2.4. Other code
grep-asserts these names; never rename without updating callers."""
from engine.modules.snapshot import SnapshotEntry
# SnapshotEntry is a TypedDict — read its annotations
annotations = SnapshotEntry.__annotations__
assert set(annotations.keys()) == {
"snapshot_version", "uuid", "type", "name",
"payload", "payload_hash", "source",
}
def test_engine_modules_snapshot_does_not_import_db_or_wp_api():
"""Engine isolation: snapshot.py must be importable with no DB layer
in sys.modules. CLAUDE.md isolation rule — engine/ never knows about
wp_api/ or wp_nodes/. Any future contributor adding `from engine.db
import ...` here breaks the runtime separation."""
# Force fresh import to capture import-time side effects
if "engine.modules.snapshot" in sys.modules:
del sys.modules["engine.modules.snapshot"]
importlib.import_module("engine.modules.snapshot")
mod = sys.modules["engine.modules.snapshot"]
forbidden_prefixes = ("engine.db", "wp_api", "wp_nodes", "comfy_api")
for name in dir(mod):
attr = getattr(mod, name)
attr_module = getattr(attr, "__module__", "")
for prefix in forbidden_prefixes:
assert not attr_module.startswith(prefix), (
f"engine.modules.snapshot leaks {prefix} via attribute "
f"{name!r} (came from {attr_module})"
)
# ---------------------------------------------------------------------------
# Walker tests (Task 5)
# ---------------------------------------------------------------------------
def _make_module(uuid: str, *, type: str = "wildcard", name: str | None = None,
payload: dict | None = None) -> dict:
"""Test helper — builds a row matching what repo.get_by_uuid returns."""
p = payload or {"options": [{"value": "x", "weight": 1}]}
return {
"id": f"wc_{name or uuid}_{uuid}",
"uuid": uuid,
"type": type,
"name": name or uuid,
"payload": p,
"payload_hash": "deadbeef" * 8, # actual hash not tested here
}
def test_walk_single_module_no_refs_returns_one_entry():
from engine.modules.snapshot import walk_transitive_refs
catalog = {"a1111111": _make_module("a1111111")}
result = walk_transitive_refs(
["a1111111"], fetch_module=catalog.get,
)
assert set(result.snapshots.keys()) == {"a1111111"}
assert result.snapshots["a1111111"]["source"] == {"kind": "user"}
assert result.walk_overflow == []
def test_walk_follows_at_uuid_refs_marks_deps():
from engine.modules.snapshot import walk_transitive_refs
# outfit references @{color_uuid}
catalog = {
"aa111111": _make_module("aa111111", name="outfit", payload={
"options": [{"value": "@{bb222222} dress", "weight": 1}],
}),
"bb222222": _make_module("bb222222", name="color", payload={
"options": [{"value": "red", "weight": 1}],
}),
}
result = walk_transitive_refs(["aa111111"], fetch_module=catalog.get)
assert set(result.snapshots.keys()) == {"aa111111", "bb222222"}
assert result.snapshots["aa111111"]["source"] == {"kind": "user"}
assert result.snapshots["bb222222"]["source"] == {
"kind": "dep", "parent_uuids": ["aa111111"],
}
def test_walk_records_missing_target_overflow():
from engine.modules.snapshot import walk_transitive_refs
catalog = {
"a1111111": _make_module("a1111111", payload={
"options": [{"value": "@{deadc0de}", "weight": 1}],
}),
}
result = walk_transitive_refs(["a1111111"], fetch_module=catalog.get)
# Walker returns the picked module but records the missing dep
assert "a1111111" in result.snapshots
assert "deadc0de" not in result.snapshots
assert {"uuid": "deadc0de", "reason": "missing_target"} in result.walk_overflow
def test_walk_records_max_depth_overflow():
"""Spec §2.10 + §6 — walker honors max_ref_depth=8 cap."""
from engine.modules.snapshot import walk_transitive_refs
# Build a 10-deep linear chain: a → b → c → ... → j
chain_uuids = [f"a{i:07x}" for i in range(10)]
catalog = {}
for i, u in enumerate(chain_uuids):
next_ref = f"@{{{chain_uuids[i+1]}}}" if i + 1 < len(chain_uuids) else "leaf"
catalog[u] = _make_module(u, payload={
"options": [{"value": next_ref, "weight": 1}],
})
result = walk_transitive_refs(
[chain_uuids[0]], fetch_module=catalog.get, max_depth=8,
)
# First 9 (depth 0..8 inclusive) are walked, 10th is overflow
assert len(result.snapshots) == 9
assert any(o["reason"] == "max_depth" for o in result.walk_overflow)
def test_walk_handles_cycle_records_overflow():
"""A → B → A. Walker terminates, records cycle, includes both."""
from engine.modules.snapshot import walk_transitive_refs
catalog = {
"a1111111": _make_module("a1111111", payload={
"options": [{"value": "@{b2222222}", "weight": 1}],
}),
"b2222222": _make_module("b2222222", payload={
"options": [{"value": "@{a1111111}", "weight": 1}],
}),
}
result = walk_transitive_refs(["a1111111"], fetch_module=catalog.get)
assert set(result.snapshots.keys()) == {"a1111111", "b2222222"}
assert any(o["reason"] == "cycle_detected" for o in result.walk_overflow)
def test_walk_multi_parent_dep_lists_both_parents():
"""A and C both ref B. B's source.parent_uuids contains both."""
from engine.modules.snapshot import walk_transitive_refs
catalog = {
"a1111111": _make_module("a1111111", payload={
"options": [{"value": "@{b2222222}", "weight": 1}],
}),
"c3333333": _make_module("c3333333", payload={
"options": [{"value": "@{b2222222}", "weight": 1}],
}),
"b2222222": _make_module("b2222222"),
}
result = walk_transitive_refs(
["a1111111", "c3333333"], fetch_module=catalog.get,
)
assert result.snapshots["b2222222"]["source"]["kind"] == "dep"
assert sorted(
result.snapshots["b2222222"]["source"]["parent_uuids"]
) == ["a1111111", "c3333333"]
def test_walk_skips_non_wildcard_picks_at_root():
"""Spec §2.7 — catalog only ever contains wildcards. If a non-wildcard
is in the roots list (e.g. user picked a Combine), walker emits it but
does NOT walk its payload for refs (combines aren't @{}-targets)."""
from engine.modules.snapshot import walk_transitive_refs
catalog = {
"dd111111": _make_module("dd111111", type="combine", payload={
"template": "$alpha and @{cc333333}", # ref ignored
}),
"cc333333": _make_module("cc333333"),
}
result = walk_transitive_refs(["dd111111"], fetch_module=catalog.get)
# Combine present at root level (the picker still wants to embed it),
# but its @{} refs are NOT followed because non-wildcards do not
# participate in the catalog.
assert "dd111111" in result.snapshots
assert "cc333333" not in result.snapshots