Files
DumiFlex-ComfyUI-Wildcard-P…/tests/wp_nodes/test_context_bundle_gate.py
T
DumiFlex d69863e4a9 feat(bundles): nesting data model — BundleInstance.parent_uid + insertion mints inner instances + gate ANDs through parent chain
The data model for tier-2 nesting. Three layers move together:

1. BundleInstance gains an optional parent_uid field. Top-level bundles leave it null (or omit); a nested bundle inserted via the new picker writes parent_uid = outer._uid. Tier-2 API cap (wp_api/bundles.py) keeps this chain at most one hop deep.

2. buildBundleInsertion stops the prior pre-flatten-and-dissolve behaviour. For each bundle-typed child of the entry being inserted, it mints a NEW BundleInstance (parent_uid pointing at the outer) and stamps each grandchild's bundle_origin at the inner's _uid. Direct leaves still belong to the outer. Returns BundleInsertion.innerInstances[] alongside the existing bundleInstance — caller pushes both into bundles[]. ContextWidget.insertBundle now spreads innerInstances into the new bundles cache; the reset-to-library path drops any old inner instances of the targeted outer and pushes the freshly minted ones with parent_uid rewritten to the existing outer._uid (so the visible frame doesn't churn).

3. The enabled gate walks parent_uid up the chain. Engine boundary (wp_nodes/types.py:deserialize_node_input) maintains a parent_uid lookup alongside the enabled lookup; for each module, _bundle_chain_enabled walks ancestors and returns false on the first disabled one. Defensive depth cap (8) absorbs corrupt cycles. The frontend mirror (src/widgets/_shared.ts:isModuleEffectivelyEnabled) does the same walk; buildBundleEnabledMap now returns BundleEnabledIndex { enabled, parent } so every reader gets both maps in one build pass.

Effective enabled = child.enabled AND immediate-bundle.enabled AND every ancestor bundle's enabled. Disabling the outer disables every inner leaf; disabling an inner only disables its own leaves; the outer's direct leaves stay untouched.

New tests: 4 vitest cases for nested gate behaviour (ancestor disabled cascades, both enabled = pass, only inner off = inner-only gated, outer-direct leaf unaffected) + 4 pytest cases (engine-boundary equivalent, plus cycle-safety). insert.test.ts updated to assert the new BundleInsertion.innerInstances field + correct outer/inner bundle_origin stamping.

This commit lands the data model only. Frame renderer + cascade scopes + drag-drop + save-to-library round-trip ship in follow-ups.
2026-05-20 21:15:05 +03:00

183 lines
7.2 KiB
Python

"""Bundle.enabled is a non-destructive gate, not a mutator.
Previously the SPA cascaded `BundleInstance.enabled` into every child's
`module.enabled` on toggle. Disabling a bundle clobbered each child's
individual on/off state; re-enabling restored them all to `true`,
regardless of who was actually off before. Users couldn't keep a
"4 disabled, 3 enabled" composition through a bundle-level toggle.
Now `child.instance.enabled` is preserved verbatim. The engine sees the
effective view via `deserialize_node_input`, which ANDs each bundle's
enabled flag with its children's at the wire boundary. Bundle gate flips
purely cosmetically on the workflow state; the engine reads the gated
shape.
Effective enabled = bundle.enabled AND child.enabled.
"""
from __future__ import annotations
from wp_nodes.types import deserialize_node_input
def _wildcard(mid: str, *, enabled: bool, bundle_origin: str | None = None) -> dict:
row: dict = {
"id": mid,
"_uid": f"u-{mid}",
"type": "wildcard",
"enabled": enabled,
"meta": {"name": mid},
"entries": [],
"payload": {"options": [{"id": "o", "value": "x", "weight": 1}]},
}
if bundle_origin is not None:
row["bundle_origin"] = bundle_origin
return row
def test_no_bundles_returns_modules_verbatim():
"""Without a `bundles[]` field, the gate is a no-op."""
raw = {"modules": [_wildcard("m1", enabled=True), _wildcard("m2", enabled=False)]}
modules, _, _ = deserialize_node_input(raw)
assert [m["enabled"] for m in modules] == [True, False]
def test_disabled_bundle_gates_its_children_only():
"""A disabled bundle masks `enabled=False` onto every child whose
`bundle_origin` matches. Children outside the bundle are untouched."""
raw = {
"bundles": [{"_uid": "B1", "enabled": False, "start_idx": 0, "end_idx": 1}],
"modules": [
_wildcard("m1", enabled=True, bundle_origin="B1"),
_wildcard("m2", enabled=True, bundle_origin="B1"),
_wildcard("m3", enabled=True), # outside bundle
],
}
modules, _, _ = deserialize_node_input(raw)
assert [m["enabled"] for m in modules] == [False, False, True]
def test_enabled_bundle_passes_children_through_unchanged():
"""When `bundle.enabled` is true, the gate is a no-op for that
bundle's children — they keep their individual on/off."""
raw = {
"bundles": [{"_uid": "B1", "enabled": True, "start_idx": 0, "end_idx": 1}],
"modules": [
_wildcard("m1", enabled=True, bundle_origin="B1"),
_wildcard("m2", enabled=False, bundle_origin="B1"),
],
}
modules, _, _ = deserialize_node_input(raw)
assert [m["enabled"] for m in modules] == [True, False]
def test_gate_preserves_individual_state_on_the_original_row():
"""The gate must clone the row rather than mutate. The SPA holds onto
the same dict for undo + state restore; mutating it here would mean
re-enabling the bundle no longer restores the child's previous state."""
child = _wildcard("m1", enabled=True, bundle_origin="B1")
raw = {
"bundles": [{"_uid": "B1", "enabled": False, "start_idx": 0, "end_idx": 0}],
"modules": [child],
}
modules, _, _ = deserialize_node_input(raw)
assert modules[0]["enabled"] is False # engine-facing view
assert child["enabled"] is True # original row preserved
def test_gate_skips_orphan_bundle_origin():
"""If `bundle_origin` points at a bundle uid that isn't in the
`bundles[]` list (stale / removed), the row passes through with its
own enabled flag intact rather than being gated to false."""
raw = {
"bundles": [{"_uid": "B1", "enabled": False, "start_idx": 0, "end_idx": 0}],
"modules": [
_wildcard("m1", enabled=True, bundle_origin="STALE"),
],
}
modules, _, _ = deserialize_node_input(raw)
assert modules[0]["enabled"] is True
def test_gate_handles_missing_bundle_origin():
"""Top-level (non-bundle) modules have no `bundle_origin` — the gate
leaves them strictly untouched even when other bundles are disabled."""
raw = {
"bundles": [{"_uid": "B1", "enabled": False, "start_idx": 0, "end_idx": 0}],
"modules": [
_wildcard("m1", enabled=True, bundle_origin="B1"),
_wildcard("m2", enabled=True),
],
}
modules, _, _ = deserialize_node_input(raw)
assert [m["enabled"] for m in modules] == [False, True]
# ── Tier-2 nesting: parent_uid chain ──────────────────────────────────────
def test_gate_ands_through_parent_chain_disabled_outer():
"""An inner bundle (parent_uid → outer) inherits the outer's
disabled state. The leaf's bundle_origin points at the INNER, but
the gate walks parent_uid up and ANDs through the chain — disabled
outer disables every leaf in every inner."""
raw = {
"bundles": [
{"_uid": "OUTER", "enabled": False, "start_idx": 0, "end_idx": 0},
{"_uid": "INNER", "enabled": True, "parent_uid": "OUTER",
"start_idx": 0, "end_idx": 0},
],
"modules": [_wildcard("m1", enabled=True, bundle_origin="INNER")],
}
modules, _, _ = deserialize_node_input(raw)
assert modules[0]["enabled"] is False
def test_gate_allows_when_both_inner_and_outer_enabled():
raw = {
"bundles": [
{"_uid": "OUTER", "enabled": True, "start_idx": 0, "end_idx": 0},
{"_uid": "INNER", "enabled": True, "parent_uid": "OUTER",
"start_idx": 0, "end_idx": 0},
],
"modules": [_wildcard("m1", enabled=True, bundle_origin="INNER")],
}
modules, _, _ = deserialize_node_input(raw)
assert modules[0]["enabled"] is True
def test_gate_disables_when_only_inner_is_off():
"""Outer enabled, inner disabled — inner-leaf disabled."""
raw = {
"bundles": [
{"_uid": "OUTER", "enabled": True, "start_idx": 0, "end_idx": 1},
{"_uid": "INNER", "enabled": False, "parent_uid": "OUTER",
"start_idx": 0, "end_idx": 0},
],
"modules": [
_wildcard("inner_leaf", enabled=True, bundle_origin="INNER"),
_wildcard("outer_leaf", enabled=True, bundle_origin="OUTER"),
],
}
modules, _, _ = deserialize_node_input(raw)
# Inner-leaf gated off; outer-direct leaf still on.
assert [m["enabled"] for m in modules] == [False, True]
def test_gate_walk_terminates_on_cycle():
"""Defensive depth cap — a corrupt cycle in parent_uid must not
spin forever. Gate falls through with the leaf's own enabled."""
raw = {
"bundles": [
{"_uid": "A", "enabled": True, "parent_uid": "B",
"start_idx": 0, "end_idx": 0},
{"_uid": "B", "enabled": True, "parent_uid": "A",
"start_idx": 0, "end_idx": 0},
],
"modules": [_wildcard("m1", enabled=True, bundle_origin="A")],
}
# Should NOT raise; just return the row as-is (or gated off if
# any ancestor in the partial walk was disabled — both A and B
# are enabled here, so the result is True).
modules, _, _ = deserialize_node_input(raw)
assert modules[0]["enabled"] is True