Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06c380fd01 | ||
|
|
c48aafb143 | ||
|
|
ff4d8faa1f | ||
|
|
c7c867e531 | ||
|
|
2fb853fa81 | ||
|
|
e1885a3115 | ||
|
|
f38a316ccb | ||
|
|
e56fd22bc9 | ||
|
|
b33afd8797 | ||
|
|
67a09df6d6 | ||
|
|
1f164df0c5 | ||
|
|
91b5f7581a | ||
|
|
3527ada397 | ||
|
|
25388b4650 | ||
|
|
6eb4b730cb | ||
|
|
942834d16a | ||
|
|
03796c56ba | ||
|
|
65683aed7c | ||
|
|
8a9dc9efbd | ||
|
|
8f696d6eec | ||
|
|
1822297a9a | ||
|
|
21f285033d | ||
|
|
bb5e84d3b2 | ||
|
|
b292699487 | ||
|
|
39fdb6f187 | ||
|
|
3c492f1cb9 | ||
|
|
79cf3857ad | ||
|
|
e93f7ba06e | ||
|
|
4e71e043e7 | ||
|
|
7b416ad0ed | ||
|
|
ece6d49929 | ||
|
|
6dc033d57e | ||
|
|
9663636d10 | ||
|
|
8910b98a86 | ||
|
|
10dcac4100 | ||
|
|
15c1ade3ef | ||
|
|
8871a2806d | ||
|
|
072e4e7836 | ||
|
|
0f7aaabe3c |
@@ -1,3 +1,52 @@
|
||||
## 🎉 Wildcard Pipeline 2.8.0
|
||||
|
||||
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
|
||||
|
||||
<details>
|
||||
<summary><b>📋 Full changelog</b> — click to expand the per-commit list</summary>
|
||||
|
||||
|
||||
* fix(context): add hover states to the per-kind edit modal footer buttons ([b292699](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b292699))
|
||||
* fix(context): capture the loop's per-frame seeds so the lock pins the previous roll ([b33afd8](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b33afd8))
|
||||
* fix(context): clear redundant frame lock-null override when the base unlocks ([f38a316](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/f38a316))
|
||||
* fix(context): coerce disabled_frames to int set and guard frame toggle ([65683ae](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/65683ae))
|
||||
* fix(context): modal lock button pins the rolled seed via injected resolver ([e56fd22](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/e56fd22))
|
||||
* fix(context): name the disabled toggles in the per-frame runtime hint + wrap loop frame chips ([2fb853f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/2fb853f))
|
||||
* fix(context): per-frame enable overrides base enabled (frame_enabled) ([6eb4b73](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/6eb4b73))
|
||||
* fix(context): per-frame seed lock captures that frame's own rolled seed ([25388b4](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/25388b4))
|
||||
* fix(context): per-frame seed lock computes the frame's real seed from the loop ([1f164df](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/1f164df))
|
||||
* fix(context): restrict per-frame overrides to an allowlist of meaningful fields ([bb5e84d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/bb5e84d))
|
||||
* fix(context): resync editor draft on frame revert + restyle the banner for readability ([39fdb6f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/39fdb6f))
|
||||
* fix(context): scope the wrapping grid to the frame chips only ([c48aafb](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c48aafb))
|
||||
* fix(context): stack the frame banner above the editor card instead of beside it ([3c492f1](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/3c492f1))
|
||||
* fix(context): sweep redundant frame lock-null when base unlocks via the modal ([e1885a3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/e1885a3))
|
||||
* fix(engine): apply iteration_overrides for generic-object modules ([942834d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/942834d))
|
||||
* fix(engine): include disabled_frames in the generic-object snapshot keys ([03796c5](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/03796c5))
|
||||
* fix(engine): stop double-shifting the per-iteration override seed ([3527ada](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/3527ada))
|
||||
* fix(extension): read loop seed and count as numbers for per-frame seed lock ([67a09df](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/67a09df))
|
||||
* feat(context-loop): add edit-frame selector chip row ([ece6d49](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ece6d49))
|
||||
* feat(context-loop): add session frame-cursor singleton ([6dc033d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/6dc033d))
|
||||
* feat(context): add hold-across-run toggle to the runtime sections ([9663636](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/9663636))
|
||||
* feat(context): add lock-previous-seed to the iteration-seed modals ([c7c867e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c7c867e))
|
||||
* feat(context): add pure frame-override helpers ([7b416ad](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/7b416ad))
|
||||
* feat(context): disable run-level controls while editing a frame ([1822297](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/1822297))
|
||||
* feat(context): frame banner with revert-to-base in the module editor ([79cf385](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/79cf385))
|
||||
* feat(context): held and per-frame override row badges ([e93f7ba](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/e93f7ba))
|
||||
* feat(context): make the row quick-lock act on the active frame ([21f2850](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/21f2850))
|
||||
* feat(context): per-frame enable/disable via disabled_frames ([8a9dc9e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8a9dc9e))
|
||||
* feat(context): redirect edits into iteration_overrides when a frame is active ([4e71e04](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/4e71e04))
|
||||
* feat(engine): apply per-frame iteration_overrides onto module instance ([10dcac4](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/10dcac4))
|
||||
* feat(engine): combine honors hold seed_scope ([8871a28](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8871a28))
|
||||
* feat(engine): fixed_values honors hold seed_scope ([15c1ade](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/15c1ade))
|
||||
* feat(engine): thread a hold seed and loop index into the pipeline run ([0f7aaab](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0f7aaab))
|
||||
* feat(engine): wildcard honors hold seed_scope, locked_seed still wins ([072e4e7](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/072e4e7))
|
||||
* feat(manager): snapshot source/target tag_groups into published constraints ([ff4d8fa](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ff4d8fa))
|
||||
* feat(types): add seed_scope and iteration_overrides to module config ([8910b98](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8910b98))
|
||||
* revert(context): drop per-frame seed capture, superseded by double-shift fix ([91b5f75](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/91b5f75))
|
||||
* refactor(context): unify frame-hint markup and add false-case coverage ([8f696d6](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8f696d6))
|
||||
|
||||
</details>
|
||||
|
||||
## 🎉 Wildcard Pipeline 2.7.0
|
||||
|
||||
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
|
||||
|
||||
@@ -101,7 +101,10 @@ class CombineHandler(ModuleHandler):
|
||||
# - chain seed otherwise → re-rolls each queue
|
||||
# Both feed derive_module_rng(seed, output_var) so locking with
|
||||
# locked_seed = chain_seed reproduces the unlocked roll exactly.
|
||||
chain_seed = int(ctx.get("__wp_node_seed__", 0) or 0)
|
||||
if instance.get("seed_scope") == "hold":
|
||||
chain_seed = int(ctx.get("__wp_node_seed_hold__", ctx.get("__wp_node_seed__", 0)) or 0)
|
||||
else:
|
||||
chain_seed = int(ctx.get("__wp_node_seed__", 0) or 0)
|
||||
locked_seed = instance.get("locked_seed")
|
||||
if isinstance(locked_seed, (int, float)):
|
||||
effective_seed = int(locked_seed)
|
||||
|
||||
@@ -150,7 +150,12 @@ class FixedValuesHandler(ModuleHandler):
|
||||
return out
|
||||
|
||||
# Effective seed selection (mirrors WildcardHandler + CombineHandler):
|
||||
chain_seed = int(ctx.get("__wp_node_seed__", 0) or 0)
|
||||
if instance.get("seed_scope") == "hold":
|
||||
chain_seed = int(
|
||||
ctx.get("__wp_node_seed_hold__", ctx.get("__wp_node_seed__", 0)) or 0
|
||||
)
|
||||
else:
|
||||
chain_seed = int(ctx.get("__wp_node_seed__", 0) or 0)
|
||||
locked_seed = instance.get("locked_seed")
|
||||
if isinstance(locked_seed, (int, float)):
|
||||
effective_seed = int(locked_seed)
|
||||
|
||||
@@ -645,7 +645,10 @@ class WildcardHandler(ModuleHandler):
|
||||
# unlocked pick exactly. This is the contract the lock UX
|
||||
# relies on: the user sees a roll, locks it, captures the
|
||||
# current chain seed, future runs reproduce the same roll.
|
||||
chain_seed = int(ctx.get("__wp_node_seed__", 0) or 0)
|
||||
if instance.get("seed_scope") == "hold":
|
||||
chain_seed = int(ctx.get("__wp_node_seed_hold__", ctx.get("__wp_node_seed__", 0)) or 0)
|
||||
else:
|
||||
chain_seed = int(ctx.get("__wp_node_seed__", 0) or 0)
|
||||
locked_seed = instance.get("locked_seed")
|
||||
if locked_seed is not None:
|
||||
try:
|
||||
|
||||
+67
-8
@@ -244,10 +244,14 @@ class PipelineEngine:
|
||||
modules: list[Module],
|
||||
ctx: Context | None = None,
|
||||
seed: int = 0,
|
||||
hold_seed: int | None = None,
|
||||
loop_index: int = 0,
|
||||
) -> Context:
|
||||
"""Execute modules top-to-bottom, mutating ctx, returning ctx."""
|
||||
ctx = {} if ctx is None else ctx
|
||||
ctx["__wp_node_seed__"] = seed
|
||||
ctx["__wp_node_seed_hold__"] = hold_seed if hold_seed is not None else seed
|
||||
ctx["__wp_loop_index__"] = loop_index
|
||||
ctx["__wp_rng__"] = random.Random(seed)
|
||||
ctx.setdefault("__wp_warnings__", [])
|
||||
ctx.setdefault("__wp_trace__", [])
|
||||
@@ -296,18 +300,52 @@ class PipelineEngine:
|
||||
_module_name = getattr(module, "name", "") or ""
|
||||
_module_bundle_origin = getattr(module, "bundle_origin", "") or ""
|
||||
|
||||
if not _module_enabled:
|
||||
# Disabled modules don't run — but the trace row still
|
||||
# surfaces what the module would have written, so the
|
||||
# debug viewer can label it `$varname (disabled)`
|
||||
# instead of falling back to an opaque short-uuid.
|
||||
_k = int(ctx.get("__wp_loop_index__", 0))
|
||||
# Per-frame enable override. `frame_enabled[str(k)]` overrides the
|
||||
# base `enabled` flag for frame k in EITHER direction: a base-off
|
||||
# module can be turned ON for select frames, a base-on module OFF
|
||||
# for others. effective = frame_enabled.get(k, base_enabled).
|
||||
# Legacy `disabled_frames` (a one-way blocklist) is still honoured —
|
||||
# each listed frame reads as frame_enabled[k]=False.
|
||||
_frame_enabled_map = (
|
||||
module.get("frame_enabled")
|
||||
if isinstance(module, dict)
|
||||
else getattr(module, "frame_enabled", None)
|
||||
)
|
||||
_legacy_disabled = (
|
||||
module.get("disabled_frames")
|
||||
if isinstance(module, dict)
|
||||
else getattr(module, "disabled_frames", None)
|
||||
)
|
||||
_frame_override: bool | None = None
|
||||
if isinstance(_frame_enabled_map, dict) and str(_k) in _frame_enabled_map:
|
||||
_frame_override = bool(_frame_enabled_map[str(_k)])
|
||||
elif isinstance(_legacy_disabled, (list, tuple)) and _k in {
|
||||
int(f)
|
||||
for f in _legacy_disabled
|
||||
if isinstance(f, (int, float)) and not isinstance(f, bool)
|
||||
}:
|
||||
_frame_override = False
|
||||
_effective_enabled = (
|
||||
_frame_override if _frame_override is not None else bool(_module_enabled)
|
||||
)
|
||||
|
||||
if not _effective_enabled:
|
||||
# Module is suppressed this frame. Distinguish a per-frame
|
||||
# disable (a frame override forced it off) from a global
|
||||
# disable (base off, no frame override) so the debug viewer
|
||||
# can label `skipped_frame` vs `skipped_disabled`. Either way
|
||||
# the trace row still surfaces what it WOULD have written so
|
||||
# the debug viewer can label `$varname (disabled)` rather than
|
||||
# falling back to an opaque short-uuid.
|
||||
_frame_forced_off = _frame_override is False
|
||||
meta = _extract_static_meta(module)
|
||||
ctx["__wp_trace__"].append({
|
||||
"id": _module_id,
|
||||
"_uid": _module_uid,
|
||||
"type": _module_type_raw,
|
||||
"enabled": False,
|
||||
"status": "skipped_disabled",
|
||||
"enabled": bool(_module_enabled) if _frame_forced_off else False,
|
||||
"status": "skipped_frame" if _frame_forced_off else "skipped_disabled",
|
||||
"writes": [],
|
||||
"error": None,
|
||||
**meta,
|
||||
@@ -325,17 +363,38 @@ class PipelineEngine:
|
||||
else:
|
||||
# Generic object: pull known snapshot keys via getattr so
|
||||
# class-level attributes (common in tests) are visible.
|
||||
# `iteration_overrides` is load-bearing — the per-frame
|
||||
# override patch below reads it back off `raw`. `disabled_frames`
|
||||
# is listed for snapshot honesty only (the skip gate already
|
||||
# read it module-direct above).
|
||||
raw = {
|
||||
k: getattr(module, k)
|
||||
for k in (
|
||||
"id", "type", "enabled", "payload", "entries",
|
||||
"meta", "library_id", "library_snapshot_at",
|
||||
"library_version_at_snapshot", "name", "category_id",
|
||||
"instance",
|
||||
"instance", "iteration_overrides",
|
||||
"disabled_frames", "frame_enabled",
|
||||
)
|
||||
if hasattr(module, k)
|
||||
}
|
||||
snapshot = coerce_legacy_module(raw)
|
||||
# Per-frame iteration override: patch snapshot["instance"]
|
||||
# for the current loop index BEFORE the handler runs.
|
||||
# iteration_overrides is a dict keyed by stringified index;
|
||||
# the matching partial dict is shallow-merged into instance.
|
||||
_ov = (
|
||||
raw.get("iteration_overrides")
|
||||
if isinstance(raw, dict)
|
||||
else getattr(module, "iteration_overrides", None)
|
||||
)
|
||||
if isinstance(_ov, dict):
|
||||
_frame_key = str(ctx.get("__wp_loop_index__", 0))
|
||||
_patch = _ov.get(_frame_key)
|
||||
if isinstance(_patch, dict) and isinstance(
|
||||
snapshot.get("instance"), dict
|
||||
):
|
||||
snapshot["instance"] = {**snapshot["instance"], **_patch}
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Failed to coerce module %r at index %s: %s", module, index, e
|
||||
|
||||
@@ -33,6 +33,10 @@ INSTANCE_SCHEMAS: dict[str, dict[str, InstanceFieldType]] = {
|
||||
"category_filter": "string",
|
||||
"exclude_null": "boolean",
|
||||
"locked_seed": "number",
|
||||
# Iteration seed scope: "hold" pins RNG to the run base seed (same
|
||||
# across loop iterations, re-rolls per run); "vary" (default/absent)
|
||||
# rides the per-iteration seed. `locked_seed` takes priority when set.
|
||||
"seed_scope": "string",
|
||||
"internal": "boolean",
|
||||
# SP2a multi-select: per-instance count range (pick_min..pick_max; a
|
||||
# range other than 1..1 resolves N options without replacement into a
|
||||
@@ -59,6 +63,8 @@ INSTANCE_SCHEMAS: dict[str, dict[str, InstanceFieldType]] = {
|
||||
# resolution gives fixed_values seed-lock parity with wildcard
|
||||
# + combine. Locked seed pins the alternation outcome.
|
||||
"locked_seed": "number",
|
||||
# Iteration seed scope: hold/vary control (parity with wildcard).
|
||||
"seed_scope": "string",
|
||||
},
|
||||
"combine": {
|
||||
# v2 single-pane modal exposes a richer override surface:
|
||||
@@ -69,6 +75,8 @@ INSTANCE_SCHEMAS: dict[str, dict[str, InstanceFieldType]] = {
|
||||
"template_override": "string",
|
||||
"variable_binding": "string",
|
||||
"locked_seed": "number",
|
||||
# Iteration seed scope: hold/vary control (parity with wildcard).
|
||||
"seed_scope": "string",
|
||||
"internal": "boolean",
|
||||
},
|
||||
"derivation": {
|
||||
|
||||
+10
-3
@@ -66,8 +66,10 @@ def effective_chain_seed(
|
||||
1. ``seed_override`` (from ContextLoop) replaces ``widget_seed`` as
|
||||
base when present.
|
||||
2. Otherwise ``widget_seed`` is the base.
|
||||
3. ``loop_index`` is mixed via XOR with a stable hash. ``loop_index=0``
|
||||
→ no-op (backwards-compat for chains without ContextLoop).
|
||||
3. ``loop_index`` is mixed via XOR with a stable hash ONLY when there is
|
||||
no override (to vary a constant ``widget_seed`` per iteration). An
|
||||
override is already per-iteration (the ContextLoop derived series) so
|
||||
it is used as-is. ``loop_index=0`` → no-op.
|
||||
|
||||
Locked-module precedence lives in the per-module handlers, NOT here.
|
||||
Locked modules read ``instance.locked_seed`` directly and ignore the
|
||||
@@ -77,7 +79,12 @@ def effective_chain_seed(
|
||||
matches the visual shape of ComfyUI's frontend-randomised seeds.
|
||||
"""
|
||||
base = seed_override if seed_override is not None else widget_seed
|
||||
if loop_index == 0:
|
||||
# An override IS the loop's already-per-iteration derived seed → use it
|
||||
# verbatim. The loop_index XOR exists only to vary a CONSTANT widget seed
|
||||
# (the override-OFF path); re-mixing an override that already differs per
|
||||
# iteration is a double-shift that made the actual seed diverge from the
|
||||
# derived series the preview + per-iteration seed-modal locks show.
|
||||
if loop_index == 0 or seed_override is not None:
|
||||
return base & MAX_SAFE_SEED
|
||||
digest = hashlib.sha256(f"loop:{loop_index}".encode()).digest()
|
||||
shift = int.from_bytes(digest[:8], "big")
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "comfyui-wildcard-pipeline",
|
||||
"version": "2.7.0",
|
||||
"version": "2.8.0",
|
||||
"private": true,
|
||||
"description": "Modular procedural prompt generation for ComfyUI",
|
||||
"author": "DumiFlex",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "comfyui-wildcard-pipeline"
|
||||
version = "2.7.0"
|
||||
version = "2.8.0"
|
||||
description = "Modular procedural prompt generation for ComfyUI — wildcards, constraints, derivations, and context pipelines."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
@@ -194,7 +194,28 @@ const ENTRY_LIMIT = 30 * 1024; // 30 KB
|
||||
// Bumped 444 -> 445 KB on 2026-06-23 for T16 (seed modal paste-to-replace).
|
||||
// Adds parseSeedLocks in seed-derive + pasteAll/Paste button in SeedListModal.
|
||||
// Measured 454900 bytes (244 over the 444 ceiling); minimal bump.
|
||||
const TOTAL_LIMIT = 445 * 1024; // 445 KB
|
||||
// Bumped 445 -> 446 KB on 2026-06-24 for UI-C (wire ContextWidget frame edits).
|
||||
// Adds imports of frame-cursor / frame-overrides / INSTANCE_FIELDS_PER_KIND into
|
||||
// the ContextWidget lazy chunk + the merge-on-open / write-redirect logic.
|
||||
// Measured 455772 bytes (92 over the 445 ceiling); minimal bump.
|
||||
// Bumped 446 -> 447 KB on 2026-06-24 for RF2 (frame-aware row quick-lock).
|
||||
// Adds toggleFrameLock helper in frame-overrides.ts + effectiveLockedSeed +
|
||||
// frame branch in toggleLockOnCard + frame-aware isLocked. Net ~168 bytes
|
||||
// gzip in the lazy ContextWidget chunk; entry stays put.
|
||||
// Bumped 447 -> 448 KB on 2026-06-24 for the symmetric per-frame enable
|
||||
// redesign (a `frame_enabled` override map replaces the `disabled_frames`
|
||||
// blocklist so a base-off module can be turned ON for select frames). Adds
|
||||
// frameEnabledAt / effectiveEnabled / frameEnableOverride / toggleFrameEnabled
|
||||
// to frame-overrides.ts, the effective-enabled checkbox + bidirectional
|
||||
// on-#k / off-#k badge in ModuleRow, and the .wp-mod-badge--on CSS. Measured
|
||||
// 457868 bytes (140 over the 447 ceiling); minimal bump.
|
||||
// Bumped 448 -> 449 KB on 2026-06-25 for the per-frame seed-lock correctness
|
||||
// work: the Loop's derived-series capture (loop_seeds UI payload + onExecuted
|
||||
// store + frameSeedReader preference, so the lock pins the PREVIOUS run's
|
||||
// per-frame seed under control_after_generate=randomize) plus the modal
|
||||
// Lock-button resolver (lock-seed-ctx provide/inject into SeedLockControls).
|
||||
// Landed exactly at the 448 ceiling (458752/458752); bump for headroom.
|
||||
const TOTAL_LIMIT = 449 * 1024; // 449 KB
|
||||
|
||||
function gzipSize(path) {
|
||||
return gzipSync(readFileSync(path)).length;
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import ContextLoopWidget from "./ContextLoopWidget.vue";
|
||||
import SeedListModal from "../shared/SeedListModal.vue";
|
||||
import { emptyContextLoopConfig } from "./types";
|
||||
import { currentFrame } from "./frame-cursor";
|
||||
|
||||
describe("ContextLoopWidget", () => {
|
||||
it("renders defaults — hash chip active, both switches off, internal toggles on", () => {
|
||||
@@ -127,3 +128,23 @@ describe("ContextLoopWidget seeds button", () => {
|
||||
expect(on.findComponent(SeedListModal).props("overrideHint")).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ContextLoopWidget frame selector", () => {
|
||||
beforeEach(() => { currentFrame.value = null; });
|
||||
function fw(count = 3) {
|
||||
return mount(ContextLoopWidget, {
|
||||
props: { modelValue: emptyContextLoopConfig(), count },
|
||||
attachTo: document.body, global: { stubs: { teleport: true } },
|
||||
});
|
||||
}
|
||||
it("renders base + one chip per iteration", () => {
|
||||
expect(fw(3).findAll('[data-test^="loop-frame-"]')).toHaveLength(4);
|
||||
});
|
||||
it("clicking a frame chip sets the shared cursor; base clears it", async () => {
|
||||
const wr = fw(3);
|
||||
await wr.find('[data-test="loop-frame-2"]').trigger("click");
|
||||
expect(currentFrame.value).toBe(1);
|
||||
await wr.find('[data-test="loop-frame-base"]').trigger("click");
|
||||
expect(currentFrame.value).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
import { computed, ref } from "vue";
|
||||
import type { ContextLoopConfig, LoopStrategy } from "./types";
|
||||
import SeedListModal from "../shared/SeedListModal.vue";
|
||||
import { currentFrame, setFrame } from "./frame-cursor";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -26,8 +27,12 @@ const props = withDefaults(
|
||||
baseSeed?: number;
|
||||
/** Iteration count forwarded from the host ComfyUI INT widget. */
|
||||
count?: number;
|
||||
/** The seed each frame used on the PREVIOUS run, captured from the node's
|
||||
* executed `loop_seeds` UI payload by the host glue. Drives the seed
|
||||
* modal's per-frame "lock previous" button. Null until a run lands. */
|
||||
previousSeeds?: number[] | null;
|
||||
}>(),
|
||||
{ nodeMode: 0, baseSeed: 0, count: 1 },
|
||||
{ nodeMode: 0, baseSeed: 0, count: 1, previousSeeds: null },
|
||||
);
|
||||
|
||||
const emit = defineEmits<{ "update:modelValue": [next: ContextLoopConfig] }>();
|
||||
@@ -50,6 +55,9 @@ const OVERRIDE_SEED_TOOLTIP =
|
||||
|
||||
const isMuted = computed<boolean>(() => props.nodeMode === 2);
|
||||
const isBypassed = computed<boolean>(() => props.nodeMode === 4);
|
||||
const frameChips = computed(() =>
|
||||
Array.from({ length: Math.max(1, props.count ?? 1) }, (_, i) => i),
|
||||
);
|
||||
|
||||
const seedsOpen = ref(false);
|
||||
const lockedCount = computed(() => Object.keys(props.modelValue.seed_locks ?? {}).length);
|
||||
@@ -128,6 +136,17 @@ function toggleTotalInternal(): void {
|
||||
<svg class="wp-loop__seedbtn-chev" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M9 6l6 6-6 6" /></svg>
|
||||
</button>
|
||||
|
||||
<div class="wp-loop__section">
|
||||
<div class="wp-loop__label">edit frame</div>
|
||||
<div class="wp-loop__chips wp-loop__chips--frames" role="radiogroup" aria-label="Edit frame">
|
||||
<button type="button" class="wp-loop__chip" :class="{ 'wp-loop__chip--active': currentFrame === null }"
|
||||
data-test="loop-frame-base" role="radio" :aria-checked="currentFrame === null" @click="setFrame(null)">base</button>
|
||||
<button v-for="i in frameChips" :key="i" type="button" class="wp-loop__chip"
|
||||
:class="{ 'wp-loop__chip--active': currentFrame === i }"
|
||||
:data-test="`loop-frame-${i + 1}`" role="radio" :aria-checked="currentFrame === i" @click="setFrame(i)">#{{ i + 1 }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="wp-loop__row"
|
||||
:title="OVERRIDE_SEED_TOOLTIP"
|
||||
@@ -204,6 +223,7 @@ function toggleTotalInternal(): void {
|
||||
|
||||
<SeedListModal v-if="seedsOpen" :node-name="'WP Context Loop'" :base-seed="baseSeed"
|
||||
:count="count" :strategy="modelValue.strategy" :seed-locks="modelValue.seed_locks ?? {}"
|
||||
:previous-seeds="previousSeeds"
|
||||
:override-hint="!modelValue.override_seed ? 'These seeds apply only when Override Context seed is on.' : ''"
|
||||
@update:seed-locks="onSeedLocks" @close="seedsOpen = false" />
|
||||
</div>
|
||||
@@ -229,9 +249,18 @@ function toggleTotalInternal(): void {
|
||||
}
|
||||
|
||||
.wp-loop__chips { display: flex; gap: 4px; }
|
||||
/* Frame chips ONLY (the strategy row has 3 fixed chips that must stay one
|
||||
row + show full labels): grid so 15+ frame chips WRAP instead of
|
||||
overflowing the node. auto-fill keeps a uniform cell width; the last row
|
||||
grows to 1fr to fill it. */
|
||||
.wp-loop__chips--frames {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(38px, 1fr));
|
||||
}
|
||||
.wp-loop__chip {
|
||||
flex: 1;
|
||||
padding: 4px 6px;
|
||||
text-align: center;
|
||||
background: var(--wp-bg-deep, var(--wp-bg, #0e1015));
|
||||
border: 1px solid var(--wp-border, #353841);
|
||||
border-radius: 3px;
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { currentFrame, setFrame } from "./frame-cursor";
|
||||
|
||||
describe("frame-cursor", () => {
|
||||
beforeEach(() => { currentFrame.value = null; });
|
||||
it("defaults to null (base)", () => { expect(currentFrame.value).toBe(null); });
|
||||
it("setFrame updates the shared ref", () => {
|
||||
setFrame(2);
|
||||
expect(currentFrame.value).toBe(2);
|
||||
setFrame(null);
|
||||
expect(currentFrame.value).toBe(null);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { ref } from "vue";
|
||||
|
||||
// Module-scoped reactive singleton: the "current editing frame" cursor, shared
|
||||
// across every ContextWidget + ContextLoopWidget Vue app on the page. Each node
|
||||
// mounts its own app, so provide/inject can't span them — a shared module import
|
||||
// is the cross-app channel (same pattern as context/drag-store.ts). `null` = base
|
||||
// (no frame). Session-only — never serialized into the workflow.
|
||||
export const currentFrame = ref<number | null>(null);
|
||||
|
||||
export function setFrame(frame: number | null): void {
|
||||
currentFrame.value = frame;
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import BundleFrame from "./bundles/BundleFrame.vue";
|
||||
import { BundleFrameCtxKey, type BundleFrameCtx } from "./bundles/bundle-frame-ctx";
|
||||
import ModuleRow from "./ModuleRow.vue";
|
||||
import { ModuleRowCtxKey, type ModuleRowCtx } from "./module-row-ctx";
|
||||
import { LockSeedKey } from "./lock-seed-ctx";
|
||||
import { buildBundleInsertion, type BundleLibraryEntry } from "./bundles/insert";
|
||||
import { isEnabled, type WildcardOption, type InstanceLike } from "./editors/wildcard/probability";
|
||||
import { buildLibraryChildrenWithIntegrity, toChildSnapshot } from "./bundles/save";
|
||||
@@ -74,7 +75,9 @@ import { dragState, queueHandoff, pendingHandoffs, takeHandoffsFor } from "./dra
|
||||
import { nextBindingSuffix } from "./duplicates/binding-suffix";
|
||||
import { pushToast } from "../shared/toast-store";
|
||||
import { kindIcon } from "../shared/kind-icons";
|
||||
import { KIND_TITLE } from "./editors/_shell";
|
||||
import { KIND_TITLE, FRAME_OVERRIDABLE_FIELDS } from "./editors/_shell";
|
||||
import { currentFrame } from "../context-loop/frame-cursor";
|
||||
import { withFrameInstance, diffInstance, setFrameOverride, clearFrameOverride, toggleFrameLock, toggleFrameEnabled, dropRedundantFrameLockNulls, effectiveEnabled as feEffectiveEnabled, frameEnableOverride as feFrameEnableOverride } from "./frame-overrides";
|
||||
import { varColorClass } from "../shared/var-color";
|
||||
import wpLogoSvg from "../shared/wp-logo.svg?raw";
|
||||
import {
|
||||
@@ -151,6 +154,10 @@ const props = withDefaults(defineProps<{
|
||||
nodeMode?: number;
|
||||
/** Last-run seed reader keyed by module id. Null = user hasn't queued. */
|
||||
lastUsedSeedReader?: (moduleId?: string) => number | null;
|
||||
/** Per-frame seed reader — the seed THIS node rolls at iteration frame #k,
|
||||
* computed from the upstream Loop without a run. Drives the per-frame seed
|
||||
* lock so it pins the seed the module actually uses on that frame. */
|
||||
frameSeedReader?: (frame: number) => number | null;
|
||||
onChange: (json: string) => void;
|
||||
/** Called whenever the formula-computed minimum node width changes
|
||||
* (a conflict appears, a state badge gets attached). Mount glue
|
||||
@@ -679,9 +686,11 @@ function onBundlePushClosed(): void {
|
||||
pushBundleTargetUid.value = null;
|
||||
pushBundleCascadeScan.value = null;
|
||||
}
|
||||
const editingModule = computed<ModuleEntry | null>(() =>
|
||||
editingIdx.value != null ? (value.value.modules[editingIdx.value] ?? null) : null,
|
||||
);
|
||||
const editingModule = computed<ModuleEntry | null>(() => {
|
||||
if (editingIdx.value == null) return null;
|
||||
const m = value.value.modules[editingIdx.value] ?? null;
|
||||
return m ? withFrameInstance(m, currentFrame.value) : null;
|
||||
});
|
||||
|
||||
/** Per-occurrence pair lookup for the currently-edited CARRIER module.
|
||||
* Map keys are the carrier's `via.optionIds` — wildcard option ids for a
|
||||
@@ -2880,11 +2889,22 @@ function isInternalable(m: ModuleEntry): boolean {
|
||||
}
|
||||
|
||||
function isLocked(m: ModuleEntry): boolean {
|
||||
return typeof m.instance?.locked_seed === "number";
|
||||
return typeof withFrameInstance(m, currentFrame.value).instance?.locked_seed === "number";
|
||||
}
|
||||
function isInternal(m: ModuleEntry): boolean {
|
||||
return !!m.instance?.internal;
|
||||
}
|
||||
function isHeld(m: ModuleEntry): boolean {
|
||||
return m.instance?.seed_scope === "hold";
|
||||
}
|
||||
function isOverriddenOnFrame(m: ModuleEntry): boolean {
|
||||
const k = currentFrame.value;
|
||||
return k != null && !!m.iteration_overrides?.[String(k)];
|
||||
}
|
||||
function effectiveLockedSeed(m: ModuleEntry): number | undefined {
|
||||
const v = withFrameInstance(m, currentFrame.value).instance?.locked_seed;
|
||||
return typeof v === "number" ? v : undefined;
|
||||
}
|
||||
|
||||
// ── Formula-driven minWidth ────────────────────────────────────────
|
||||
// Pull-based pattern: createDomWidgetHost reads this through a
|
||||
@@ -2938,6 +2958,22 @@ watch(
|
||||
function toggleLockOnCard(idx: number) {
|
||||
const m = value.value.modules[idx];
|
||||
if (!m) return;
|
||||
const k = currentFrame.value;
|
||||
if (k != null) {
|
||||
// Prefer the seed THIS module actually rolls at frame #k (computed from the
|
||||
// upstream Loop), so the lock pins the frame's real seed — not the single
|
||||
// representative seed a run surfaced. Falls back to last-used / stored when
|
||||
// no Loop drives the node.
|
||||
const frameSeed = props.frameSeedReader?.(k);
|
||||
const lastUsed = props.lastUsedSeedReader?.(m._uid ?? m.id);
|
||||
const fallback = typeof frameSeed === "number" ? frameSeed
|
||||
: typeof lastUsed === "number" ? lastUsed
|
||||
: (typeof m.instance?._ui?.last_locked_seed === "number" ? m.instance._ui.last_locked_seed : 0);
|
||||
const list = [...value.value.modules];
|
||||
list[idx] = toggleFrameLock(m, k, fallback);
|
||||
commitModules(list);
|
||||
return;
|
||||
}
|
||||
const inst = m.instance ?? {};
|
||||
let nextInst: NonNullable<ModuleEntry["instance"]>;
|
||||
if (typeof inst.locked_seed === "number") {
|
||||
@@ -2980,7 +3016,9 @@ function toggleLockOnCard(idx: number) {
|
||||
// map-by-id pass would toggle every sibling at once. Indexing into
|
||||
// the array hits the specific instance the user clicked.
|
||||
const list = [...value.value.modules];
|
||||
list[idx] = { ...m, instance: nextInst };
|
||||
// Unlocking base makes any frame `locked_seed: null` override redundant —
|
||||
// sweep it so the row stops showing a stale "override #k". No-op on lock.
|
||||
list[idx] = dropRedundantFrameLockNulls({ ...m, instance: nextInst });
|
||||
commitModules(list);
|
||||
}
|
||||
|
||||
@@ -3697,11 +3735,32 @@ function moveToEdge(idx: number, edge: "top" | "bottom") {
|
||||
|
||||
function toggleEnabled(idx: number) {
|
||||
if (idx < 0 || idx >= value.value.modules.length) return;
|
||||
const k = currentFrame.value;
|
||||
if (k != null) {
|
||||
// Frame-active: flip the module's per-frame enable override (symmetric —
|
||||
// base-off turns ON for this frame, base-on turns OFF). Pure logic lives in
|
||||
// frame-overrides.toggleFrameEnabled; base `enabled` is never touched here.
|
||||
const m = value.value.modules[idx];
|
||||
if (!m) return;
|
||||
const list = [...value.value.modules];
|
||||
list[idx] = toggleFrameEnabled(m, k);
|
||||
commitModules(list);
|
||||
return;
|
||||
}
|
||||
const list = [...value.value.modules];
|
||||
list[idx] = { ...list[idx], enabled: !list[idx].enabled };
|
||||
commitModules(list);
|
||||
}
|
||||
|
||||
// Thin wrappers binding the pure frame-overrides enable helpers to the reactive
|
||||
// `currentFrame` cursor — exposed to ModuleRow via moduleRowCtx.
|
||||
function effectiveEnabled(m: ModuleEntry): boolean {
|
||||
return feEffectiveEnabled(m, currentFrame.value);
|
||||
}
|
||||
function frameEnableOverride(m: ModuleEntry): "on" | "off" | null {
|
||||
return feFrameEnableOverride(m, currentFrame.value);
|
||||
}
|
||||
|
||||
function toggleCollapsed(idx: number) {
|
||||
const target = value.value.modules[idx];
|
||||
if (!target) return;
|
||||
@@ -3815,7 +3874,22 @@ function saveEditedModule(updated: ModuleEntry & { _originalId?: string }) {
|
||||
const cleaned: ModuleEntry = { ...updated };
|
||||
delete (cleaned as { _originalId?: string })._originalId;
|
||||
const list = [...value.value.modules];
|
||||
list[targetIdx] = cleaned;
|
||||
const k = currentFrame.value;
|
||||
if (k == null) {
|
||||
list[targetIdx] = cleaned; // base edit — unchanged behavior
|
||||
} else {
|
||||
const baseModule = value.value.modules[targetIdx]; // base: payload + base instance intact
|
||||
const fields = FRAME_OVERRIDABLE_FIELDS[baseModule.type] ?? [];
|
||||
const delta = diffInstance(baseModule.instance, cleaned.instance, fields);
|
||||
// base payload + base instance preserved; the frame's override = the instance delta (replace, not merge)
|
||||
let next = clearFrameOverride(baseModule, k);
|
||||
if (Object.keys(delta).length) next = setFrameOverride(next, k, delta);
|
||||
list[targetIdx] = next;
|
||||
}
|
||||
// A base-unlock (here via the modal, or via the row toggle) makes any frame
|
||||
// `locked_seed: null` override redundant — sweep it so the row stops showing
|
||||
// a stale "override #k". No-op while the base stays locked.
|
||||
list[targetIdx] = dropRedundantFrameLockNulls(list[targetIdx]);
|
||||
commitModules(list);
|
||||
if (updated._originalId && updated._originalId !== updated.id) {
|
||||
nextTick(() => {
|
||||
@@ -3831,6 +3905,18 @@ function saveEditedModule(updated: ModuleEntry & { _originalId?: string }) {
|
||||
editingIdx.value = null;
|
||||
}
|
||||
|
||||
/** Revert the currently-edited module's frame-#k override back to the
|
||||
* base instance (i.e. remove the iteration_overrides[k] entry entirely).
|
||||
* Called from the frame banner's "Revert to base" button in ModuleEditModal. */
|
||||
function revertEditingFrame(): void {
|
||||
const idx = editingIdx.value;
|
||||
const k = currentFrame.value;
|
||||
if (idx == null || k == null) return;
|
||||
const list = [...value.value.modules];
|
||||
list[idx] = clearFrameOverride(value.value.modules[idx], k);
|
||||
commitModules(list);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the BundleInstanceModal for `uid`. Snapshots the live
|
||||
* BundleInstance into `bundleDraft` so the user's typing in
|
||||
@@ -4720,8 +4806,22 @@ const moduleRowCtx: ModuleRowCtx = {
|
||||
pairingFor,
|
||||
viaInboundFor,
|
||||
contributorsFor,
|
||||
currentFrame,
|
||||
isHeld,
|
||||
isOverriddenOnFrame,
|
||||
effectiveLockedSeed,
|
||||
effectiveEnabled,
|
||||
frameEnableOverride,
|
||||
};
|
||||
provide(ModuleRowCtxKey, moduleRowCtx);
|
||||
// Seed source for the edit-modal's Lock button — resolves the seed the module
|
||||
// actually rolled (frame #k's captured seed when a frame is active, else the
|
||||
// last-run seed) so locking pins what the user saw, not a random number.
|
||||
provide(LockSeedKey, (m: ModuleEntry): number | null => {
|
||||
const k = currentFrame.value;
|
||||
if (k != null) return props.frameSeedReader?.(k) ?? null;
|
||||
return props.lastUsedSeedReader?.(m._uid ?? m.id) ?? null;
|
||||
});
|
||||
|
||||
const bundleFrameCtx: BundleFrameCtx = {
|
||||
bundleChildDriftCount,
|
||||
@@ -4986,8 +5086,10 @@ provide(BundleFrameCtxKey, bundleFrameCtx);
|
||||
:chain-modules="chainModules"
|
||||
:via-option-pairs="editingModuleViaOptionPairs"
|
||||
:last-used-seed-reader="lastUsedSeedReader"
|
||||
:current-frame="currentFrame"
|
||||
@save="saveEditedModule"
|
||||
@close="editingIdx = null"
|
||||
@revert-frame="revertEditingFrame"
|
||||
/>
|
||||
|
||||
<!-- Bundle edit modal — sibling to ModuleEditModal, dispatched
|
||||
|
||||
@@ -590,6 +590,26 @@ describe("ModuleEditModal — derivation v2 dispatcher", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── Frame banner + revert-to-base (Phase E.1) ───────────────────────────
|
||||
|
||||
describe("ModuleEditModal — frame banner", () => {
|
||||
beforeEach(() => _resetForTests());
|
||||
|
||||
it("shows the frame banner + emits revert-frame when a frame is active", async () => {
|
||||
const w = mount(ModuleEditModal, { ...mountOpts, props: { visible: true, module: makeWildcard(), currentFrame: 1 } });
|
||||
await nextTick();
|
||||
expect(w.find('[data-test="modal-frame-banner"]').exists()).toBe(true);
|
||||
await w.find('[data-test="modal-frame-revert"]').trigger("click");
|
||||
expect(w.emitted("revert-frame")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("no frame banner on base (currentFrame null)", async () => {
|
||||
const w = mount(ModuleEditModal, { ...mountOpts, props: { visible: true, module: makeWildcard(), currentFrame: null } });
|
||||
await nextTick();
|
||||
expect(w.find('[data-test="modal-frame-banner"]').exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Constraint v2 dispatcher branch ────────────────────────────────────
|
||||
|
||||
describe("ModuleEditModal — constraint v2 dispatcher", () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onBeforeUnmount } from "vue";
|
||||
|
||||
import ModalShell from "../shared/ModalShell.vue";
|
||||
import type { ModuleEntry, ModuleEntryKind } from "../../widgets/_shared";
|
||||
import { type ResolvedValue } from "../../widgets/richTokenize";
|
||||
@@ -51,11 +52,15 @@ const props = defineProps<{
|
||||
* generic chain seed.
|
||||
*/
|
||||
lastUsedSeedReader?: (moduleId?: string) => number | null;
|
||||
/** When a frame override context is active, the 0-based frame index.
|
||||
* Null / undefined = editing the base (no banner shown). */
|
||||
currentFrame?: number | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "save", value: ModuleEntry): void;
|
||||
(e: "close"): void;
|
||||
(e: "revert-frame"): void;
|
||||
}>();
|
||||
|
||||
// Draft state — owned by the modal. Cancel discards, Save commits via emit.
|
||||
@@ -63,8 +68,13 @@ const emit = defineEmits<{
|
||||
// via JSON round-trip (Proxy-safe at every depth, unlike structuredClone).
|
||||
const draft = ref<ModuleEntry | null>(null);
|
||||
|
||||
watch(() => props.visible, (v) => {
|
||||
if (v && props.module) {
|
||||
watch([() => props.visible, () => props.module], () => {
|
||||
if (props.visible && props.module) {
|
||||
// Re-clone on open AND whenever the source module changes under an open
|
||||
// modal — e.g. "Revert to base" clears the frame override, so editingModule
|
||||
// recomputes and the draft must resync to the base instance. (During normal
|
||||
// editing the draft is mutated locally; props.module identity is stable, so
|
||||
// this does not clobber in-progress edits.)
|
||||
draft.value = JSON.parse(JSON.stringify(props.module));
|
||||
window.addEventListener("keydown", onKeydown);
|
||||
} else {
|
||||
@@ -382,6 +392,23 @@ function cancel() {
|
||||
|
||||
<template>
|
||||
<ModalShell :visible="visible" @close="cancel">
|
||||
<!-- Column so the frame banner stacks ABOVE the per-kind modal card
|
||||
(each per-kind modal renders a plain card; ModalShell's wrapper is
|
||||
a centered flex ROW, so a bare banner sibling would land beside the
|
||||
card). align-self:stretch on the banner spans it to the card width. -->
|
||||
<div class="wp-frame-edit-body">
|
||||
<!-- Frame-context banner — shown when the user is editing overrides
|
||||
for a specific iteration frame (#k). Sits above all per-kind
|
||||
modal bodies. Invisible on base editing (currentFrame == null). -->
|
||||
<div
|
||||
v-if="currentFrame != null"
|
||||
class="wp-modal-frame-banner"
|
||||
data-test="modal-frame-banner"
|
||||
>
|
||||
<span>Editing frame #{{ currentFrame + 1 }} — changes affect only this frame.</span>
|
||||
<button type="button" data-test="modal-frame-revert" @click="emit('revert-frame')">Revert #{{ currentFrame + 1 }} to base</button>
|
||||
</div>
|
||||
|
||||
<!-- v2 wildcard branch — single-pane tailored modal renders its
|
||||
own header/footer/body. No v1 chrome (no .wp-medit wrapper,
|
||||
no tab strip, no LibraryRoundTripActions footer). -->
|
||||
@@ -392,6 +419,7 @@ function cancel() {
|
||||
:upstream-vars="upstreamVars"
|
||||
:sibling-vars="siblingVars"
|
||||
:via-option-pairs="viaOptionPairs"
|
||||
:frame-active="currentFrame != null"
|
||||
@update="onUpdate"
|
||||
@save="save"
|
||||
@cancel="cancel"
|
||||
@@ -404,6 +432,7 @@ function cancel() {
|
||||
v-else-if="draft && draft.type === 'fixed_values'"
|
||||
:module="draft"
|
||||
:is-modified="instanceModified"
|
||||
:frame-active="currentFrame != null"
|
||||
@update="onUpdate"
|
||||
@save="save"
|
||||
@cancel="cancel"
|
||||
@@ -421,6 +450,7 @@ function cancel() {
|
||||
:upstream-vars="upstreamVars"
|
||||
:upstream-resolved="upstreamResolved"
|
||||
:sibling-vars="siblingVars"
|
||||
:frame-active="currentFrame != null"
|
||||
@update="onUpdate"
|
||||
@save="save"
|
||||
@cancel="cancel"
|
||||
@@ -442,6 +472,7 @@ function cancel() {
|
||||
:upstream-vars="upstreamVars"
|
||||
:sibling-vars="siblingVars"
|
||||
:via-option-pairs="viaOptionPairs"
|
||||
:frame-active="currentFrame != null"
|
||||
@update="onUpdate"
|
||||
@save="save"
|
||||
@cancel="cancel"
|
||||
@@ -460,13 +491,14 @@ function cancel() {
|
||||
:is-modified="instanceModified"
|
||||
:sibling-modules="siblingModules"
|
||||
:chain-modules="chainModules"
|
||||
:frame-active="currentFrame != null"
|
||||
@update="onUpdate"
|
||||
@save="save"
|
||||
@cancel="cancel"
|
||||
@save-to-library="onSaveToLibraryClick"
|
||||
@clear-all-overrides="onClearAllOverrides"
|
||||
/>
|
||||
|
||||
</div>
|
||||
</ModalShell>
|
||||
|
||||
<!-- Themed confirm dialog — replaces window.confirm() for any
|
||||
@@ -503,5 +535,42 @@ function cancel() {
|
||||
* (WildcardInstanceModal, FixedValuesInstanceModal, etc.). The
|
||||
* `.wp-medit` chrome was the v1 tabbed shell — removed alongside
|
||||
* the v1 dispatch in the 2026-05-10 cleanup. */
|
||||
|
||||
.wp-frame-edit-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 0;
|
||||
max-height: 100%;
|
||||
}
|
||||
.wp-modal-frame-banner {
|
||||
align-self: stretch;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 7px 11px;
|
||||
border-radius: 6px;
|
||||
/* solid surface (amber mixed into the modal bg, not over the canvas) so the
|
||||
* text stays readable; amber = the override/warning accent used elsewhere. */
|
||||
background: color-mix(in oklab, var(--wp-amber) 14%, var(--wp-bg2));
|
||||
border: 1px solid color-mix(in oklab, var(--wp-amber) 38%, transparent);
|
||||
color: var(--wp-amber);
|
||||
font: 500 11px var(--wp-font-sans);
|
||||
}
|
||||
.wp-modal-frame-banner button {
|
||||
flex-shrink: 0;
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid color-mix(in oklab, var(--wp-amber) 55%, transparent);
|
||||
background: color-mix(in oklab, var(--wp-amber) 20%, transparent);
|
||||
color: var(--wp-amber);
|
||||
font: 600 10px var(--wp-font-sans);
|
||||
cursor: pointer;
|
||||
}
|
||||
.wp-modal-frame-banner button:hover {
|
||||
background: color-mix(in oklab, var(--wp-amber) 32%, transparent);
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -47,6 +47,12 @@ function makeCtx(over: Partial<ModuleRowCtx> = {}): ModuleRowCtx {
|
||||
pairingFor: () => null,
|
||||
viaInboundFor: () => [],
|
||||
contributorsFor: () => [],
|
||||
currentFrame: ref<number | null>(null),
|
||||
isHeld: () => false,
|
||||
isOverriddenOnFrame: () => false,
|
||||
effectiveLockedSeed: () => undefined,
|
||||
effectiveEnabled: (m) => m.enabled !== false,
|
||||
frameEnableOverride: () => null,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
@@ -131,3 +137,77 @@ describe("ModuleRow.vue — contributor cluster", () => {
|
||||
expect(wrapper.find(".wp-pair-badge").exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ModuleRow.vue — iteration-aware badges", () => {
|
||||
it("shows a held badge when seed_scope is hold", () => {
|
||||
const mod = module({ instance: { seed_scope: "hold" } as ModuleEntry["instance"] });
|
||||
const ctx = makeCtx({ isHeld: (m) => m.instance?.seed_scope === "hold" });
|
||||
const wrapper = mountRow(ctx, mod);
|
||||
expect(wrapper.find('[data-test="mod-held"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("shows an override badge labelled with the current frame", () => {
|
||||
const frame = ref<number | null>(1);
|
||||
const mod = module({ iteration_overrides: { "1": { weight: 2 } } as ModuleEntry["iteration_overrides"] });
|
||||
const ctx = makeCtx({
|
||||
currentFrame: frame,
|
||||
isOverriddenOnFrame: (m) => {
|
||||
const k = frame.value;
|
||||
return k != null && !!m.iteration_overrides?.[String(k)];
|
||||
},
|
||||
});
|
||||
const wrapper = mountRow(ctx, mod);
|
||||
const badge = wrapper.find('[data-test="mod-override"]');
|
||||
expect(badge.exists()).toBe(true);
|
||||
expect(badge.text()).toContain("#2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ModuleRow.vue — per-frame enable/disable", () => {
|
||||
it("checkbox is unchecked when effectiveEnabled is false on the active frame", () => {
|
||||
const frame = ref<number | null>(2);
|
||||
const ctx = makeCtx({ currentFrame: frame, effectiveEnabled: () => false });
|
||||
const wrapper = mountRow(ctx, module({ enabled: true }));
|
||||
const checkbox = wrapper.find('input[type="checkbox"]');
|
||||
expect(checkbox.exists()).toBe(true);
|
||||
expect((checkbox.element as HTMLInputElement).checked).toBe(false);
|
||||
});
|
||||
|
||||
it("checkbox is checked when effectiveEnabled is true (base-off but on for this frame)", () => {
|
||||
const frame = ref<number | null>(2);
|
||||
const ctx = makeCtx({ currentFrame: frame, effectiveEnabled: () => true });
|
||||
const wrapper = mountRow(ctx, module({ enabled: false }));
|
||||
const checkbox = wrapper.find('input[type="checkbox"]');
|
||||
expect((checkbox.element as HTMLInputElement).checked).toBe(true);
|
||||
});
|
||||
|
||||
it("shows an off-#k badge when frameEnableOverride is 'off'", () => {
|
||||
const frame = ref<number | null>(2);
|
||||
const ctx = makeCtx({ currentFrame: frame, frameEnableOverride: () => "off" });
|
||||
const wrapper = mountRow(ctx, module({ enabled: true }));
|
||||
const badge = wrapper.find('[data-test="mod-frame-disabled"]');
|
||||
expect(badge.exists()).toBe(true);
|
||||
expect(badge.text()).toContain("#3"); // frame index 2 (0-based) → "#3"
|
||||
});
|
||||
|
||||
it("shows an on-#k badge when frameEnableOverride is 'on'", () => {
|
||||
const frame = ref<number | null>(2);
|
||||
const ctx = makeCtx({ currentFrame: frame, frameEnableOverride: () => "on" });
|
||||
const wrapper = mountRow(ctx, module({ enabled: false }));
|
||||
const badge = wrapper.find('[data-test="mod-frame-enabled"]');
|
||||
expect(badge.exists()).toBe(true);
|
||||
expect(badge.text()).toContain("#3");
|
||||
});
|
||||
|
||||
it("checkbox change calls ctx.toggleEnabled with the row index", async () => {
|
||||
const frame = ref<number | null>(2);
|
||||
const calls: number[] = [];
|
||||
const ctx = makeCtx({
|
||||
currentFrame: frame,
|
||||
toggleEnabled: (idx) => { calls.push(idx); },
|
||||
});
|
||||
const wrapper = mountRow(ctx, module({ enabled: true }));
|
||||
await wrapper.find('input[type="checkbox"]').trigger("change");
|
||||
expect(calls).toEqual([0]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,7 +36,7 @@ const {
|
||||
tabindex="0"
|
||||
draggable="true"
|
||||
:class="{
|
||||
'wp-disabled': !module.enabled,
|
||||
'wp-disabled': !(ctx?.effectiveEnabled(module) ?? (module.enabled !== false)),
|
||||
'wp-conflict-error': severityFor(module._uid ?? module.id) === 'error',
|
||||
'wp-conflict-warning': severityFor(module._uid ?? module.id) === 'warning',
|
||||
'wp-conflict-info': severityFor(module._uid ?? module.id) === 'info',
|
||||
@@ -74,8 +74,12 @@ const {
|
||||
:title="isCollapsed(module) ? 'Expand' : 'Collapse'" @click="toggleCollapsed(idx)">
|
||||
<i :class="['pi', isCollapsed(module) ? 'pi-caret-right' : 'pi-caret-down']" aria-hidden="true"></i>
|
||||
</button>
|
||||
<label class="wp-toggle" draggable="false" :title="module.enabled ? 'Disable' : 'Enable'">
|
||||
<input type="checkbox" :checked="module.enabled" :aria-label="`enable ${module.meta.name}`" @change="toggleEnabled(idx)" />
|
||||
<label class="wp-toggle" draggable="false"
|
||||
:title="ctx && ctx.currentFrame.value != null ? (ctx.effectiveEnabled(module) ? 'Disable on this frame' : 'Enable on this frame') : (module.enabled ? 'Disable' : 'Enable')">
|
||||
<input type="checkbox"
|
||||
:checked="ctx ? ctx.effectiveEnabled(module) : module.enabled"
|
||||
:aria-label="`enable ${module.meta.name}`"
|
||||
@change="toggleEnabled(idx)" />
|
||||
<span class="wp-toggle-mark"></span>
|
||||
</label>
|
||||
<span class="wp-row-type-icon" :title="module.type" aria-hidden="true">
|
||||
@@ -149,11 +153,15 @@ const {
|
||||
class="wp-conflict-badge"
|
||||
:class="`wp-conflict-badge--${severityFor(module._uid ?? module.id)}`"
|
||||
:title="conflictTooltip(module._uid ?? module.id)">{{ conflictBadgeText(module._uid ?? module.id) }}</span>
|
||||
<span v-if="ctx?.isHeld(module)" class="wp-mod-badge wp-mod-badge--clash" data-test="mod-held" title="Held across the run">held</span>
|
||||
<span v-if="ctx?.isOverriddenOnFrame(module)" class="wp-conflict-badge wp-conflict-badge--warning" data-test="mod-override" title="Overridden on this frame">override #{{ (ctx.currentFrame.value ?? 0) + 1 }}</span>
|
||||
<span v-if="ctx?.frameEnableOverride(module) === 'off'" class="wp-mod-badge wp-mod-badge--missing" data-test="mod-frame-disabled" title="Disabled on this frame">off #{{ (ctx.currentFrame.value ?? 0) + 1 }}</span>
|
||||
<span v-else-if="ctx?.frameEnableOverride(module) === 'on'" class="wp-mod-badge wp-mod-badge--on" data-test="mod-frame-enabled" title="Enabled on this frame">on #{{ (ctx.currentFrame.value ?? 0) + 1 }}</span>
|
||||
</span>
|
||||
<div class="wp-mod-actions" draggable="false">
|
||||
<button v-if="isSeedLockable(module)" type="button" class="wp-btn--icon-sm wp-btn--warn"
|
||||
:class="{ 'is-locked': isLocked(module) }" data-test="row-action-lock"
|
||||
:title="isLocked(module) ? `Locked seed: ${module.instance?.locked_seed}. Click to unlock.` : 'Lock seed'"
|
||||
:title="isLocked(module) ? `Locked seed: ${ctx?.effectiveLockedSeed(module)}. Click to unlock.` : 'Lock seed'"
|
||||
:aria-label="isLocked(module) ? 'Unlock seed' : 'Lock seed'"
|
||||
@click.stop="toggleLockOnCard(idx)"><i class="pi pi-lock" /></button>
|
||||
<button v-if="module.type === 'wildcard' || module.type === 'fixed_values' || module.type === 'combine' || module.type === 'derivation'"
|
||||
|
||||
@@ -21,8 +21,9 @@
|
||||
* `runtime-seed-up`, `runtime-seed-down` — so the existing test
|
||||
* suites pass without modification.
|
||||
*/
|
||||
import { computed } from "vue";
|
||||
import { computed, inject } from "vue";
|
||||
import type { ModuleEntry } from "../../../../widgets/_shared";
|
||||
import { LockSeedKey } from "../../lock-seed-ctx";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -36,6 +37,10 @@ const props = withDefaults(
|
||||
);
|
||||
const emit = defineEmits<{ "update": [patch: Partial<ModuleEntry>] }>();
|
||||
|
||||
// The seed the module actually rolled — provided by ContextWidget. Used as the
|
||||
// default when locking so "Lock" pins what the user saw, not a fresh random.
|
||||
const lockSeedFor = inject(LockSeedKey, null);
|
||||
|
||||
const instance = computed(() => props.module.instance ?? {});
|
||||
const locked = computed(() => typeof instance.value.locked_seed === "number");
|
||||
const seedValue = computed(() =>
|
||||
@@ -71,7 +76,11 @@ function onLockClick(): void {
|
||||
} else {
|
||||
const ui = instance.value._ui ?? {};
|
||||
const remembered = typeof ui.last_locked_seed === "number" ? ui.last_locked_seed : null;
|
||||
const seed = remembered ?? randomSeed();
|
||||
// Prefer the seed the module ACTUALLY rolled (frame #k's captured seed, or
|
||||
// the last-run seed) so locking reproduces what the user saw; then the
|
||||
// remembered seed; a fresh random only as a last resort.
|
||||
const resolved = lockSeedFor?.(props.module);
|
||||
const seed = typeof resolved === "number" ? resolved : (remembered ?? randomSeed());
|
||||
emitInstance({
|
||||
locked_seed: seed,
|
||||
_ui: { ...ui, last_locked_seed: seed },
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { INSTANCE_FIELDS_PER_KIND } from "./_shell";
|
||||
import { FRAME_OVERRIDABLE_FIELDS, INSTANCE_FIELDS_PER_KIND } from "./_shell";
|
||||
|
||||
describe("INSTANCE_FIELDS_PER_KIND registry", () => {
|
||||
it("lists exactly the fields each kind exposes per spec §5.5", () => {
|
||||
@@ -7,7 +7,7 @@ describe("INSTANCE_FIELDS_PER_KIND registry", () => {
|
||||
// v2 dropped `mode` + `pinned_option_id` — see
|
||||
// docs/superpowers/specs/2026-05-08-instance-overrides-v2-design.md §6.1.
|
||||
"variable_binding", "enabled_options",
|
||||
"option_weights", "category_filter", "exclude_null", "locked_seed", "internal",
|
||||
"option_weights", "category_filter", "exclude_null", "locked_seed", "seed_scope", "internal",
|
||||
// SP2a multi-select (2026-06-08): per-instance count range + separator.
|
||||
// SP2c: pick_independent (allow repeats / with replacement).
|
||||
"pick_min", "pick_max", "pick_separator", "pick_independent",
|
||||
@@ -16,15 +16,17 @@ describe("INSTANCE_FIELDS_PER_KIND registry", () => {
|
||||
// `locked_seed` added in the combine + fixed_values syntax-parity
|
||||
// cycle (2026-05-08): fixed_values now resolves `{a|b|c}` per
|
||||
// value, so it gains seed-lock parity with wildcard + combine.
|
||||
"values_overrides", "enabled_options", "locked_seed",
|
||||
// `seed_scope` added for iteration-aware hold/vary control.
|
||||
"values_overrides", "enabled_options", "locked_seed", "seed_scope",
|
||||
]);
|
||||
expect(INSTANCE_FIELDS_PER_KIND.combine).toEqual([
|
||||
// v2 single-pane modal exposes per-instance overrides:
|
||||
// - template_override (combine v2, this cycle)
|
||||
// - variable_binding (matches wildcard's identity-section UX)
|
||||
// - locked_seed (parity with wildcard + fixed_values)
|
||||
// - seed_scope (iteration-aware hold/vary control)
|
||||
// - internal (existing hide-from-prompt toggle)
|
||||
"template_override", "variable_binding", "locked_seed", "internal",
|
||||
"template_override", "variable_binding", "locked_seed", "seed_scope", "internal",
|
||||
]);
|
||||
expect(INSTANCE_FIELDS_PER_KIND.derivation).toEqual([
|
||||
// Tier-D modal expansion (2026-05-10): per-rule + per-branch
|
||||
@@ -54,3 +56,16 @@ describe("INSTANCE_FIELDS_PER_KIND registry", () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FRAME_OVERRIDABLE_FIELDS", () => {
|
||||
const EXCLUDED = ["seed_scope", "variable_binding", "internal"] as const;
|
||||
for (const kind of Object.keys(FRAME_OVERRIDABLE_FIELDS) as Array<keyof typeof FRAME_OVERRIDABLE_FIELDS>) {
|
||||
it(`${kind}: excludes run-level/structural fields`, () => {
|
||||
for (const ex of EXCLUDED) expect(FRAME_OVERRIDABLE_FIELDS[kind]).not.toContain(ex);
|
||||
});
|
||||
it(`${kind}: is a subset of INSTANCE_FIELDS_PER_KIND`, () => {
|
||||
const sup = new Set(INSTANCE_FIELDS_PER_KIND[kind]);
|
||||
for (const f of FRAME_OVERRIDABLE_FIELDS[kind]) expect(sup.has(f)).toBe(true);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -14,7 +14,7 @@ export type InstanceFieldKey =
|
||||
| "variable_binding" | "enabled_options" | "option_weights" | "category_filter"
|
||||
| "exclude_null"
|
||||
| "pick_min" | "pick_max" | "pick_separator" | "pick_independent"
|
||||
| "mode" | "pinned_option_id" | "locked_seed" | "internal" | "values_overrides"
|
||||
| "mode" | "pinned_option_id" | "locked_seed" | "seed_scope" | "internal" | "values_overrides"
|
||||
| "template_override"
|
||||
| "disabled_rule_ids"
|
||||
// Derivation tier-D modal expansion (2026-05-10 cycle)
|
||||
@@ -46,13 +46,13 @@ export const INSTANCE_FIELDS_PER_KIND: Record<ModuleEntryKind, readonly Instance
|
||||
// implicit in pool state. Engine handler still reads them on
|
||||
// legacy snapshots; UI never writes them.
|
||||
"variable_binding", "enabled_options",
|
||||
"option_weights", "category_filter", "exclude_null", "locked_seed", "internal",
|
||||
"option_weights", "category_filter", "exclude_null", "locked_seed", "seed_scope", "internal",
|
||||
// SP2a multi-select: count range + join separator (per-instance, local).
|
||||
// SP2c: pick_independent (allow repeats / with replacement).
|
||||
"pick_min", "pick_max", "pick_separator", "pick_independent",
|
||||
],
|
||||
fixed_values: ["values_overrides", "enabled_options", "locked_seed"],
|
||||
combine: ["template_override", "variable_binding", "locked_seed", "internal"],
|
||||
fixed_values: ["values_overrides", "enabled_options", "locked_seed", "seed_scope"],
|
||||
combine: ["template_override", "variable_binding", "locked_seed", "seed_scope", "internal"],
|
||||
derivation: [
|
||||
// Tier-D modal expansion (2026-05-10):
|
||||
// - rule-group overrides (cleared by Reset btn)
|
||||
@@ -83,3 +83,19 @@ export const INSTANCE_FIELDS_PER_KIND: Record<ModuleEntryKind, readonly Instance
|
||||
"target_select",
|
||||
],
|
||||
};
|
||||
|
||||
// Subset of INSTANCE_FIELDS_PER_KIND that is meaningful to override PER-FRAME.
|
||||
// Excludes run-level (seed_scope), structural/reference (variable_binding), and
|
||||
// internal (base-only; per-frame visibility unsupported in v1). Used only by the
|
||||
// per-frame save-diff so toggling Hold/binding/Hide on a frame records no override.
|
||||
export const FRAME_OVERRIDABLE_FIELDS: Record<ModuleEntryKind, readonly InstanceFieldKey[]> = {
|
||||
wildcard: ["enabled_options", "option_weights", "category_filter", "exclude_null",
|
||||
"locked_seed", "pick_min", "pick_max", "pick_separator", "pick_independent"],
|
||||
fixed_values: ["values_overrides", "enabled_options", "locked_seed"],
|
||||
combine: ["template_override", "locked_seed"],
|
||||
derivation: ["disabled_rule_ids", "disabled_branch_keys", "action_value_overrides",
|
||||
"condition_value_overrides", "rule_order_override", "locked_seed"],
|
||||
constraint: ["disabled_exception_keys", "disabled_matrix_cells", "cell_mode_overrides",
|
||||
"cell_factor_overrides", "exception_mode_overrides", "exception_factor_overrides",
|
||||
"extra_exceptions", "target_select"],
|
||||
};
|
||||
|
||||
@@ -186,6 +186,8 @@ const headerName = computed(() => props.bundle.name || props.libraryName || "Bun
|
||||
.wp-bdm__hint kbd { font: 9px var(--wp-font-mono); background: var(--wp-bg-deep, var(--wp-bg)); border: 1px solid var(--wp-border); padding: 1px 4px; border-radius: 2px; color: var(--wp-text-muted, var(--wp-text2)); }
|
||||
.wp-bdm__btn { padding: 5px 12px; border: 1px solid var(--wp-border); border-radius: 3px; background: transparent; color: var(--wp-text-muted, var(--wp-text2)); font: 11px var(--wp-font-sans); cursor: pointer; }
|
||||
.wp-bdm__btn--primary { border-color: var(--wp-accent); background: var(--wp-accent); color: white; }
|
||||
.wp-bdm__btn:hover { border-color: var(--wp-border-strong, var(--wp-border2)); color: var(--wp-text); }
|
||||
.wp-bdm__btn--primary:hover { border-color: var(--wp-accent2, var(--wp-accent)); background: var(--wp-accent2, var(--wp-accent)); color: white; }
|
||||
.wp-bdm__btn--quiet { border-color: transparent; color: var(--wp-text-dim, var(--wp-text3)); display: inline-flex; align-items: center; gap: 5px; font-size: 10px; }
|
||||
.wp-bdm__btn--quiet:hover { border-color: var(--wp-border); color: var(--wp-text-muted, var(--wp-text2)); }
|
||||
.wp-bdm__btn--quiet .pi { font-size: 10px; }
|
||||
|
||||
@@ -35,6 +35,9 @@ const props = withDefaults(
|
||||
/** Sibling var names produced by other modules in this same Context
|
||||
* node. Combined with upstreamVars to populate the dropdown. */
|
||||
siblingVars?: string[];
|
||||
/** When true, a frame override context is active. Run-level and
|
||||
* structural controls are disabled in frame mode. */
|
||||
frameActive?: boolean;
|
||||
}>(),
|
||||
{
|
||||
isDrifted: false,
|
||||
@@ -100,6 +103,7 @@ function onSpaClick(): void {
|
||||
:module="module"
|
||||
:upstream-vars="upstreamVars"
|
||||
:sibling-vars="siblingVars"
|
||||
:frame-active="frameActive"
|
||||
@update="onUpdate"
|
||||
/>
|
||||
<TemplateSection
|
||||
@@ -110,7 +114,7 @@ function onSpaClick(): void {
|
||||
@update="onUpdate"
|
||||
/>
|
||||
<div class="wp-cbm__dock">
|
||||
<RuntimeSection :module="module" @update="onUpdate" />
|
||||
<RuntimeSection :module="module" :frame-active="frameActive" @update="onUpdate" />
|
||||
|
||||
<footer class="wp-cbm__foot">
|
||||
<a
|
||||
@@ -220,6 +224,15 @@ function onSpaClick(): void {
|
||||
background: var(--wp-accent);
|
||||
color: white;
|
||||
}
|
||||
.wp-cbm__btn:hover {
|
||||
border-color: var(--wp-border-strong, var(--wp-border2));
|
||||
color: var(--wp-text);
|
||||
}
|
||||
.wp-cbm__btn--primary:hover {
|
||||
border-color: var(--wp-accent2, var(--wp-accent));
|
||||
background: var(--wp-accent2, var(--wp-accent));
|
||||
color: white;
|
||||
}
|
||||
.wp-cbm__btn--quiet {
|
||||
border-color: transparent;
|
||||
color: var(--wp-text-dim, var(--wp-text3));
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
// R3: IdentitySection frame-active gating — combine variant.
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import IdentitySection from "./IdentitySection.vue";
|
||||
import type { ModuleEntry } from "../../../../../widgets/_shared";
|
||||
|
||||
function makeModule(overrides: Partial<ModuleEntry> = {}): ModuleEntry {
|
||||
return {
|
||||
id: "cb12cd34",
|
||||
type: "combine",
|
||||
enabled: true,
|
||||
meta: { name: "final_prompt" },
|
||||
entries: [],
|
||||
payload: { output_var: "final_prompt", template: "$style portrait" },
|
||||
instance: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("combine IdentitySection — frameActive gating on variable binding", () => {
|
||||
it("binding input is NOT disabled in base mode (frameActive absent)", () => {
|
||||
const w = mount(IdentitySection, { props: { module: makeModule() } });
|
||||
expect(w.find('[data-test="id-binding"]').attributes("disabled")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("binding input is NOT disabled when frameActive=false", () => {
|
||||
const w = mount(IdentitySection, { props: { module: makeModule(), frameActive: false } });
|
||||
expect(w.find('[data-test="id-binding"]').attributes("disabled")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("binding input IS disabled when frameActive=true", () => {
|
||||
const w = mount(IdentitySection, { props: { module: makeModule(), frameActive: true } });
|
||||
expect(w.find('[data-test="id-binding"]').attributes("disabled")).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows binding frame-active hint when frameActive=true", () => {
|
||||
const w = mount(IdentitySection, { props: { module: makeModule(), frameActive: true } });
|
||||
expect(w.find('[data-test="id-binding-frame-hint"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT show binding frame-active hint when frameActive=false", () => {
|
||||
const w = mount(IdentitySection, { props: { module: makeModule(), frameActive: false } });
|
||||
expect(w.find('[data-test="id-binding-frame-hint"]').exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,9 @@ const props = withDefaults(
|
||||
upstreamVars?: string[];
|
||||
/** Names produced by other modules in the SAME Context node. */
|
||||
siblingVars?: string[];
|
||||
/** When true, a frame is active — the variable binding input is
|
||||
* structural (not per-frame) so it is disabled with a hint. */
|
||||
frameActive?: boolean;
|
||||
}>(),
|
||||
{ upstreamVars: () => [], siblingVars: () => [] },
|
||||
);
|
||||
@@ -157,6 +160,7 @@ function onResetBinding(): void {
|
||||
:value="bindingValue"
|
||||
:placeholder="libraryBinding"
|
||||
aria-label="Variable binding"
|
||||
:disabled="frameActive || undefined"
|
||||
@input="onBindingInput"
|
||||
/>
|
||||
</div>
|
||||
@@ -171,6 +175,11 @@ function onResetBinding(): void {
|
||||
><i class="pi pi-replay" aria-hidden="true" /></button>
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
v-if="frameActive"
|
||||
class="id__frame-hint"
|
||||
data-test="id-binding-frame-hint"
|
||||
>Applies to every frame — switch to base to change.</p>
|
||||
<div
|
||||
v-if="collidesWith !== null"
|
||||
class="id__collision"
|
||||
@@ -303,4 +312,9 @@ function onResetBinding(): void {
|
||||
border: 1px solid color-mix(in srgb, var(--wp-accent) 35%, transparent);
|
||||
}
|
||||
.id__collision .pi { font-size: 11px; margin-top: 1px; flex-shrink: 0; }
|
||||
.id__frame-hint {
|
||||
margin: 4px 0 0;
|
||||
font: 10px var(--wp-font-sans);
|
||||
color: var(--wp-text-dim, var(--wp-text3));
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
// R3: RuntimeSection frame-active gating — combine variant.
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import RuntimeSection from "./RuntimeSection.vue";
|
||||
import type { ModuleEntry } from "../../../../../widgets/_shared";
|
||||
|
||||
function makeModule(overrides: Partial<ModuleEntry> = {}): ModuleEntry {
|
||||
return {
|
||||
id: "cb12cd34",
|
||||
type: "combine",
|
||||
enabled: true,
|
||||
meta: { name: "final_prompt" },
|
||||
entries: [],
|
||||
payload: { output_var: "final_prompt", template: "$style portrait" },
|
||||
instance: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("combine RuntimeSection — frameActive gating", () => {
|
||||
it("hide toggle is NOT disabled in base mode (frameActive absent)", () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule() } });
|
||||
expect(w.find('[data-test="runtime-hide"]').attributes("disabled")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("hold toggle is NOT disabled in base mode (frameActive absent)", () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule() } });
|
||||
expect(w.find('[data-test="runtime-hold"]').attributes("disabled")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("hide toggle is NOT disabled when frameActive=false", () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: false } });
|
||||
expect(w.find('[data-test="runtime-hide"]').attributes("disabled")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("hold toggle is NOT disabled when frameActive=false", () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: false } });
|
||||
expect(w.find('[data-test="runtime-hold"]').attributes("disabled")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("hide toggle IS disabled when frameActive=true", () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: true } });
|
||||
expect(w.find('[data-test="runtime-hide"]').attributes("disabled")).toBeDefined();
|
||||
});
|
||||
|
||||
it("hold toggle IS disabled when frameActive=true", () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: true } });
|
||||
expect(w.find('[data-test="runtime-hold"]').attributes("disabled")).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows frame-active hint when frameActive=true", () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: true } });
|
||||
expect(w.find('[data-test="runtime-frame-hint"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT show frame-active hint when frameActive=false", () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: false } });
|
||||
expect(w.find('[data-test="runtime-frame-hint"]').exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -112,4 +112,21 @@ describe("combine RuntimeSection", () => {
|
||||
await w.find('[data-test="runtime-hide"]').trigger("click");
|
||||
expect(lastPatch(w).instance?.internal).toBe(true);
|
||||
});
|
||||
|
||||
it("seed-scope toggle is off (vary) by default", () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule() } });
|
||||
expect(w.find('[data-test="runtime-hold"]').classes()).not.toContain("toggle--on");
|
||||
});
|
||||
|
||||
it("clicking seed-scope toggle emits patch with seed_scope = hold", async () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule() } });
|
||||
await w.find('[data-test="runtime-hold"]').trigger("click");
|
||||
expect(lastPatch(w).instance?.seed_scope).toBe("hold");
|
||||
});
|
||||
|
||||
it("clicking seed-scope toggle when held emits seed_scope = vary", async () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule({ instance: { seed_scope: "hold" } }) } });
|
||||
await w.find('[data-test="runtime-hold"]').trigger("click");
|
||||
expect(lastPatch(w).instance?.seed_scope).toBe("vary");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@ import { computed } from "vue";
|
||||
import type { ModuleEntry } from "../../../../../widgets/_shared";
|
||||
import SeedLockControls from "../../_shared/SeedLockControls.vue";
|
||||
|
||||
const props = defineProps<{ module: ModuleEntry }>();
|
||||
const props = defineProps<{ module: ModuleEntry; frameActive?: boolean }>();
|
||||
const emit = defineEmits<{ "update": [patch: Partial<ModuleEntry>] }>();
|
||||
|
||||
const instance = computed(() => props.module.instance ?? {});
|
||||
@@ -21,6 +21,11 @@ const internal = computed(() => instance.value.internal === true);
|
||||
function onHideClick(): void {
|
||||
emit("update", { instance: { ...instance.value, internal: !internal.value } });
|
||||
}
|
||||
|
||||
const held = computed(() => instance.value.seed_scope === "hold");
|
||||
function onHoldClick(): void {
|
||||
emit("update", { instance: { ...instance.value, seed_scope: held.value ? "vary" : "hold" } });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -34,11 +39,30 @@ function onHideClick(): void {
|
||||
data-test="runtime-hide"
|
||||
role="switch"
|
||||
:aria-checked="internal"
|
||||
:disabled="frameActive || undefined"
|
||||
@click="onHideClick"
|
||||
>
|
||||
<i class="pi pi-eye-slash" aria-hidden="true" />
|
||||
Hide from prompt
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="toggle"
|
||||
:class="{ 'toggle--on': held }"
|
||||
data-test="runtime-hold"
|
||||
role="switch"
|
||||
:aria-checked="held"
|
||||
:disabled="frameActive || undefined"
|
||||
@click="onHoldClick"
|
||||
>
|
||||
<i class="pi pi-link" aria-hidden="true" />
|
||||
Hold across run
|
||||
</button>
|
||||
<p
|
||||
v-if="frameActive"
|
||||
class="runtime__frame-hint"
|
||||
data-test="runtime-frame-hint"
|
||||
>Hide from prompt and Hold across run apply to every frame — switch to base to change.</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -78,4 +102,13 @@ function onHideClick(): void {
|
||||
background: rgba(99, 102, 241, 0.10);
|
||||
}
|
||||
.toggle--on .pi { color: var(--wp-accent-text, var(--wp-text)); }
|
||||
.toggle:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.runtime__frame-hint {
|
||||
margin: 4px 0 0;
|
||||
font: 10px var(--wp-font-sans);
|
||||
color: var(--wp-text-dim, var(--wp-text3));
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -65,6 +65,11 @@ const props = withDefaults(
|
||||
* Optional — undefined in headless/legacy mounts falls back to
|
||||
* `siblingModules` alone. */
|
||||
chainModules?: ChainModule[];
|
||||
/** When true, a frame override context is active. Accepted for
|
||||
* interface uniformity; constraint has no RuntimeSection and its
|
||||
* IdentitySection carries no variable-binding input, so nothing
|
||||
* is forwarded to a child here. */
|
||||
frameActive?: boolean;
|
||||
}>(),
|
||||
{ isDrifted: false, isModified: false, siblingModules: () => [] },
|
||||
);
|
||||
@@ -847,6 +852,15 @@ function onSpaClick(): void {
|
||||
background: var(--wp-accent);
|
||||
color: white;
|
||||
}
|
||||
.wp-cnm__btn:hover {
|
||||
border-color: var(--wp-border-strong, var(--wp-border2));
|
||||
color: var(--wp-text);
|
||||
}
|
||||
.wp-cnm__btn--primary:hover {
|
||||
border-color: var(--wp-accent2, var(--wp-accent));
|
||||
background: var(--wp-accent2, var(--wp-accent));
|
||||
color: white;
|
||||
}
|
||||
.wp-cnm__btn--quiet {
|
||||
border-color: transparent;
|
||||
color: var(--wp-text-dim, var(--wp-text3));
|
||||
|
||||
@@ -42,6 +42,9 @@ const props = withDefaults(
|
||||
* engine branch key (`${rule_id}:${bi}` / `${rule_id}:else`). Forwarded
|
||||
* verbatim to RulesSection, which renders the inline `↪#N` badge. */
|
||||
viaOptionPairs?: Map<string, readonly PairingBadge[]>;
|
||||
/** When true, a frame override context is active. Run-level controls
|
||||
* are disabled in frame mode. */
|
||||
frameActive?: boolean;
|
||||
}>(),
|
||||
{
|
||||
isDrifted: false,
|
||||
@@ -161,7 +164,7 @@ function onSpaClick(): void {
|
||||
@update="onUpdate"
|
||||
/>
|
||||
<div class="wp-dvm__dock">
|
||||
<RuntimeSection :module="module" @update="onUpdate" />
|
||||
<RuntimeSection :module="module" :frame-active="frameActive" @update="onUpdate" />
|
||||
|
||||
<footer class="wp-dvm__foot">
|
||||
<a
|
||||
@@ -270,6 +273,15 @@ function onSpaClick(): void {
|
||||
background: var(--wp-accent);
|
||||
color: white;
|
||||
}
|
||||
.wp-dvm__btn:hover {
|
||||
border-color: var(--wp-border-strong, var(--wp-border2));
|
||||
color: var(--wp-text);
|
||||
}
|
||||
.wp-dvm__btn--primary:hover {
|
||||
border-color: var(--wp-accent2, var(--wp-accent));
|
||||
background: var(--wp-accent2, var(--wp-accent));
|
||||
color: white;
|
||||
}
|
||||
.wp-dvm__btn--quiet {
|
||||
border-color: transparent;
|
||||
color: var(--wp-text-dim, var(--wp-text3));
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
// R3: RuntimeSection frame-active gating — derivation variant.
|
||||
// Derivation RuntimeSection only has runtime-hide (no runtime-hold),
|
||||
// so only the Hide from prompt toggle needs disabling.
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import RuntimeSection from "./RuntimeSection.vue";
|
||||
import type { ModuleEntry } from "../../../../../widgets/_shared";
|
||||
|
||||
function makeModule(overrides: Partial<ModuleEntry> = {}): ModuleEntry {
|
||||
return {
|
||||
id: "dv012345",
|
||||
type: "derivation",
|
||||
enabled: true,
|
||||
meta: { name: "mood-rules" },
|
||||
entries: [],
|
||||
payload: { rules: [] },
|
||||
instance: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("derivation RuntimeSection — frameActive gating", () => {
|
||||
it("hide toggle is NOT disabled in base mode (frameActive absent)", () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule() } });
|
||||
expect(w.find('[data-test="runtime-hide"]').attributes("disabled")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("hide toggle is NOT disabled when frameActive=false", () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: false } });
|
||||
expect(w.find('[data-test="runtime-hide"]').attributes("disabled")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("hide toggle IS disabled when frameActive=true", () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: true } });
|
||||
expect(w.find('[data-test="runtime-hide"]').attributes("disabled")).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows frame-active hint when frameActive=true", () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: true } });
|
||||
expect(w.find('[data-test="runtime-frame-hint"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT show frame-active hint when frameActive=false", () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: false } });
|
||||
expect(w.find('[data-test="runtime-frame-hint"]').exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,7 @@ import { computed } from "vue";
|
||||
import type { ModuleEntry } from "../../../../../widgets/_shared";
|
||||
import SeedLockControls from "../../_shared/SeedLockControls.vue";
|
||||
|
||||
const props = defineProps<{ module: ModuleEntry }>();
|
||||
const props = defineProps<{ module: ModuleEntry; frameActive?: boolean }>();
|
||||
const emit = defineEmits<{ "update": [patch: Partial<ModuleEntry>] }>();
|
||||
|
||||
const instance = computed(() => props.module.instance ?? {});
|
||||
@@ -32,11 +32,17 @@ function onHideClick(): void {
|
||||
data-test="runtime-hide"
|
||||
role="switch"
|
||||
:aria-checked="internal"
|
||||
:disabled="frameActive || undefined"
|
||||
@click="onHideClick"
|
||||
>
|
||||
<i class="pi pi-eye-slash" aria-hidden="true" />
|
||||
Hide from prompt
|
||||
</button>
|
||||
<p
|
||||
v-if="frameActive"
|
||||
class="runtime__frame-hint"
|
||||
data-test="runtime-frame-hint"
|
||||
>Hide from prompt applies to every frame — switch to base to change.</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -76,4 +82,13 @@ function onHideClick(): void {
|
||||
background: rgba(99, 102, 241, 0.10);
|
||||
}
|
||||
.toggle--on .pi { color: var(--wp-accent-text, var(--wp-text)); }
|
||||
.toggle:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.runtime__frame-hint {
|
||||
margin: 4px 0 0;
|
||||
font: 10px var(--wp-font-sans);
|
||||
color: var(--wp-text-dim, var(--wp-text3));
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -12,6 +12,9 @@ const props = withDefaults(
|
||||
/** True when draft has unsaved instance edits. Gates "Save to library"
|
||||
* visibility — pushing an unmodified payload back is a no-op. */
|
||||
isModified?: boolean;
|
||||
/** When true, a frame override context is active. Run-level controls
|
||||
* are disabled in frame mode. */
|
||||
frameActive?: boolean;
|
||||
}>(),
|
||||
{ isDrifted: false, isModified: false },
|
||||
);
|
||||
@@ -67,7 +70,7 @@ function onSpaClick(): void {
|
||||
<IdentitySection :module="module" @update="onUpdate" />
|
||||
<ValuesSection :module="module" @update="onUpdate" />
|
||||
<div class="wp-fvm__dock">
|
||||
<RuntimeSection :module="module" @update="onUpdate" />
|
||||
<RuntimeSection :module="module" :frame-active="frameActive" @update="onUpdate" />
|
||||
|
||||
<footer class="wp-fvm__foot">
|
||||
<a
|
||||
@@ -177,6 +180,15 @@ function onSpaClick(): void {
|
||||
background: var(--wp-accent);
|
||||
color: white;
|
||||
}
|
||||
.wp-fvm__btn:hover {
|
||||
border-color: var(--wp-border-strong, var(--wp-border2));
|
||||
color: var(--wp-text);
|
||||
}
|
||||
.wp-fvm__btn--primary:hover {
|
||||
border-color: var(--wp-accent2, var(--wp-accent));
|
||||
background: var(--wp-accent2, var(--wp-accent));
|
||||
color: white;
|
||||
}
|
||||
.wp-fvm__btn--quiet {
|
||||
border-color: transparent;
|
||||
color: var(--wp-text-dim, var(--wp-text3));
|
||||
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
// R3: RuntimeSection frame-active gating — fixed-values variant.
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import RuntimeSection from "./RuntimeSection.vue";
|
||||
import type { ModuleEntry } from "../../../../../widgets/_shared";
|
||||
|
||||
function makeModule(overrides: Partial<ModuleEntry> = {}): ModuleEntry {
|
||||
return {
|
||||
id: "fv012345",
|
||||
type: "fixed_values",
|
||||
enabled: true,
|
||||
meta: { name: "presets" },
|
||||
entries: [],
|
||||
payload: { values: [] },
|
||||
instance: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("fixed-values RuntimeSection — frameActive gating", () => {
|
||||
it("hide toggle is NOT disabled in base mode (frameActive absent)", () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule() } });
|
||||
expect(w.find('[data-test="runtime-hide"]').attributes("disabled")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("hold toggle is NOT disabled in base mode (frameActive absent)", () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule() } });
|
||||
expect(w.find('[data-test="runtime-hold"]').attributes("disabled")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("hide toggle is NOT disabled when frameActive=false", () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: false } });
|
||||
expect(w.find('[data-test="runtime-hide"]').attributes("disabled")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("hold toggle is NOT disabled when frameActive=false", () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: false } });
|
||||
expect(w.find('[data-test="runtime-hold"]').attributes("disabled")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("hide toggle IS disabled when frameActive=true", () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: true } });
|
||||
expect(w.find('[data-test="runtime-hide"]').attributes("disabled")).toBeDefined();
|
||||
});
|
||||
|
||||
it("hold toggle IS disabled when frameActive=true", () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: true } });
|
||||
expect(w.find('[data-test="runtime-hold"]').attributes("disabled")).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows frame-active hint when frameActive=true", () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: true } });
|
||||
expect(w.find('[data-test="runtime-frame-hint"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT show frame-active hint when frameActive=false", () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: false } });
|
||||
expect(w.find('[data-test="runtime-frame-hint"]').exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,11 @@ function makeModule(overrides: Partial<ModuleEntry> = {}): ModuleEntry {
|
||||
};
|
||||
}
|
||||
|
||||
function lastPatch(w: ReturnType<typeof mount>): Partial<ModuleEntry> {
|
||||
const updates = w.emitted("update")! as unknown[][];
|
||||
return updates[updates.length - 1][0] as Partial<ModuleEntry>;
|
||||
}
|
||||
|
||||
describe("fixed-values RuntimeSection", () => {
|
||||
it("renders Hide-from-prompt toggle", () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule() } });
|
||||
@@ -80,4 +85,21 @@ describe("fixed-values RuntimeSection", () => {
|
||||
});
|
||||
expect(on.find('[data-test="runtime-seed"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("seed-scope toggle is off (vary) by default", () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule() } });
|
||||
expect(w.find('[data-test="runtime-hold"]').classes()).not.toContain("toggle--on");
|
||||
});
|
||||
|
||||
it("clicking seed-scope toggle emits patch with seed_scope = hold", async () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule() } });
|
||||
await w.find('[data-test="runtime-hold"]').trigger("click");
|
||||
expect(lastPatch(w).instance?.seed_scope).toBe("hold");
|
||||
});
|
||||
|
||||
it("clicking seed-scope toggle when held emits seed_scope = vary", async () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule({ instance: { seed_scope: "hold" } }) } });
|
||||
await w.find('[data-test="runtime-hold"]').trigger("click");
|
||||
expect(lastPatch(w).instance?.seed_scope).toBe("vary");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Combine RuntimeSection — composes the shared `<SeedLockControls />`
|
||||
* (default "Lock seed" label; combine pins inline `{a|b|c}`
|
||||
* alternation resolution, not an option pick) plus the
|
||||
* Hide-from-prompt toggle.
|
||||
*
|
||||
* Slimmed during the 2026-05-09 extraction — previously held a
|
||||
* verbatim copy of wildcard's lock UI.
|
||||
* FixedValues RuntimeSection — composes the shared `<SeedLockControls />`
|
||||
* plus the Hide-from-prompt and Hold-across-run toggles.
|
||||
*/
|
||||
import { computed } from "vue";
|
||||
import type { ModuleEntry } from "../../../../../widgets/_shared";
|
||||
import SeedLockControls from "../../_shared/SeedLockControls.vue";
|
||||
|
||||
const props = defineProps<{ module: ModuleEntry }>();
|
||||
const props = defineProps<{ module: ModuleEntry; frameActive?: boolean }>();
|
||||
const emit = defineEmits<{ "update": [patch: Partial<ModuleEntry>] }>();
|
||||
|
||||
const instance = computed(() => props.module.instance ?? {});
|
||||
@@ -21,6 +16,11 @@ const internal = computed(() => instance.value.internal === true);
|
||||
function onHideClick(): void {
|
||||
emit("update", { instance: { ...instance.value, internal: !internal.value } });
|
||||
}
|
||||
|
||||
const held = computed(() => instance.value.seed_scope === "hold");
|
||||
function onHoldClick(): void {
|
||||
emit("update", { instance: { ...instance.value, seed_scope: held.value ? "vary" : "hold" } });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -34,11 +34,30 @@ function onHideClick(): void {
|
||||
data-test="runtime-hide"
|
||||
role="switch"
|
||||
:aria-checked="internal"
|
||||
:disabled="frameActive || undefined"
|
||||
@click="onHideClick"
|
||||
>
|
||||
<i class="pi pi-eye-slash" aria-hidden="true" />
|
||||
Hide from prompt
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="toggle"
|
||||
:class="{ 'toggle--on': held }"
|
||||
data-test="runtime-hold"
|
||||
role="switch"
|
||||
:aria-checked="held"
|
||||
:disabled="frameActive || undefined"
|
||||
@click="onHoldClick"
|
||||
>
|
||||
<i class="pi pi-link" aria-hidden="true" />
|
||||
Hold across run
|
||||
</button>
|
||||
<p
|
||||
v-if="frameActive"
|
||||
class="runtime__frame-hint"
|
||||
data-test="runtime-frame-hint"
|
||||
>Hide from prompt and Hold across run apply to every frame — switch to base to change.</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -78,4 +97,13 @@ function onHideClick(): void {
|
||||
background: rgba(99, 102, 241, 0.10);
|
||||
}
|
||||
.toggle--on .pi { color: var(--wp-accent-text, var(--wp-text)); }
|
||||
.toggle:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.runtime__frame-hint {
|
||||
margin: 4px 0 0;
|
||||
font: 10px var(--wp-font-sans);
|
||||
color: var(--wp-text-dim, var(--wp-text3));
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -22,6 +22,9 @@ const props = withDefaults(
|
||||
* (one of its options contains a nested `@{uuid}` that resolves to a
|
||||
* downstream constraint's target). Keyed by option id. */
|
||||
viaOptionPairs?: Map<string, PairingBadge[]>;
|
||||
/** When true, a frame override context is active. Run-level and
|
||||
* structural controls are disabled in frame mode. */
|
||||
frameActive?: boolean;
|
||||
}>(),
|
||||
{ isDrifted: false, isModified: false, upstreamVars: () => [], siblingVars: () => [], viaOptionPairs: () => new Map() },
|
||||
);
|
||||
@@ -98,11 +101,12 @@ function onSpaClick(): void {
|
||||
:module="module"
|
||||
:upstream-vars="upstreamVars"
|
||||
:sibling-vars="siblingVars"
|
||||
:frame-active="frameActive"
|
||||
@update="onUpdate"
|
||||
/>
|
||||
<PoolSection :module="module" :via-option-pairs="viaOptionPairs" @update="onUpdate" />
|
||||
<div class="wp-wcm__dock">
|
||||
<RuntimeSection :module="module" @update="onUpdate" />
|
||||
<RuntimeSection :module="module" :frame-active="frameActive" @update="onUpdate" />
|
||||
|
||||
<footer class="wp-wcm__foot">
|
||||
<a
|
||||
@@ -223,6 +227,15 @@ function onSpaClick(): void {
|
||||
background: var(--wp-accent);
|
||||
color: white;
|
||||
}
|
||||
.wp-wcm__btn:hover {
|
||||
border-color: var(--wp-border-strong, var(--wp-border2));
|
||||
color: var(--wp-text);
|
||||
}
|
||||
.wp-wcm__btn--primary:hover {
|
||||
border-color: var(--wp-accent2, var(--wp-accent));
|
||||
background: var(--wp-accent2, var(--wp-accent));
|
||||
color: white;
|
||||
}
|
||||
.wp-wcm__btn--quiet {
|
||||
border-color: transparent;
|
||||
color: var(--wp-text-dim, var(--wp-text3));
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
// R3: IdentitySection frame-active gating — wildcard variant.
|
||||
// variable_binding is a structural field that is meaningless to change
|
||||
// on a per-frame basis, so the binding input must be disabled when
|
||||
// frameActive=true.
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import IdentitySection from "./IdentitySection.vue";
|
||||
import type { ModuleEntry } from "../../../../../widgets/_shared";
|
||||
|
||||
function makeModule(overrides: Partial<ModuleEntry> = {}): ModuleEntry {
|
||||
return {
|
||||
id: "ab12cd34",
|
||||
type: "wildcard",
|
||||
enabled: true,
|
||||
meta: { name: "outfit" },
|
||||
entries: [],
|
||||
payload: { var_binding: "outfit", options: [] },
|
||||
instance: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("wildcard IdentitySection — frameActive gating on variable binding", () => {
|
||||
it("binding input is NOT disabled in base mode (frameActive absent)", () => {
|
||||
const w = mount(IdentitySection, { props: { module: makeModule() } });
|
||||
const input = w.find('[data-test="id-binding"]');
|
||||
expect(input.attributes("disabled")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("binding input is NOT disabled when frameActive=false", () => {
|
||||
const w = mount(IdentitySection, { props: { module: makeModule(), frameActive: false } });
|
||||
const input = w.find('[data-test="id-binding"]');
|
||||
expect(input.attributes("disabled")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("binding input IS disabled when frameActive=true", () => {
|
||||
const w = mount(IdentitySection, { props: { module: makeModule(), frameActive: true } });
|
||||
const input = w.find('[data-test="id-binding"]');
|
||||
expect(input.attributes("disabled")).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows binding frame-active hint when frameActive=true", () => {
|
||||
const w = mount(IdentitySection, { props: { module: makeModule(), frameActive: true } });
|
||||
expect(w.find('[data-test="id-binding-frame-hint"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT show binding frame-active hint when frameActive=false", () => {
|
||||
const w = mount(IdentitySection, { props: { module: makeModule(), frameActive: false } });
|
||||
expect(w.find('[data-test="id-binding-frame-hint"]').exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,9 @@ const props = withDefaults(
|
||||
upstreamVars?: string[];
|
||||
/** Names produced by other modules in the SAME Context node. */
|
||||
siblingVars?: string[];
|
||||
/** When true, a frame is active — the variable binding input is
|
||||
* structural (not per-frame) so it is disabled with a hint. */
|
||||
frameActive?: boolean;
|
||||
}>(),
|
||||
{ upstreamVars: () => [], siblingVars: () => [] },
|
||||
);
|
||||
@@ -162,6 +165,7 @@ function onResetBinding(): void {
|
||||
:value="bindingValue"
|
||||
:placeholder="libraryBinding"
|
||||
aria-label="Variable binding"
|
||||
:disabled="frameActive || undefined"
|
||||
@input="onBindingInput"
|
||||
/>
|
||||
</div>
|
||||
@@ -176,6 +180,11 @@ function onResetBinding(): void {
|
||||
><i class="pi pi-replay" aria-hidden="true" /></button>
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
v-if="frameActive"
|
||||
class="id__frame-hint"
|
||||
data-test="id-binding-frame-hint"
|
||||
>Applies to every frame — switch to base to change.</p>
|
||||
<div
|
||||
v-if="collidesWith !== null"
|
||||
class="id__collision"
|
||||
@@ -287,4 +296,9 @@ function onResetBinding(): void {
|
||||
border: 1px solid color-mix(in srgb, var(--wp-accent) 35%, transparent);
|
||||
}
|
||||
.id__collision .pi { font-size: 11px; margin-top: 1px; flex-shrink: 0; }
|
||||
.id__frame-hint {
|
||||
margin: 4px 0 0;
|
||||
font: 10px var(--wp-font-sans);
|
||||
color: var(--wp-text-dim, var(--wp-text3));
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
// R3: RuntimeSection frame-active gating — wildcard variant.
|
||||
// Hide from prompt + Hold across run are run-level controls that apply
|
||||
// to every frame, so they must be disabled (not interactive) when the
|
||||
// user is editing a per-frame override context. Tests assert:
|
||||
// - controls get `disabled` attribute when frameActive=true
|
||||
// - controls are NOT disabled when frameActive is absent/false
|
||||
// - a hint message is shown in frame-active mode
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import RuntimeSection from "./RuntimeSection.vue";
|
||||
import type { ModuleEntry } from "../../../../../widgets/_shared";
|
||||
|
||||
function makeModule(overrides: Partial<ModuleEntry> = {}): ModuleEntry {
|
||||
return {
|
||||
id: "ab12cd34",
|
||||
type: "wildcard",
|
||||
enabled: true,
|
||||
meta: { name: "outfit" },
|
||||
entries: [],
|
||||
payload: { var_binding: "outfit", options: [] },
|
||||
instance: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("wildcard RuntimeSection — frameActive gating", () => {
|
||||
it("hide toggle is NOT disabled in base mode (frameActive absent)", () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule() } });
|
||||
const btn = w.find('[data-test="runtime-hide"]');
|
||||
expect(btn.attributes("disabled")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("hold toggle is NOT disabled in base mode (frameActive absent)", () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule() } });
|
||||
const btn = w.find('[data-test="runtime-hold"]');
|
||||
expect(btn.attributes("disabled")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("hide toggle is NOT disabled when frameActive=false", () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: false } });
|
||||
const btn = w.find('[data-test="runtime-hide"]');
|
||||
expect(btn.attributes("disabled")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("hold toggle is NOT disabled when frameActive=false", () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: false } });
|
||||
const btn = w.find('[data-test="runtime-hold"]');
|
||||
expect(btn.attributes("disabled")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("hide toggle IS disabled when frameActive=true", () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: true } });
|
||||
const btn = w.find('[data-test="runtime-hide"]');
|
||||
expect(btn.attributes("disabled")).toBeDefined();
|
||||
});
|
||||
|
||||
it("hold toggle IS disabled when frameActive=true", () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: true } });
|
||||
const btn = w.find('[data-test="runtime-hold"]');
|
||||
expect(btn.attributes("disabled")).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows frame-active hint when frameActive=true", () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: true } });
|
||||
expect(w.find('[data-test="runtime-frame-hint"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT show frame-active hint when frameActive=false", () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: false } });
|
||||
expect(w.find('[data-test="runtime-frame-hint"]').exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -176,4 +176,21 @@ describe("RuntimeSection", () => {
|
||||
expect(seed).toBeGreaterThanOrEqual(0);
|
||||
expect(seed).toBeLessThanOrEqual(Number.MAX_SAFE_INTEGER);
|
||||
});
|
||||
|
||||
it("seed-scope toggle is off (vary) by default", () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule() } });
|
||||
expect(w.find('[data-test="runtime-hold"]').classes()).not.toContain("toggle--on");
|
||||
});
|
||||
|
||||
it("clicking seed-scope toggle emits patch with seed_scope = hold", async () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule() } });
|
||||
await w.find('[data-test="runtime-hold"]').trigger("click");
|
||||
expect(lastPatch(w).instance?.seed_scope).toBe("hold");
|
||||
});
|
||||
|
||||
it("clicking seed-scope toggle when held emits seed_scope = vary", async () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule({ instance: { seed_scope: "hold" } }) } });
|
||||
await w.find('[data-test="runtime-hold"]').trigger("click");
|
||||
expect(lastPatch(w).instance?.seed_scope).toBe("vary");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@ import { computed } from "vue";
|
||||
import type { ModuleEntry } from "../../../../../widgets/_shared";
|
||||
import SeedLockControls from "../../_shared/SeedLockControls.vue";
|
||||
|
||||
const props = defineProps<{ module: ModuleEntry }>();
|
||||
const props = defineProps<{ module: ModuleEntry; frameActive?: boolean }>();
|
||||
const emit = defineEmits<{ "update": [patch: Partial<ModuleEntry>] }>();
|
||||
|
||||
const instance = computed(() => props.module.instance ?? {});
|
||||
@@ -20,6 +20,11 @@ const internal = computed(() => instance.value.internal === true);
|
||||
function onHideClick(): void {
|
||||
emit("update", { instance: { ...instance.value, internal: !internal.value } });
|
||||
}
|
||||
|
||||
const held = computed(() => instance.value.seed_scope === "hold");
|
||||
function onHoldClick(): void {
|
||||
emit("update", { instance: { ...instance.value, seed_scope: held.value ? "vary" : "hold" } });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -33,11 +38,30 @@ function onHideClick(): void {
|
||||
data-test="runtime-hide"
|
||||
role="switch"
|
||||
:aria-checked="internal"
|
||||
:disabled="frameActive || undefined"
|
||||
@click="onHideClick"
|
||||
>
|
||||
<i class="pi pi-eye-slash" aria-hidden="true" />
|
||||
Hide from prompt
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="toggle"
|
||||
:class="{ 'toggle--on': held }"
|
||||
data-test="runtime-hold"
|
||||
role="switch"
|
||||
:aria-checked="held"
|
||||
:disabled="frameActive || undefined"
|
||||
@click="onHoldClick"
|
||||
>
|
||||
<i class="pi pi-link" aria-hidden="true" />
|
||||
Hold across run
|
||||
</button>
|
||||
<p
|
||||
v-if="frameActive"
|
||||
class="runtime__frame-hint"
|
||||
data-test="runtime-frame-hint"
|
||||
>Hide from prompt and Hold across run apply to every frame — switch to base to change.</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -77,4 +101,13 @@ function onHideClick(): void {
|
||||
background: rgba(99, 102, 241, 0.10);
|
||||
}
|
||||
.toggle--on .pi { color: var(--wp-accent-text, var(--wp-text)); }
|
||||
.toggle:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.runtime__frame-hint {
|
||||
margin: 4px 0 0;
|
||||
font: 10px var(--wp-font-sans);
|
||||
color: var(--wp-text-dim, var(--wp-text3));
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { withFrameInstance, diffInstance, setFrameOverride, clearFrameOverride, toggleFrameLock, toggleFrameEnabled, dropRedundantFrameLockNulls, effectiveEnabled, frameEnableOverride } from "./frame-overrides";
|
||||
import type { ModuleEntry } from "../../widgets/_shared";
|
||||
|
||||
function mod(extra: Partial<ModuleEntry> = {}): ModuleEntry {
|
||||
return { id: "m1", type: "wildcard", enabled: true, meta: { name: "x" }, entries: [], instance: {}, ...extra };
|
||||
}
|
||||
|
||||
describe("frame-overrides", () => {
|
||||
it("withFrameInstance overlays the frame patch onto the base instance", () => {
|
||||
const m = mod({
|
||||
instance: { variable_binding: "x" },
|
||||
iteration_overrides: { "2": { pinned_option_id: "0", mode: "pinned" } },
|
||||
});
|
||||
const r = withFrameInstance(m, 2);
|
||||
expect(r.instance).toEqual({ variable_binding: "x", pinned_option_id: "0", mode: "pinned" });
|
||||
expect(withFrameInstance(m, null)).toBe(m);
|
||||
expect(withFrameInstance(m, 1)).toBe(m);
|
||||
});
|
||||
it("diffInstance captures only changed declared fields", () => {
|
||||
const base = { variable_binding: "x", pinned_option_id: null };
|
||||
const edited = { variable_binding: "x", pinned_option_id: "0", mode: "pinned" };
|
||||
expect(diffInstance(base, edited, ["pinned_option_id", "mode", "variable_binding"]))
|
||||
.toEqual({ pinned_option_id: "0", mode: "pinned" });
|
||||
});
|
||||
it("setFrameOverride writes merged and clearFrameOverride removes", () => {
|
||||
const m = mod();
|
||||
const a = setFrameOverride(m, 2, { pinned_option_id: "0" });
|
||||
expect(a.iteration_overrides).toEqual({ "2": { pinned_option_id: "0" } });
|
||||
const b = clearFrameOverride(a, 2);
|
||||
expect(b.iteration_overrides).toBeUndefined();
|
||||
});
|
||||
it("toggleFrameLock: locks an unlocked frame with the fallback", () => {
|
||||
const m = mod();
|
||||
const r = toggleFrameLock(m, 2, 999);
|
||||
expect(r.iteration_overrides?.["2"]).toEqual({ locked_seed: 999 });
|
||||
});
|
||||
it("toggleFrameLock: unlocking a frame-only lock drops the key", () => {
|
||||
const m = mod({ iteration_overrides: { "2": { locked_seed: 999 } } });
|
||||
const r = toggleFrameLock(m, 2, 0);
|
||||
expect(r.iteration_overrides).toBeUndefined();
|
||||
});
|
||||
it("toggleFrameLock: unlocking a frame writes null when base is locked", () => {
|
||||
const m = mod({ instance: { locked_seed: 5 } });
|
||||
const r = toggleFrameLock(m, 2, 0);
|
||||
expect(r.iteration_overrides?.["2"]).toEqual({ locked_seed: null });
|
||||
});
|
||||
|
||||
// ── per-frame enable override (frame_enabled) ──────────────────────────
|
||||
it("effectiveEnabled: frame override wins, else base", () => {
|
||||
expect(effectiveEnabled(mod({ enabled: true }), null)).toBe(true);
|
||||
expect(effectiveEnabled(mod({ enabled: true }), 2)).toBe(true); // no override → base
|
||||
const off = mod({ enabled: true, frame_enabled: { "2": false } });
|
||||
expect(effectiveEnabled(off, 2)).toBe(false);
|
||||
expect(effectiveEnabled(off, 1)).toBe(true);
|
||||
const on = mod({ enabled: false, frame_enabled: { "2": true } });
|
||||
expect(effectiveEnabled(on, 2)).toBe(true); // base off, frame on
|
||||
expect(effectiveEnabled(on, 1)).toBe(false); // base off elsewhere
|
||||
expect(effectiveEnabled(on, null)).toBe(false); // base view
|
||||
});
|
||||
it("effectiveEnabled: folds legacy disabled_frames", () => {
|
||||
const m = mod({ enabled: true, disabled_frames: [2] });
|
||||
expect(effectiveEnabled(m, 2)).toBe(false);
|
||||
expect(effectiveEnabled(m, 0)).toBe(true);
|
||||
});
|
||||
it("frameEnableOverride: reports on / off / null vs base", () => {
|
||||
expect(frameEnableOverride(mod({ enabled: true, frame_enabled: { "2": false } }), 2)).toBe("off");
|
||||
expect(frameEnableOverride(mod({ enabled: false, frame_enabled: { "2": true } }), 2)).toBe("on");
|
||||
expect(frameEnableOverride(mod({ enabled: true }), 2)).toBeNull();
|
||||
expect(frameEnableOverride(mod({ enabled: true, frame_enabled: { "2": true } }), 2)).toBeNull(); // equals base
|
||||
expect(frameEnableOverride(mod({ enabled: true, frame_enabled: { "2": false } }), null)).toBeNull();
|
||||
});
|
||||
it("toggleFrameEnabled: a base-OFF module turns ON for the frame (the bug fix)", () => {
|
||||
const r = toggleFrameEnabled(mod({ enabled: false }), 2);
|
||||
expect(r.frame_enabled).toEqual({ "2": true });
|
||||
expect(r.enabled).toBe(false); // base untouched
|
||||
});
|
||||
it("toggleFrameEnabled: a base-ON module turns OFF for the frame", () => {
|
||||
const r = toggleFrameEnabled(mod({ enabled: true }), 2);
|
||||
expect(r.frame_enabled).toEqual({ "2": false });
|
||||
});
|
||||
it("toggleFrameEnabled: toggling back to base drops the override + the field", () => {
|
||||
const r = toggleFrameEnabled(mod({ enabled: true, frame_enabled: { "2": false } }), 2);
|
||||
expect(r.frame_enabled).toBeUndefined();
|
||||
});
|
||||
it("toggleFrameEnabled: migrates legacy disabled_frames into frame_enabled", () => {
|
||||
const r = toggleFrameEnabled(mod({ enabled: true, disabled_frames: [1] }), 2);
|
||||
expect(r.disabled_frames).toBeUndefined();
|
||||
expect(r.frame_enabled).toEqual({ "1": false, "2": false });
|
||||
});
|
||||
it("toggleFrameEnabled: recovers the user's stuck state (base off, stray disabled_frames)", () => {
|
||||
// Repro of the QA bug: base disabled with a leftover blocklist entry;
|
||||
// enabling frame #2 should run ONLY #2 and clear the noise.
|
||||
const r = toggleFrameEnabled(mod({ enabled: false, disabled_frames: [1] }), 2);
|
||||
expect(r.disabled_frames).toBeUndefined();
|
||||
expect(r.frame_enabled).toEqual({ "2": true });
|
||||
});
|
||||
|
||||
// ── dropRedundantFrameLockNulls ────────────────────────────────────────
|
||||
it("dropRedundantFrameLockNulls: clears a frame locked_seed:null once base is unlocked", () => {
|
||||
// Repro of the QA bug: lock #4, lock base, unlock #4 (writes null override),
|
||||
// unlock base → the null override is now redundant and must be swept.
|
||||
const m = mod({ instance: {}, iteration_overrides: { "3": { locked_seed: null } } });
|
||||
expect(dropRedundantFrameLockNulls(m).iteration_overrides).toBeUndefined();
|
||||
});
|
||||
it("dropRedundantFrameLockNulls: KEEPS the null override while base is locked", () => {
|
||||
const m = mod({ instance: { locked_seed: 5 }, iteration_overrides: { "3": { locked_seed: null } } });
|
||||
expect(dropRedundantFrameLockNulls(m).iteration_overrides).toEqual({ "3": { locked_seed: null } });
|
||||
});
|
||||
it("dropRedundantFrameLockNulls: keeps a frame's real lock + other override fields", () => {
|
||||
const m = mod({
|
||||
instance: {},
|
||||
iteration_overrides: { "1": { locked_seed: 99 }, "2": { locked_seed: null, pinned_option_id: "0" } },
|
||||
});
|
||||
expect(dropRedundantFrameLockNulls(m).iteration_overrides)
|
||||
.toEqual({ "1": { locked_seed: 99 }, "2": { pinned_option_id: "0" } });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
import type { ModuleEntry } from "../../widgets/_shared";
|
||||
|
||||
type Instance = NonNullable<ModuleEntry["instance"]>;
|
||||
type IterationOverrides = NonNullable<ModuleEntry["iteration_overrides"]>;
|
||||
|
||||
/** Effective module at frame `k`: base instance overlaid with the frame's
|
||||
* override patch. Returns the same reference when there's nothing to overlay. */
|
||||
export function withFrameInstance(module: ModuleEntry, frame: number | null): ModuleEntry {
|
||||
if (frame == null) return module;
|
||||
const patch = module.iteration_overrides?.[String(frame)];
|
||||
if (!patch) return module;
|
||||
return { ...module, instance: { ...(module.instance ?? {}), ...patch } };
|
||||
}
|
||||
|
||||
/** Changed override fields between base and edited instances, scoped to `fields`.
|
||||
* Per-field JSON compare (values are small + JSON-able). Accepts loose
|
||||
* Record type so callers can pass plain object literals without casting. */
|
||||
export function diffInstance(
|
||||
base: Record<string, unknown> | Instance | undefined,
|
||||
edited: Record<string, unknown> | Instance | undefined,
|
||||
fields: readonly string[],
|
||||
): Record<string, unknown> {
|
||||
const b = (base ?? {}) as Record<string, unknown>;
|
||||
const e = (edited ?? {}) as Record<string, unknown>;
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const f of fields) {
|
||||
if (JSON.stringify(b[f]) !== JSON.stringify(e[f])) out[f] = e[f];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Merge `patch` into the module's frame override (clone). Empty result drops
|
||||
* the frame key; empty map drops `iteration_overrides` entirely. */
|
||||
export function setFrameOverride(
|
||||
module: ModuleEntry, frame: number, patch: Record<string, unknown>,
|
||||
): ModuleEntry {
|
||||
const map: IterationOverrides = { ...(module.iteration_overrides ?? {}) };
|
||||
const key = String(frame);
|
||||
const merged = { ...(map[key] ?? {}), ...patch } as Partial<Instance>;
|
||||
if (Object.keys(merged).length === 0) {
|
||||
delete map[key];
|
||||
} else {
|
||||
map[key] = merged;
|
||||
}
|
||||
const out: ModuleEntry = { ...module, iteration_overrides: map };
|
||||
if (Object.keys(map).length === 0) delete out.iteration_overrides;
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Remove a frame's override entirely (clone). */
|
||||
export function clearFrameOverride(module: ModuleEntry, frame: number): ModuleEntry {
|
||||
if (!module.iteration_overrides) return module;
|
||||
const map: IterationOverrides = { ...module.iteration_overrides };
|
||||
delete map[String(frame)];
|
||||
const out: ModuleEntry = { ...module, iteration_overrides: map };
|
||||
if (Object.keys(map).length === 0) delete out.iteration_overrides;
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Toggle locked_seed for ONE frame (the row quick-lock when a frame is active).
|
||||
* Locks with `fallback` if currently unlocked at that frame; otherwise unlocks —
|
||||
* writing an explicit `null` when the BASE is locked (to override it on this frame)
|
||||
* or dropping the frame's locked_seed when the base is already unlocked. */
|
||||
export function toggleFrameLock(module: ModuleEntry, frame: number, fallback: number): ModuleEntry {
|
||||
const eff = withFrameInstance(module, frame).instance ?? {};
|
||||
const baseLocked = typeof module.instance?.locked_seed === "number";
|
||||
if (typeof eff.locked_seed === "number") {
|
||||
if (baseLocked) return setFrameOverride(module, frame, { locked_seed: null });
|
||||
const rest = { ...(module.iteration_overrides?.[String(frame)] ?? {}) } as Record<string, unknown>;
|
||||
delete rest.locked_seed;
|
||||
let next = clearFrameOverride(module, frame);
|
||||
if (Object.keys(rest).length) next = setFrameOverride(next, frame, rest);
|
||||
return next;
|
||||
}
|
||||
return setFrameOverride(module, frame, { locked_seed: fallback });
|
||||
}
|
||||
|
||||
/** Per-frame enabled state at frame `k`: the explicit `frame_enabled[k]`
|
||||
* override if present, else the legacy `disabled_frames` blocklist folded in
|
||||
* (a listed frame reads as `false`), else `undefined` — meaning "no override,
|
||||
* use base `enabled`". */
|
||||
export function frameEnabledAt(module: ModuleEntry, k: number): boolean | undefined {
|
||||
const fe = module.frame_enabled?.[String(k)];
|
||||
if (typeof fe === "boolean") return fe;
|
||||
if ((module.disabled_frames ?? []).includes(k)) return false;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Effective enabled state in the current view: `frame_enabled[k] ?? base
|
||||
* enabled` when a frame is active, base `enabled` otherwise. */
|
||||
export function effectiveEnabled(module: ModuleEntry, frame: number | null): boolean {
|
||||
const base = module.enabled !== false;
|
||||
if (frame == null) return base;
|
||||
return frameEnabledAt(module, frame) ?? base;
|
||||
}
|
||||
|
||||
/** Per-frame enable override vs base at `frame`: "on" (forced on while base
|
||||
* off), "off" (forced off while base on), or null (no frame active / no
|
||||
* override / equals base). Drives the on-#k / off-#k row badge. */
|
||||
export function frameEnableOverride(
|
||||
module: ModuleEntry, frame: number | null,
|
||||
): "on" | "off" | null {
|
||||
if (frame == null) return null;
|
||||
const fe = frameEnabledAt(module, frame);
|
||||
if (fe == null) return null;
|
||||
const base = module.enabled !== false;
|
||||
if (fe === base) return null;
|
||||
return fe ? "on" : "off";
|
||||
}
|
||||
|
||||
/** Toggle a module's per-frame enable state for ONE frame (the row checkbox when
|
||||
* a frame is active). Sets `frame_enabled[frame]` to the opposite of the current
|
||||
* effective state — symmetric, so a base-off module turns ON for the frame and a
|
||||
* base-on module turns OFF. Migrates any legacy `disabled_frames` into the map,
|
||||
* then drops entries that match base so only real overrides persist. Base
|
||||
* `enabled` is never touched. */
|
||||
export function toggleFrameEnabled(module: ModuleEntry, frame: number): ModuleEntry {
|
||||
const base = module.enabled !== false;
|
||||
const next = !(frameEnabledAt(module, frame) ?? base);
|
||||
const map: Record<string, boolean> = { ...(module.frame_enabled ?? {}) };
|
||||
for (const df of module.disabled_frames ?? []) map[String(df)] = false;
|
||||
map[String(frame)] = next;
|
||||
for (const key of Object.keys(map)) {
|
||||
if (map[key] === base) delete map[key];
|
||||
}
|
||||
const out: ModuleEntry = { ...module };
|
||||
delete out.disabled_frames;
|
||||
if (Object.keys(map).length === 0) delete out.frame_enabled;
|
||||
else out.frame_enabled = map;
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Drop redundant frame `locked_seed: null` overrides. A frame's
|
||||
* `locked_seed: null` exists ONLY to force that frame UNLOCKED over a BASE
|
||||
* lock; once the base is itself unlocked it's meaningless, so it's swept (and
|
||||
* the frame's override entry removed when nothing else remains). No-op while
|
||||
* the base stays locked. Without this, unlocking base after unlocking a frame
|
||||
* leaves a stale entry so the row keeps showing "override #k". */
|
||||
export function dropRedundantFrameLockNulls(module: ModuleEntry): ModuleEntry {
|
||||
const ov = module.iteration_overrides;
|
||||
if (!ov || typeof module.instance?.locked_seed === "number") return module;
|
||||
const next: IterationOverrides = {};
|
||||
let changed = false;
|
||||
for (const [k, patch] of Object.entries(ov)) {
|
||||
if (patch && (patch as Record<string, unknown>).locked_seed === null) {
|
||||
const rest = { ...patch } as Record<string, unknown>;
|
||||
delete rest.locked_seed;
|
||||
changed = true;
|
||||
if (Object.keys(rest).length) next[k] = rest as Partial<Instance>;
|
||||
} else {
|
||||
next[k] = patch;
|
||||
}
|
||||
}
|
||||
if (!changed) return module;
|
||||
const out: ModuleEntry = { ...module, iteration_overrides: next };
|
||||
if (Object.keys(next).length === 0) delete out.iteration_overrides;
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { InjectionKey } from "vue";
|
||||
import type { ModuleEntry } from "../../widgets/_shared";
|
||||
|
||||
/**
|
||||
* Resolves the seed to pin when the user locks a module's seed — the seed the
|
||||
* module ACTUALLY rolled: frame #k's captured seed when an iteration frame is
|
||||
* active, else the module's last-run seed. Provided by ContextWidget (which
|
||||
* owns the frame cursor + the run-seed readers) and injected by
|
||||
* SeedLockControls, which lives deep inside the per-kind edit modal. Returns
|
||||
* null when unavailable (cold start, no run yet) → the control falls back to
|
||||
* its remembered seed, then a fresh random.
|
||||
*/
|
||||
export type LockSeedResolver = (module: ModuleEntry) => number | null;
|
||||
|
||||
export const LockSeedKey: InjectionKey<LockSeedResolver> = Symbol("wpLockSeed");
|
||||
@@ -53,6 +53,25 @@ export interface ModuleRowCtx {
|
||||
* single `↥×N` chip. Empty on rows no constraint covers (every
|
||||
* constraint / source / non-target row). */
|
||||
contributorsFor: (id: string) => PairingBadge[];
|
||||
/** Reactive cursor: the frame currently open in the override editor,
|
||||
* or `null` when no frame is active (base view). */
|
||||
currentFrame: Ref<number | null>;
|
||||
/** True when the module's seed is held constant across all loop
|
||||
* iterations (`instance.seed_scope === "hold"`). */
|
||||
isHeld: (m: ModuleEntry) => boolean;
|
||||
/** True when the module has an override entry for `currentFrame`. */
|
||||
isOverriddenOnFrame: (m: ModuleEntry) => boolean;
|
||||
/** The effective locked_seed for this module at `currentFrame` (or base when
|
||||
* no frame is active). `undefined` when the effective instance is unlocked. */
|
||||
effectiveLockedSeed: (m: ModuleEntry) => number | undefined;
|
||||
/** Effective enabled state for the current view: `frame_enabled[k] ?? base
|
||||
* enabled` when a frame is active, base `enabled` otherwise. Drives the row
|
||||
* checkbox + the disabled (hatched) row styling. */
|
||||
effectiveEnabled: (m: ModuleEntry) => boolean;
|
||||
/** Per-frame enable override vs base at `currentFrame`: "on" (forced on while
|
||||
* base off), "off" (forced off while base on), or null (no frame active / no
|
||||
* override / equals base). Drives the on-#k / off-#k row badge. */
|
||||
frameEnableOverride: (m: ModuleEntry) => "on" | "off" | null;
|
||||
}
|
||||
|
||||
export const ModuleRowCtxKey: InjectionKey<ModuleRowCtx> = Symbol("moduleRowCtx");
|
||||
|
||||
@@ -33,8 +33,12 @@ const props = withDefaults(
|
||||
* The strategy CHIPS still reflect modelValue.strategy (the local config).
|
||||
* Undefined = use modelValue.strategy (no loop / override off). */
|
||||
previewStrategy?: SeedListStrategy;
|
||||
/** The seed each frame used on the PREVIOUS run, captured from the node's
|
||||
* executed `loop_seeds` UI payload by the host glue. Drives the seed
|
||||
* modal's per-frame "lock previous" button. Null until a run lands. */
|
||||
previousSeeds?: number[] | null;
|
||||
}>(),
|
||||
{ nodeMode: 0, baseSeed: 0, count: 1, previewStrategy: undefined },
|
||||
{ nodeMode: 0, baseSeed: 0, count: 1, previewStrategy: undefined, previousSeeds: null },
|
||||
);
|
||||
|
||||
const emit = defineEmits<{ "update:modelValue": [next: SeedListConfig] }>();
|
||||
@@ -201,6 +205,7 @@ function toggleOverrideStrategy(): void {
|
||||
</div>
|
||||
<SeedListModal v-if="seedsOpen" :node-name="'WP Seed List'" :base-seed="baseSeed"
|
||||
:count="count" :strategy="previewStrategy ?? modelValue.strategy" :seed-locks="modelValue.seed_locks ?? {}"
|
||||
:previous-seeds="previousSeeds"
|
||||
:override-hint="modelValue.override_seed ? 'Base seed comes from the wired loop while Override base seed from loop is on.' : ''"
|
||||
@update:seed-locks="onSeedLocks" @close="seedsOpen = false" />
|
||||
</div>
|
||||
|
||||
@@ -16,6 +16,11 @@ const props = defineProps<{
|
||||
* shows it when "Override Context seed" is OFF (a nudge); Seed List shows
|
||||
* it when "Override base seed from loop" is ON (base comes from the loop). */
|
||||
overrideHint?: string;
|
||||
/** The seed each frame used on the PREVIOUS run (captured loop series),
|
||||
* indexed by frame. Drives the per-row "lock previous" button; absent or
|
||||
* short → that frame's button greys out. Distinct from the computed
|
||||
* `derived` series, which is the UPCOMING seeds from the current base. */
|
||||
previousSeeds?: number[] | null;
|
||||
}>();
|
||||
const emit = defineEmits<{ "update:seedLocks": [next: Record<string, number>]; close: [] }>();
|
||||
|
||||
@@ -128,14 +133,14 @@ onBeforeUnmount(() => {
|
||||
<div class="sm__list">
|
||||
<SeedLockRow v-for="(s, i) in derived" :key="i" :index="i" :derived="s"
|
||||
:locked="Object.prototype.hasOwnProperty.call(seedLocks, String(i))"
|
||||
:seed="seedLocks[String(i)] ?? null" @update="onRow" />
|
||||
:seed="seedLocks[String(i)] ?? null" :previous="previousSeeds?.[i] ?? null" @update="onRow" />
|
||||
<template v-if="inactiveLocks.length">
|
||||
<div class="sm__inactive-label" data-test="mx-seed-inactive">
|
||||
Inactive · won't apply at count {{ derived.length }}
|
||||
</div>
|
||||
<SeedLockRow v-for="i in inactiveLocks" :key="`inact-${i}`" :index="i"
|
||||
:derived="seedLocks[String(i)]" :locked="true" :seed="seedLocks[String(i)]"
|
||||
:inactive="true" @update="onRow" />
|
||||
:previous="previousSeeds?.[i] ?? null" :inactive="true" @update="onRow" />
|
||||
</template>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -10,6 +10,11 @@ const props = defineProps<{
|
||||
/** Locked index beyond the current count — dimmed but still editable;
|
||||
* re-activates if count grows back. */
|
||||
inactive?: boolean;
|
||||
/** The seed this frame ACTUALLY used on the previous run (captured from the
|
||||
* node's loop series). null/undefined when there's no prior run for this
|
||||
* frame — the "lock previous" button greys out. Distinct from `derived`,
|
||||
* which is the UPCOMING seed computed from the current base. */
|
||||
previous?: number | null;
|
||||
}>();
|
||||
const emit = defineEmits<{ update: [payload: { index: number; seed: number | null }] }>();
|
||||
|
||||
@@ -21,6 +26,9 @@ function randomSeed(): number {
|
||||
}
|
||||
function set(seed: number | null) { emit("update", { index: props.index, seed }); }
|
||||
function onLock() { set(props.locked ? null : props.derived); }
|
||||
/** Fill + lock the seed this frame used on the previous run. No-op (button is
|
||||
* disabled) when there's no captured previous for this frame. */
|
||||
function lockPrevious() { if (props.previous != null) set(props.previous); }
|
||||
function onInput(ev: Event) {
|
||||
const n = Number((ev.target as HTMLInputElement).value.trim());
|
||||
if (Number.isFinite(n) && Number.isInteger(n)) set(n);
|
||||
@@ -41,6 +49,17 @@ function bump(d: 1 | -1) {
|
||||
</svg>
|
||||
{{ locked ? "Locked" : "Lock" }}
|
||||
</button>
|
||||
<button type="button" class="toggle toggle--prev" data-test="seedrow-lockprev"
|
||||
:disabled="previous == null || undefined"
|
||||
:title="previous == null
|
||||
? 'No previous run captured for this frame'
|
||||
: `Lock the seed this frame used last run (${previous})`"
|
||||
@click="lockPrevious">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 3v5h5" /><path d="M3.05 13A9 9 0 1 0 6 5.3L3 8" /><path d="M12 7v5l3 2" />
|
||||
</svg>
|
||||
Prev
|
||||
</button>
|
||||
<span class="srow__fill" />
|
||||
<span v-if="!locked" class="derived">
|
||||
<span class="derived__val" data-test="seedrow-derived">{{ derived }}</span>
|
||||
@@ -79,6 +98,11 @@ function bump(d: 1 | -1) {
|
||||
.toggle svg { color: var(--wp-text-dim, var(--wp-text3)); }
|
||||
.toggle--on { border-color: var(--wp-accent); color: var(--wp-accent-text, var(--wp-text)); background: rgba(99,102,241,.10); }
|
||||
.toggle--on svg { color: var(--wp-accent-text, var(--wp-text)); }
|
||||
/* "Lock previous run's seed" — secondary action; greys out when the frame has
|
||||
no captured previous (no prior run). */
|
||||
.toggle--prev { color: var(--wp-text-dim, var(--wp-text3)); }
|
||||
.toggle:disabled { opacity: .4; cursor: not-allowed; }
|
||||
.toggle:disabled:hover { border-color: var(--wp-border); color: var(--wp-text-dim, var(--wp-text3)); }
|
||||
.derived { display: inline-flex; align-items: center; gap: 8px; }
|
||||
.derived__val { font: 11px var(--wp-font-mono, monospace); color: var(--wp-text4, #888); }
|
||||
.derived__tag { font: 600 8px var(--wp-font-sans); text-transform: uppercase; letter-spacing: .07em; color: var(--wp-text-dim, var(--wp-text3)); border: 1px solid var(--wp-border); border-radius: 3px; padding: 1px 5px; }
|
||||
|
||||
@@ -51,6 +51,20 @@ describe("SeedListModal", () => {
|
||||
await w.findAll('[data-test="seedrow-lock"]')[0].trigger("click"); // lock #1 (index 0), sequential derived = 42
|
||||
expect(lastEmit(w)).toEqual({ "0": 42 });
|
||||
});
|
||||
it("threads previousSeeds → each row's lock-previous button (click locks the previous)", async () => {
|
||||
const w = modal({ seedLocks: {}, previousSeeds: [111, 222, 333, 444] });
|
||||
const prevBtns = w.findAll('[data-test="seedrow-lockprev"]');
|
||||
expect(prevBtns).toHaveLength(4);
|
||||
await prevBtns[2].trigger("click"); // frame #3 → its previous seed 333
|
||||
expect(lastEmit(w)).toEqual({ "2": 333 });
|
||||
});
|
||||
it("greys the lock-previous button for frames with no captured previous", () => {
|
||||
const w = modal({ seedLocks: {}, previousSeeds: [111] }); // only frame 0 has a previous
|
||||
const prevBtns = w.findAll('[data-test="seedrow-lockprev"]');
|
||||
expect(prevBtns[0].attributes("disabled")).toBeUndefined();
|
||||
expect(prevBtns[1].attributes("disabled")).toBeDefined();
|
||||
expect(prevBtns[3].attributes("disabled")).toBeDefined();
|
||||
});
|
||||
it("Lock all locks every iteration to its derived seed", async () => {
|
||||
const w = modal({ seedLocks: {} });
|
||||
await w.find('[data-test="mx-seed-lockall"]').trigger("click");
|
||||
|
||||
@@ -37,4 +37,15 @@ describe("SeedLockRow", () => {
|
||||
await w.find('[data-test="seedrow-lock"]').trigger("click");
|
||||
expect(w.emitted("update")![0][0]).toEqual({ index: 0, seed: null });
|
||||
});
|
||||
it("lock-previous: fills + locks the captured previous seed", async () => {
|
||||
const w = row({ previous: 777 });
|
||||
const btn = w.find('[data-test="seedrow-lockprev"]');
|
||||
expect(btn.attributes("disabled")).toBeUndefined();
|
||||
await btn.trigger("click");
|
||||
expect(w.emitted("update")![0][0]).toEqual({ index: 0, seed: 777 });
|
||||
});
|
||||
it("lock-previous: disabled when the frame has no previous seed", () => {
|
||||
expect(row({ previous: null }).find('[data-test="seedrow-lockprev"]').attributes("disabled")).toBeDefined();
|
||||
expect(row().find('[data-test="seedrow-lockprev"]').attributes("disabled")).toBeDefined(); // absent → disabled
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import corpus from "../../../../tests/fixtures/seed-derive-corpus.json";
|
||||
import { deriveLoopSeeds, applySeedLocks, parseSeedLocks } from "../seed-derive";
|
||||
import { deriveLoopSeeds, applySeedLocks, parseSeedLocks, effectiveChainSeed } from "../seed-derive";
|
||||
|
||||
describe("seed-derive (Py≡TS parity)", () => {
|
||||
it("matches the engine corpus for every (base, count, strategy)", () => {
|
||||
@@ -16,6 +16,19 @@ describe("seed-derive (Py≡TS parity)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("effectiveChainSeed (Py≡TS parity)", () => {
|
||||
it("uses an override verbatim — no loop_index XOR (engine no-double-shift rule)", () => {
|
||||
expect(effectiveChainSeed(42, 999, 0)).toBe(999);
|
||||
expect(effectiveChainSeed(42, 999, 3)).toBe(999);
|
||||
});
|
||||
it("XORs loop_index into a constant widget seed only when there is no override", () => {
|
||||
expect(effectiveChainSeed(42, null, 0)).toBe(42);
|
||||
expect(effectiveChainSeed(42, null, 1)).toBe(482072580190692);
|
||||
expect(effectiveChainSeed(42, null, 5)).toBe(343395897700430);
|
||||
expect(effectiveChainSeed(2 ** 60, null, 3)).toBe(385286345697257);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseSeedLocks (Paste — inverse of the modal copy format)", () => {
|
||||
it("parses #N: seed lines into a 0-based lock map", () => {
|
||||
expect(parseSeedLocks("#1: 42\n#2: 43\n#3: 44")).toEqual({ "0": 42, "1": 43, "2": 44 });
|
||||
|
||||
@@ -343,7 +343,8 @@
|
||||
.wp-mod-badge--mod,
|
||||
.wp-mod-badge--drift,
|
||||
.wp-mod-badge--missing,
|
||||
.wp-mod-badge--clash {
|
||||
.wp-mod-badge--clash,
|
||||
.wp-mod-badge--on {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.wp-mod-badge--mod {
|
||||
@@ -358,6 +359,13 @@
|
||||
background: color-mix(in oklab, var(--wp-danger) 18%, transparent);
|
||||
color: var(--wp-danger);
|
||||
}
|
||||
/* `--on` variant: per-frame ENABLE override — module is off on base but turned
|
||||
* ON for this frame. Green (--wp-success) to read as "running here", the
|
||||
* positive inverse of the `--missing` red used for the off-#k badge. */
|
||||
.wp-mod-badge--on {
|
||||
background: color-mix(in oklab, var(--wp-success) 18%, transparent);
|
||||
color: var(--wp-success);
|
||||
}
|
||||
/* `--clash` variant: id reused by a DIFFERENT kind (shared id-space).
|
||||
* Violet (--wp-accent) — distinct from MOD orange / DRIFT amber / MISSING
|
||||
* red so a cross-kind collision is unmistakable. */
|
||||
|
||||
@@ -32,6 +32,25 @@ export function applySeedLocks(derived: number[], locks: Record<number, number>)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* TS mirror of engine/seed_derive.py `effective_chain_seed` — the seed a
|
||||
* WP_Context node rolls at iteration `loopIndex`. An override (the loop's
|
||||
* already-per-iteration derived seed) is used verbatim; only a constant
|
||||
* widget seed (no override) gets the loop_index XOR. Mirrors the engine's
|
||||
* no-double-shift rule so a per-frame seed lock pins the seed the module
|
||||
* actually rolls at that frame.
|
||||
*/
|
||||
export function effectiveChainSeed(
|
||||
widgetSeed: number, seedOverride: number | null, loopIndex: number,
|
||||
): number {
|
||||
const base = seedOverride != null ? BigInt(seedOverride) : BigInt(widgetSeed);
|
||||
if (loopIndex === 0 || seedOverride != null) return Number(base & MASK);
|
||||
const digest = sha256Bytes(`loop:${loopIndex}`);
|
||||
let shift = 0n;
|
||||
for (let b = 0; b < 8; b++) shift = (shift << 8n) | BigInt(digest[b]);
|
||||
return Number((base ^ shift) & MASK);
|
||||
}
|
||||
|
||||
const LOCK_LINE = /^\s*#(\d+)\s*:\s*(-?\d+)\s*$/;
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
findDownstreamAssemblers,
|
||||
findRootGraph,
|
||||
hasUpstreamLoopOverridingSeed,
|
||||
resolveUpstreamLoopSeed,
|
||||
type LiteGraphLike,
|
||||
type LiteNodeLike,
|
||||
} from "./graph";
|
||||
@@ -36,6 +37,55 @@ function fakeContextNode(id: number, vars: string[], upstreamLink?: number): Lit
|
||||
};
|
||||
}
|
||||
|
||||
describe("resolveUpstreamLoopSeed", () => {
|
||||
function loopNode(seed: number, count: number, cfg: object): LiteNodeLike {
|
||||
return {
|
||||
id: 1,
|
||||
type: "WP_ContextLoop",
|
||||
outputs: [{ name: "context", links: [100], type: "PIPELINE_CONTEXT" }],
|
||||
widgets: [
|
||||
{ name: "seed", value: seed },
|
||||
{ name: "count", value: count },
|
||||
{ name: "wp_context_loop_config", value: JSON.stringify(cfg) },
|
||||
],
|
||||
} as LiteNodeLike;
|
||||
}
|
||||
function mkGraph(loop: LiteNodeLike, ctx: LiteNodeLike): LiteGraphLike {
|
||||
return {
|
||||
_nodes: [loop, ctx],
|
||||
links: { 100: { id: 100, origin_id: 1, origin_slot: 0, target_id: 2, target_slot: 0 } },
|
||||
getNodeById: (id) => ({ 1: loop, 2: ctx } as Record<number, LiteNodeLike>)[id] ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
it("reads the Loop's NUMERIC seed + count (regression: widgetValue is string-only)", () => {
|
||||
const loop = loopNode(175588593219397, 4, { override_seed: true, strategy: "hash_index", seed_locks: {} });
|
||||
const ctx = fakeContextNode(2, [], 100);
|
||||
expect(resolveUpstreamLoopSeed(mkGraph(loop, ctx), ctx)).toEqual({
|
||||
overrideSeed: true,
|
||||
baseSeed: 175588593219397,
|
||||
count: 4,
|
||||
strategy: "hash_index",
|
||||
seedLocks: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null when no Loop is upstream", () => {
|
||||
const ctx = fakeContextNode(2, [], undefined);
|
||||
const graph: LiteGraphLike = { _nodes: [ctx], links: {}, getNodeById: () => null };
|
||||
expect(resolveUpstreamLoopSeed(graph, ctx)).toBeNull();
|
||||
});
|
||||
|
||||
it("carries override_seed=false + the Loop's per-iteration seed_locks", () => {
|
||||
const loop = loopNode(10, 3, { override_seed: false, strategy: "sequential", seed_locks: { "1": 999 } });
|
||||
const ctx = fakeContextNode(2, [], 100);
|
||||
const got = resolveUpstreamLoopSeed(mkGraph(loop, ctx), ctx);
|
||||
expect(got?.overrideSeed).toBe(false);
|
||||
expect(got?.strategy).toBe("sequential");
|
||||
expect(got?.seedLocks).toEqual({ "1": 999 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("collectUpstreamVariables", () => {
|
||||
it("returns the upstream chain's writes only — never the starting node's own", () => {
|
||||
const a = fakeContextNode(1, ["style"]);
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
type WildcardOption,
|
||||
} from "../components/context/editors/wildcard/probability";
|
||||
import { ensure as ensurePreviewLookup, lookup as previewLookup } from "./preview-resolver";
|
||||
import type { SeedStrategy } from "../components/shared/seed-derive";
|
||||
|
||||
// ── Subgraph boundary primer ────────────────────────────────────────────
|
||||
// ComfyUI subgraphs are nested LGraph instances attached to a parent node
|
||||
@@ -100,6 +101,14 @@ function widgetValue(node: LiteNodeLike, name: string): string {
|
||||
return typeof w?.value === "string" ? w.value : "";
|
||||
}
|
||||
|
||||
/** Numeric widget read — `widgetValue` is string-only (returns "" for Int /
|
||||
* Float / combo widgets), so a numeric widget like the Loop's `seed`/`count`
|
||||
* must be read here. Coerces the raw value; NaN when absent/unparseable. */
|
||||
function widgetNumber(node: LiteNodeLike, name: string): number {
|
||||
const w = node.widgets?.find((x) => x.name === name);
|
||||
return typeof w?.value === "number" ? w.value : Number(w?.value);
|
||||
}
|
||||
|
||||
|
||||
function isSubgraphNode(n: LiteNodeLike): boolean {
|
||||
if (typeof n.isSubgraphNode === "function") {
|
||||
@@ -430,6 +439,63 @@ export function hasUpstreamLoopOverridingSeed(
|
||||
return false;
|
||||
}
|
||||
|
||||
export interface UpstreamLoopSeed {
|
||||
overrideSeed: boolean;
|
||||
baseSeed: number;
|
||||
count: number;
|
||||
strategy: SeedStrategy;
|
||||
/** 0-based iteration index (stringified) -> pinned seed (the Loop's own
|
||||
* per-iteration seed-modal locks). Overlaid on the derived series. */
|
||||
seedLocks: Record<string, number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk upstream to the first WP_ContextLoop and return its seed-deriving
|
||||
* config — base seed + count + strategy + override flag + per-iteration locks —
|
||||
* or null when no Loop drives this node. Lets a downstream WP_Context compute
|
||||
* the seed each iteration actually rolls (for the per-frame seed lock). Same
|
||||
* chain walk as `hasUpstreamLoopOverridingSeed`.
|
||||
*/
|
||||
export function resolveUpstreamLoopSeed(
|
||||
rootGraph: LiteGraphLike,
|
||||
node: LiteNodeLike,
|
||||
): UpstreamLoopSeed | null {
|
||||
const parents = buildSubgraphParents(rootGraph);
|
||||
const seen = new Set<string>([locator(graphOf(node, rootGraph), node)]);
|
||||
let cur = pipelineUpstreamOf(node, graphOf(node, rootGraph), parents);
|
||||
while (cur && !seen.has(locator(cur.graph, cur.node))) {
|
||||
seen.add(locator(cur.graph, cur.node));
|
||||
if (cur.node.type === "WP_ContextLoop" && !isSkippedMode(cur.node)) {
|
||||
const cfg = parseWidgetJson<{
|
||||
strategy?: string;
|
||||
override_seed?: boolean;
|
||||
seed_locks?: Record<string, number>;
|
||||
}>(widgetValue(cur.node, "wp_context_loop_config"), {});
|
||||
const strategy: SeedStrategy =
|
||||
cfg.strategy === "sequential" || cfg.strategy === "prime_stride"
|
||||
? cfg.strategy
|
||||
: "hash_index";
|
||||
const rawSeed = widgetNumber(cur.node, "seed");
|
||||
const rawCount = widgetNumber(cur.node, "count");
|
||||
const seedLocks: Record<string, number> = {};
|
||||
if (cfg.seed_locks && typeof cfg.seed_locks === "object") {
|
||||
for (const [k, v] of Object.entries(cfg.seed_locks)) {
|
||||
if (typeof v === "number" && Number.isFinite(v)) seedLocks[k] = v;
|
||||
}
|
||||
}
|
||||
return {
|
||||
overrideSeed: cfg.override_seed === true,
|
||||
baseSeed: Number.isFinite(rawSeed) ? rawSeed : 0,
|
||||
count: Number.isFinite(rawCount) && rawCount >= 1 ? Math.floor(rawCount) : 1,
|
||||
strategy,
|
||||
seedLocks,
|
||||
};
|
||||
}
|
||||
cur = pipelineUpstreamOf(cur.node, cur.graph, parents);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Variable bindings contributed by every `WP_ContextInjector` upstream
|
||||
* of `node`. Used by the assembler so the in-template preview shows
|
||||
|
||||
@@ -227,3 +227,73 @@ describe("buildModulePublishable — constraint axis-name backfill", () => {
|
||||
expect(inner).not.toHaveProperty("target_wildcard_name");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildModulePublishable — constraint axis-group backfill", () => {
|
||||
// The grouping (tag_groups) lives on the WILDCARDS, not the constraint. These
|
||||
// are the source/target wildcards as they sit in the live module catalog.
|
||||
const moodWc = {
|
||||
id: "mood0001", type: "wildcard", name: "mood",
|
||||
payload: { var_binding: "mood", tag_groups: { temperament: ["bold", "serene"] } },
|
||||
} as unknown as ModuleRow;
|
||||
const colorWc = {
|
||||
id: "color001", type: "wildcard", name: "color",
|
||||
payload: {
|
||||
var_binding: "color",
|
||||
tag_groups: { saturation: ["vivid", "muted", "pastel"], tone: ["light", "medium", "dark"] },
|
||||
},
|
||||
} as unknown as ModuleRow;
|
||||
// Single-axis wildcard: no tag_groups → nothing to snapshot.
|
||||
const flatWc = {
|
||||
id: "flat0001", type: "wildcard", name: "flat", payload: { var_binding: "flat" },
|
||||
} as unknown as ModuleRow;
|
||||
const catalog: ModuleRow[] = [moodWc, colorWc, flatWc];
|
||||
|
||||
function constraintRow(payload: Record<string, unknown>): ModuleRow {
|
||||
return {
|
||||
id: "cn000002", type: "constraint", name: "Mood x Color",
|
||||
description: "", category_id: null, tags: [], is_favorite: false,
|
||||
payload, payload_hash: "h".repeat(64), version: 1,
|
||||
created_at: "2026-01-01", updated_at: "2026-01-01", content_rating: "safe" as const,
|
||||
} as unknown as ModuleRow;
|
||||
}
|
||||
|
||||
it("snapshots the source/target wildcards' tag_groups into the published constraint", () => {
|
||||
const row = constraintRow({
|
||||
source_wildcard_id: "mood0001", target_wildcard_id: "color001", matrix: {}, exceptions: [],
|
||||
});
|
||||
const inner = buildModulePublishable(row, catalog).payload.payload as Record<string, unknown>;
|
||||
expect(inner.source_groups).toEqual({ temperament: ["bold", "serene"] });
|
||||
expect(inner.target_groups).toEqual({
|
||||
saturation: ["vivid", "muted", "pastel"], tone: ["light", "medium", "dark"],
|
||||
});
|
||||
});
|
||||
|
||||
it("does NOT overwrite groups already embedded on the payload", () => {
|
||||
const row = constraintRow({
|
||||
source_wildcard_id: "mood0001", target_wildcard_id: "color001",
|
||||
source_groups: { custom: ["x"] }, matrix: {}, exceptions: [],
|
||||
});
|
||||
const inner = buildModulePublishable(row, catalog).payload.payload as Record<string, unknown>;
|
||||
expect(inner.source_groups).toEqual({ custom: ["x"] });
|
||||
expect(inner.target_groups).toBeDefined(); // target still backfilled
|
||||
});
|
||||
|
||||
it("leaves groups absent for a single-axis wildcard or a dangling ref", () => {
|
||||
const row = constraintRow({
|
||||
source_wildcard_id: "flat0001", target_wildcard_id: "gone9999", matrix: {}, exceptions: [],
|
||||
});
|
||||
const inner = buildModulePublishable(row, catalog).payload.payload as Record<string, unknown>;
|
||||
expect(inner).not.toHaveProperty("source_groups");
|
||||
expect(inner).not.toHaveProperty("target_groups");
|
||||
});
|
||||
|
||||
it("a constraint carrying snapshotted groups still passes strict validation", () => {
|
||||
const row = constraintRow({
|
||||
source_wildcard_id: "mood0001", target_wildcard_id: "color001", matrix: {}, exceptions: [],
|
||||
});
|
||||
const pub = buildModulePublishable(row, catalog);
|
||||
expect(() =>
|
||||
buildPublishBody({ payload: pub.payload, name: pub.name, description: pub.description }),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -234,6 +234,58 @@ function backfillConstraintNames(
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Backfill a CONSTRAINT payload's axis GROUPINGS from the live catalog —
|
||||
* sibling of `backfillConstraintNames`. A constraint references its source /
|
||||
* target wildcards by id, but the per-axis grouping (`tag_groups: {axis:
|
||||
* [sub-category, …]}`) lives on those WILDCARDS, not on the constraint. The
|
||||
* community preview groups + colours its matrix by those axes, and a STANDALONE
|
||||
* constraint (its wildcards not also published) has no dependency to resolve
|
||||
* them from — so the matrix renders flat. Snapshotting each wildcard's
|
||||
* tag_groups into the published constraint as `source_groups` / `target_groups`
|
||||
* makes the grouping self-contained: the community needs nothing but the
|
||||
* constraint itself.
|
||||
*
|
||||
* Non-mutating; fills only an ABSENT field, only when the id resolves to a
|
||||
* wildcard carrying a non-empty `{axis: string[]}` tag_groups. A no-op for
|
||||
* non-constraints, an empty catalog, single-axis wildcards, or dangling refs
|
||||
* (those fall back to flat, same as a legacy constraint). These extra inner-
|
||||
* payload keys ride the same path the cached names do — the constraint
|
||||
* validator's inner `z.object` strips them on a consumer's install, so they're
|
||||
* preview-only metadata that never reaches the engine.
|
||||
*/
|
||||
function backfillConstraintGroups(
|
||||
type: string,
|
||||
payload: Record<string, unknown>,
|
||||
catalog: ModuleRow[],
|
||||
): Record<string, unknown> {
|
||||
if (type !== "constraint" || catalog.length === 0) return payload;
|
||||
const groupsById = (id: unknown): Record<string, string[]> | undefined => {
|
||||
if (typeof id !== "string") return undefined;
|
||||
const row = catalog.find((m) => m.id === id);
|
||||
const tg = (row?.payload as { tag_groups?: unknown } | undefined)?.tag_groups;
|
||||
if (!tg || typeof tg !== "object") return undefined;
|
||||
const out: Record<string, string[]> = {};
|
||||
for (const [axis, members] of Object.entries(tg as Record<string, unknown>)) {
|
||||
if (Array.isArray(members)) {
|
||||
const tags = members.filter((m): m is string => typeof m === "string");
|
||||
if (tags.length) out[axis] = tags;
|
||||
}
|
||||
}
|
||||
return Object.keys(out).length ? out : undefined;
|
||||
};
|
||||
const out = { ...payload };
|
||||
if (typeof out.source_groups === "undefined") {
|
||||
const g = groupsById(out.source_wildcard_id);
|
||||
if (g) out.source_groups = g;
|
||||
}
|
||||
if (typeof out.target_groups === "undefined") {
|
||||
const g = groupsById(out.target_wildcard_id);
|
||||
if (g) out.target_groups = g;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the engine-row payload for a module library row. Strips
|
||||
* server-stamped lifecycle fields + the local `history` sidecar —
|
||||
@@ -249,9 +301,13 @@ export function buildModulePublishable(
|
||||
row: ModuleRow,
|
||||
catalog: ModuleRow[] = [],
|
||||
): PublishablePayload {
|
||||
const inner = backfillConstraintNames(
|
||||
const inner = backfillConstraintGroups(
|
||||
row.type,
|
||||
stripHistory(row.payload as Record<string, unknown>),
|
||||
backfillConstraintNames(
|
||||
row.type,
|
||||
stripHistory(row.payload as Record<string, unknown>),
|
||||
catalog,
|
||||
),
|
||||
catalog,
|
||||
);
|
||||
const payload: Record<string, unknown> = {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Shared "previous run seed series" capture for the Context Loop + Seed List
|
||||
* widget glue. Both nodes emit their executed per-iteration series as
|
||||
* `loop_seeds` in the node UI; this stores it onto `node.__wp_prev_seeds__` so
|
||||
* the seed modal can offer a per-frame "lock previous" button.
|
||||
*
|
||||
* Mirrors the WP_Context capture in `context.ts`. Captured series is the run
|
||||
* that JUST finished — robust to control_after_generate rotating the live seed
|
||||
* widget to the NEXT run's value. No listener cleanup (same rationale as
|
||||
* context.ts): the handler filters by node id and returns early when stale.
|
||||
*/
|
||||
import { app } from "#comfyui/app";
|
||||
|
||||
type ExecutedEvent = CustomEvent<{ node: string | number; output?: unknown }>;
|
||||
|
||||
/** ComfyUI wraps UI values in single-element arrays; probe both the top-level
|
||||
* and the nested `ui` shape for cross-version safety. */
|
||||
function pickArrayValue(obj: Record<string, unknown>, key: string): unknown {
|
||||
const direct = obj[key];
|
||||
if (direct !== undefined) return Array.isArray(direct) ? direct[0] : direct;
|
||||
const ui = obj.ui as Record<string, unknown> | undefined;
|
||||
if (ui) {
|
||||
const fromUi = ui[key];
|
||||
if (fromUi !== undefined) return Array.isArray(fromUi) ? fromUi[0] : fromUi;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Attach an `executed` listener that captures this node's `loop_seeds` series
|
||||
* onto `node.__wp_prev_seeds__` (or clears it when the series is empty). */
|
||||
export function attachLoopSeedsCapture(node: { id: string | number }): void {
|
||||
function onExecuted(ev: Event): void {
|
||||
const detail = (ev as ExecutedEvent).detail;
|
||||
if (!detail || String(detail.node) !== String(node.id)) return;
|
||||
if (!detail.output || typeof detail.output !== "object") return;
|
||||
const rawSeries = pickArrayValue(detail.output as Record<string, unknown>, "loop_seeds");
|
||||
if (!Array.isArray(rawSeries)) return;
|
||||
const series = rawSeries
|
||||
.map((v) => (typeof v === "number" ? v : Number(v)))
|
||||
.filter((n) => Number.isFinite(n));
|
||||
(node as unknown as { __wp_prev_seeds__?: number[] }).__wp_prev_seeds__ =
|
||||
series.length ? series : undefined;
|
||||
}
|
||||
(app as unknown as { api?: { addEventListener: (n: string, fn: (e: Event) => void) => void } })
|
||||
.api?.addEventListener("executed", onExecuted);
|
||||
}
|
||||
@@ -620,6 +620,10 @@ export interface ModuleEntry {
|
||||
* project, adapted to our `_fresh_instance` shape.
|
||||
*/
|
||||
locked_seed?: number | null;
|
||||
/** Iteration seed scope: "hold" pins this module's RNG to the run base
|
||||
* seed (identical across loop iterations, re-rolls per run); "vary"
|
||||
* (default/absent) rides the per-iteration seed. `locked_seed` wins. */
|
||||
seed_scope?: "vary" | "hold";
|
||||
/**
|
||||
* Internal — when true, every binding this module produces is
|
||||
* marked engine-only. Downstream modules in the same chain can
|
||||
@@ -777,6 +781,19 @@ export interface ModuleEntry {
|
||||
master_lock_by?: string;
|
||||
};
|
||||
};
|
||||
/** Per-frame override patches: stringified 0-based iteration index ->
|
||||
* partial instance merged onto this module's instance for that frame. */
|
||||
iteration_overrides?: Record<string, Partial<NonNullable<ModuleEntry["instance"]>>>;
|
||||
/** Legacy per-frame blocklist (0-based loop indices on which the
|
||||
* module was suppressed). Superseded by `frame_enabled`; still read for
|
||||
* back-compat and folded into `frame_enabled` on the first frame toggle. */
|
||||
disabled_frames?: number[];
|
||||
/** Per-frame enable override: stringified 0-based frame index -> enabled.
|
||||
* Overrides the base `enabled` flag for that frame in EITHER direction — a
|
||||
* base-off module can be turned ON for select frames, a base-on module OFF
|
||||
* for others. effective = frame_enabled[k] ?? base enabled. Only frames that
|
||||
* differ from base are stored. */
|
||||
frame_enabled?: Record<string, boolean>;
|
||||
}
|
||||
|
||||
export function parseWidgetJson<T>(raw: string, fallback: T): T {
|
||||
|
||||
@@ -14,10 +14,12 @@ import {
|
||||
collectUpstreamWildcardUuids,
|
||||
findRootGraph,
|
||||
hasUpstreamLoopOverridingSeed,
|
||||
resolveUpstreamLoopSeed,
|
||||
type LiteGraphLike,
|
||||
type LiteNodeLike,
|
||||
} from "../extension/graph";
|
||||
import { collectCrossNodePairingsFull, collectFullChainModules } from "../extension/cross-node-pairings";
|
||||
import { deriveLoopSeeds, applySeedLocks, effectiveChainSeed } from "../components/shared/seed-derive";
|
||||
import type { ChainModule, PairingBadge, RowPairings, TargetSelect } from "../extension/constraint-pairs";
|
||||
import { reactiveFromGraph, stringArrayEqual } from "../extension/reactive";
|
||||
import { applyVarAccessor, type ResolvedValue } from "./richTokenize";
|
||||
@@ -442,6 +444,50 @@ export function create(node: ContextNode, inputName: string) {
|
||||
.__wp_last_used_seed__;
|
||||
return typeof v === "number" && Number.isFinite(v) ? v : null;
|
||||
}
|
||||
|
||||
// Per-frame seed reader for the iteration-frame lock. Computes the seed
|
||||
// THIS module rolls at frame #k WITHOUT a run, mirroring the engine:
|
||||
// resolve the upstream Loop's derived series (base + strategy + the Loop's
|
||||
// own per-iteration locks) and feed it through effectiveChainSeed exactly
|
||||
// like wp_nodes/context_node.py. Null when no frame active / no Loop /
|
||||
// frame out of range — caller then falls back to the stored seed.
|
||||
function frameSeedReader(frame: number): number | null {
|
||||
if (!Number.isInteger(frame) || frame < 0) return null;
|
||||
// Prefer the seed THIS frame ACTUALLY rolled last run — captured from
|
||||
// the Loop's derived series in the run's UI payload (see onExecuted).
|
||||
// This is the PREVIOUS run's value, which is what "lock to reproduce"
|
||||
// needs; it survives control_after_generate=randomize rotating the
|
||||
// live seed widget to the NEXT run's seed.
|
||||
const captured = (node as unknown as { __wp_loop_seeds__?: number[] }).__wp_loop_seeds__;
|
||||
if (Array.isArray(captured) && frame < captured.length
|
||||
&& typeof captured[frame] === "number") {
|
||||
return captured[frame];
|
||||
}
|
||||
// No run captured yet (cold start) / Loop not driving seeds → compute
|
||||
// from the current Loop config. Exact for fixed control; for randomize
|
||||
// before any run there's nothing better than the about-to-run series.
|
||||
const startGraph =
|
||||
(node as unknown as { graph?: LiteGraphLike }).graph
|
||||
?? (app.graph as unknown as LiteGraphLike);
|
||||
const rootGraph = findRootGraph(startGraph);
|
||||
const loop = resolveUpstreamLoopSeed(rootGraph, node);
|
||||
const seedWidget = (node as unknown as {
|
||||
widgets?: Array<{ name?: string; value?: unknown }>;
|
||||
}).widgets?.find((w) => w.name === "seed");
|
||||
const nodeSeedRaw = Number(seedWidget?.value);
|
||||
const nodeSeed = Number.isFinite(nodeSeedRaw) ? nodeSeedRaw : 0;
|
||||
if (loop && loop.overrideSeed) {
|
||||
if (frame >= loop.count) return null;
|
||||
const series = applySeedLocks(
|
||||
deriveLoopSeeds(loop.baseSeed, loop.count, loop.strategy),
|
||||
loop.seedLocks as unknown as Record<number, number>,
|
||||
);
|
||||
return effectiveChainSeed(nodeSeed, series[frame], frame);
|
||||
}
|
||||
// No override (or no Loop): the per-node widget seed varies per
|
||||
// iteration via the loop_index XOR — same path as a base-off override.
|
||||
return effectiveChainSeed(nodeSeed, null, frame);
|
||||
}
|
||||
return () => {
|
||||
// Reactive lock: read the upstream-override ref inside the
|
||||
// render thunk so Vue tracks it. `syncSeedWidgetGate` is a
|
||||
@@ -462,6 +508,7 @@ export function create(node: ContextNode, inputName: string) {
|
||||
chainModules: chainModulesRef.value,
|
||||
nodeMode: nodeMode.value,
|
||||
lastUsedSeedReader,
|
||||
frameSeedReader,
|
||||
onChange: (json: string) => host.setValue(json),
|
||||
onRequestMinWidth: (w: number) => {
|
||||
if (w === dynamicMinWidth) return;
|
||||
@@ -562,6 +609,18 @@ export function create(node: ContextNode, inputName: string) {
|
||||
(node as unknown as { __wp_last_used_per_module__?: Record<string, number> })
|
||||
.__wp_last_used_per_module__ = perModule;
|
||||
}
|
||||
// The Loop's per-iteration derived series for THIS run. Stored so the
|
||||
// per-frame seed lock pins the seed each frame actually rolled — the
|
||||
// captured series is the PREVIOUS run's (the live seed widget may have
|
||||
// since rotated under control_after_generate=randomize).
|
||||
const rawSeries = pickArrayValue(out, "loop_seeds");
|
||||
if (Array.isArray(rawSeries)) {
|
||||
const series = rawSeries
|
||||
.map((v) => (typeof v === "number" ? v : Number(v)))
|
||||
.filter((n) => Number.isFinite(n));
|
||||
(node as unknown as { __wp_loop_seeds__?: number[] }).__wp_loop_seeds__ =
|
||||
series.length ? series : undefined;
|
||||
}
|
||||
// No fallback when `module_seeds` is missing — the chain-level
|
||||
// `__wp_last_used_seed__` already updated above gives the
|
||||
// toggle a sensible fallback via the chain-level reader path,
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
*/
|
||||
import { defineAsyncComponent, h, ref, type Component } from "vue";
|
||||
import { createDomWidgetHost, type DomWidgetHost, type MountTargetNode } from "./_shared";
|
||||
import { attachLoopSeedsCapture } from "./_seed-capture";
|
||||
import { reactiveFromGraph } from "../extension/reactive";
|
||||
import {
|
||||
emptyContextLoopConfig,
|
||||
@@ -50,6 +51,16 @@ export function create(node: ContextLoopHostNode, inputName: string) {
|
||||
Object.is,
|
||||
);
|
||||
|
||||
// The Loop's per-iteration seed series from the PREVIOUS run, captured in
|
||||
// `onExecuted` below. Drives the seed modal's "lock previous" button.
|
||||
// Reactive so an open modal refreshes the instant a run lands; `onExecuted`
|
||||
// assigns a fresh array each run, so ref equality detects the change.
|
||||
const previousSeeds = reactiveFromGraph(
|
||||
node,
|
||||
() => (node as unknown as { __wp_prev_seeds__?: number[] }).__wp_prev_seeds__ ?? null,
|
||||
Object.is,
|
||||
);
|
||||
|
||||
let host: DomWidgetHost | null = null;
|
||||
|
||||
const wrapper: Component = {
|
||||
@@ -66,6 +77,7 @@ export function create(node: ContextLoopHostNode, inputName: string) {
|
||||
nodeMode: nodeMode.value,
|
||||
baseSeed: baseSeed.value,
|
||||
count: count.value,
|
||||
previousSeeds: previousSeeds.value,
|
||||
"onUpdate:modelValue": onUpdate,
|
||||
});
|
||||
},
|
||||
@@ -81,5 +93,10 @@ export function create(node: ContextLoopHostNode, inputName: string) {
|
||||
minHeight: 140,
|
||||
minWidth: 240,
|
||||
});
|
||||
|
||||
// Capture the Loop's executed `loop_seeds` series for the modal's "lock
|
||||
// previous" button (see _seed-capture).
|
||||
attachLoopSeedsCapture(node);
|
||||
|
||||
return host;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
*/
|
||||
import { defineAsyncComponent, h, ref, watch, type Component } from "vue";
|
||||
import { createDomWidgetHost, type DomWidgetHost, type MountTargetNode } from "./_shared";
|
||||
import { attachLoopSeedsCapture } from "./_seed-capture";
|
||||
import { reactiveFromGraph } from "../extension/reactive";
|
||||
import {
|
||||
emptySeedListConfig,
|
||||
@@ -53,6 +54,16 @@ export function create(node: SeedListHostNode, inputName: string) {
|
||||
Object.is,
|
||||
);
|
||||
|
||||
// The node's per-iteration seed series from the PREVIOUS run, captured in
|
||||
// `onExecuted` below. Drives the seed modal's "lock previous" button.
|
||||
// Reactive so an open modal refreshes the instant a run lands; `onExecuted`
|
||||
// assigns a fresh array each run, so ref equality detects the change.
|
||||
const previousSeeds = reactiveFromGraph(
|
||||
node,
|
||||
() => (node as unknown as { __wp_prev_seeds__?: number[] }).__wp_prev_seeds__ ?? null,
|
||||
Object.is,
|
||||
);
|
||||
|
||||
// Walk the `loop_config` input to its upstream WP_ContextLoop node and
|
||||
// read its stock `seed` + `count` widgets plus `wp_context_loop_config`
|
||||
// strategy. Returns null when unwired. Reactive so the preview updates
|
||||
@@ -142,6 +153,7 @@ export function create(node: SeedListHostNode, inputName: string) {
|
||||
baseSeed: effective.baseSeed,
|
||||
count: effective.count,
|
||||
previewStrategy: effective.strategy,
|
||||
previousSeeds: previousSeeds.value,
|
||||
"onUpdate:modelValue": onUpdate,
|
||||
});
|
||||
};
|
||||
@@ -206,5 +218,9 @@ export function create(node: SeedListHostNode, inputName: string) {
|
||||
prevHasLink = now;
|
||||
});
|
||||
|
||||
// Capture the node's executed `loop_seeds` series for the modal's "lock
|
||||
// previous" button (see _seed-capture).
|
||||
attachLoopSeedsCapture(node);
|
||||
|
||||
return host;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
from engine.pipeline import PipelineEngine
|
||||
|
||||
|
||||
def _run(modules, *, loop_index, seed=42):
|
||||
return PipelineEngine().run(modules, seed=seed, loop_index=loop_index)
|
||||
|
||||
|
||||
def _wildcard(binding, values, *, overrides=None):
|
||||
m = {
|
||||
"type": "wildcard",
|
||||
"payload": {
|
||||
"var_binding": binding,
|
||||
"options": [{"id": str(i), "value": v, "weight": 1.0} for i, v in enumerate(values)],
|
||||
},
|
||||
"instance": {"variable_binding": binding},
|
||||
}
|
||||
if overrides is not None:
|
||||
m["iteration_overrides"] = overrides
|
||||
return m
|
||||
|
||||
|
||||
def test_override_pins_option_only_on_its_frame():
|
||||
# Override frame 2: set mode=pinned + pinned_option_id="0" (value "a").
|
||||
m = _wildcard(
|
||||
"x",
|
||||
["a", "b", "c", "d"],
|
||||
overrides={"2": {"mode": "pinned", "pinned_option_id": "0"}},
|
||||
)
|
||||
# Frame 2 must always return "a" (pinned), regardless of seed.
|
||||
for s in range(10):
|
||||
assert _run([m], loop_index=2, seed=s)["x"] == "a"
|
||||
# Non-override frames must NOT be pinned: check across many seeds to
|
||||
# confirm they produce values other than "a" (RNG-driven diversity).
|
||||
others = {
|
||||
_run([m], loop_index=k, seed=s)["x"]
|
||||
for k in (0, 1, 3, 5)
|
||||
for s in range(20)
|
||||
}
|
||||
assert others != {"a"}
|
||||
|
||||
|
||||
def test_combine_template_override_on_frame():
|
||||
m = {
|
||||
"type": "combine",
|
||||
"payload": {"output_var": "y", "template": "base text"},
|
||||
"instance": {},
|
||||
"iteration_overrides": {"1": {"template_override": "frame one text"}},
|
||||
}
|
||||
assert _run([m], loop_index=1)["y"] == "frame one text"
|
||||
assert _run([m], loop_index=0)["y"] == "base text"
|
||||
|
||||
|
||||
def test_constraint_cell_factor_override_applies_only_on_its_frame():
|
||||
"""iteration_overrides on a constraint module patches the instance before
|
||||
the handler registers its metadata — the wildcard downstream picks up
|
||||
a different matrix on the override frame vs a non-override frame.
|
||||
|
||||
Setup:
|
||||
- source wildcard "src" picks "s1" (pinned on all frames via locked_seed
|
||||
+ mode=pinned so the source is predictable and the matrix row is stable)
|
||||
- constraint: matrix s1 -> t1 boost factor 1.0 (baseline — no real effect)
|
||||
- target wildcard "tgt" has two options "t1" (id="0") and "t2" (id="1"),
|
||||
each weight 1.0. With a large boost factor (100.0) on frame 3, the
|
||||
target wildcard should pick "t1" virtually every time; on other frames
|
||||
the default factor 1.0 leaves both options equally weighted so variance
|
||||
is possible.
|
||||
|
||||
The test asserts:
|
||||
- frame 3 result is "t1" (the boosted option wins under a large factor)
|
||||
- at least one other frame is not forced to "t1" (many_other_results is
|
||||
NOT {"t1"}) — this verifies the override is frame-local, not global.
|
||||
"""
|
||||
src_module = {
|
||||
"type": "wildcard",
|
||||
"id": "src-uuid",
|
||||
"payload": {
|
||||
"var_binding": "src",
|
||||
"options": [{"id": "s0", "value": "s1", "weight": 1.0}],
|
||||
},
|
||||
"instance": {
|
||||
"variable_binding": "src",
|
||||
"mode": "pinned",
|
||||
"pinned_option_id": "s0",
|
||||
},
|
||||
}
|
||||
constraint_module = {
|
||||
"type": "constraint",
|
||||
"id": "con-uuid",
|
||||
"payload": {
|
||||
"source_wildcard_id": "src-uuid",
|
||||
"target_wildcard_id": "tgt-uuid",
|
||||
"matrix": {
|
||||
"s1": {
|
||||
"t1": {"mode": "boost", "factor": 1.0},
|
||||
},
|
||||
},
|
||||
"exceptions": [],
|
||||
},
|
||||
"instance": {},
|
||||
# On frame 3: override cell factor to 100.0 (dominates random picks).
|
||||
"iteration_overrides": {
|
||||
"3": {"cell_factor_overrides": {'["s1","t1"]': 100.0}},
|
||||
},
|
||||
}
|
||||
tgt_module = {
|
||||
"type": "wildcard",
|
||||
"id": "tgt-uuid",
|
||||
"payload": {
|
||||
"var_binding": "tgt",
|
||||
"options": [
|
||||
{"id": "0", "value": "t1", "weight": 1.0},
|
||||
{"id": "1", "value": "t2", "weight": 1.0},
|
||||
],
|
||||
},
|
||||
"instance": {"variable_binding": "tgt"},
|
||||
}
|
||||
chain = [src_module, constraint_module, tgt_module]
|
||||
|
||||
# Frame 3: massive boost forces "t1".
|
||||
result_frame3 = _run(chain, loop_index=3, seed=42)["tgt"]
|
||||
assert result_frame3 == "t1", (
|
||||
f"expected 't1' on frame 3 (boosted), got {result_frame3!r}"
|
||||
)
|
||||
|
||||
# Other frames: run many seeds to confirm "t2" appears at least once,
|
||||
# proving the override does NOT leak across frames.
|
||||
other_results = {
|
||||
_run(chain, loop_index=k, seed=s)["tgt"]
|
||||
for k in (0, 1, 2, 4)
|
||||
for s in range(10)
|
||||
}
|
||||
assert "t2" in other_results, (
|
||||
"expected 't2' to appear on non-override frames but only got: "
|
||||
f"{other_results!r}"
|
||||
)
|
||||
|
||||
|
||||
class _ObjModule:
|
||||
"""Minimal duck-typed module — NOT a dict and NOT a dataclass — to
|
||||
exercise the generic-object snapshot branch in PipelineEngine.run."""
|
||||
|
||||
def __init__(self, **attrs):
|
||||
for key, value in attrs.items():
|
||||
setattr(self, key, value)
|
||||
|
||||
|
||||
def test_iteration_override_applies_to_generic_object_module():
|
||||
"""A module arriving as a generic object (not a dict / not a dataclass)
|
||||
must still honor iteration_overrides. Regression: the generic-object
|
||||
snapshot key tuple omitted iteration_overrides, so per-frame overrides
|
||||
were silently dropped for non-dict modules."""
|
||||
m = _ObjModule(
|
||||
type="combine",
|
||||
payload={"output_var": "y", "template": "base text"},
|
||||
instance={},
|
||||
iteration_overrides={"1": {"template_override": "frame one text"}},
|
||||
)
|
||||
# Frame 1 carries an override → the template is replaced.
|
||||
assert _run([m], loop_index=1)["y"] == "frame one text"
|
||||
# Frame 0 has no override entry → base template.
|
||||
assert _run([m], loop_index=0)["y"] == "base text"
|
||||
|
||||
|
||||
# ── R4: per-frame enable/disable (disabled_frames) ──────────────────────────
|
||||
|
||||
def _wildcard_df(binding, values, *, disabled_frames=None):
|
||||
"""Wildcard module with optional disabled_frames list."""
|
||||
m = {
|
||||
"type": "wildcard",
|
||||
"payload": {
|
||||
"var_binding": binding,
|
||||
"options": [{"id": str(i), "value": v, "weight": 1.0} for i, v in enumerate(values)],
|
||||
},
|
||||
"instance": {"variable_binding": binding},
|
||||
}
|
||||
if disabled_frames is not None:
|
||||
m["disabled_frames"] = disabled_frames
|
||||
return m
|
||||
|
||||
|
||||
def test_disabled_frames_skips_only_on_listed_frame():
|
||||
"""A module with disabled_frames=[2] produces no binding at loop_index=2
|
||||
but produces one at every other frame."""
|
||||
m = _wildcard_df("z", ["alpha", "beta", "gamma"], disabled_frames=[2])
|
||||
|
||||
# Frame 2: module should be skipped — "z" must NOT appear in context.
|
||||
ctx_frame2 = _run([m], loop_index=2)
|
||||
assert "z" not in ctx_frame2, (
|
||||
f"expected 'z' absent at frame 2 (disabled_frames=[2]), got {ctx_frame2.get('z')!r}"
|
||||
)
|
||||
|
||||
# Frames 0, 1, 3: module should run — "z" must be bound.
|
||||
for k in (0, 1, 3):
|
||||
result = _run([m], loop_index=k)
|
||||
assert "z" in result, (
|
||||
f"expected 'z' bound at frame {k} but it was missing; ctx={result!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_disabled_frames_trace_row_carries_skipped_frame_status():
|
||||
"""Trace row at the disabled frame should carry status='skipped_frame'."""
|
||||
m = _wildcard_df("z", ["alpha", "beta"], disabled_frames=[2])
|
||||
|
||||
ctx = _run([m], loop_index=2)
|
||||
trace = ctx.get("__wp_trace__", [])
|
||||
assert len(trace) == 1, f"expected 1 trace row, got {trace!r}"
|
||||
row = trace[0]
|
||||
assert row["status"] == "skipped_frame", (
|
||||
f"expected status='skipped_frame', got {row['status']!r}"
|
||||
)
|
||||
assert row["enabled"] is True, (
|
||||
"frame-skip row must show enabled=True (module is not globally disabled)"
|
||||
)
|
||||
|
||||
|
||||
def test_disabled_frames_multiple_frames():
|
||||
"""disabled_frames=[0, 3] skips frames 0 and 3, runs on 1 and 2."""
|
||||
m = _wildcard_df("w", ["x", "y"], disabled_frames=[0, 3])
|
||||
|
||||
for k in (0, 3):
|
||||
result = _run([m], loop_index=k)
|
||||
assert "w" not in result, (
|
||||
f"expected 'w' absent at disabled frame {k}, got {result.get('w')!r}"
|
||||
)
|
||||
|
||||
for k in (1, 2):
|
||||
result = _run([m], loop_index=k)
|
||||
assert "w" in result, (
|
||||
f"expected 'w' bound at enabled frame {k}"
|
||||
)
|
||||
|
||||
|
||||
# ── frame_enabled: symmetric per-frame enable override ──────────────────────
|
||||
#
|
||||
# Model: effective_enabled(k) = frame_enabled[str(k)] ?? base `enabled`.
|
||||
# - base ON + frame_enabled[k]=False -> module skipped on frame k only
|
||||
# - base OFF + frame_enabled[k]=True -> module runs on frame k only
|
||||
# Legacy `disabled_frames` stays readable (back-compat) and means
|
||||
# frame_enabled[k]=False for each listed frame.
|
||||
|
||||
def _wildcard_fe(binding, values, *, enabled=True, frame_enabled=None):
|
||||
"""Wildcard module with base `enabled` + optional frame_enabled map."""
|
||||
m = {
|
||||
"type": "wildcard",
|
||||
"enabled": enabled,
|
||||
"payload": {
|
||||
"var_binding": binding,
|
||||
"options": [{"id": str(i), "value": v, "weight": 1.0} for i, v in enumerate(values)],
|
||||
},
|
||||
"instance": {"variable_binding": binding},
|
||||
}
|
||||
if frame_enabled is not None:
|
||||
m["frame_enabled"] = frame_enabled
|
||||
return m
|
||||
|
||||
|
||||
def test_frame_enable_overrides_base_off_runs_only_on_that_frame():
|
||||
"""A base-disabled module turned ON for frame 2 runs ONLY on frame 2 —
|
||||
frame_enabled overrides base `enabled` per frame. Impossible under the old
|
||||
disabled_frames blocklist (base-off skipped every frame)."""
|
||||
m = _wildcard_fe("z", ["alpha"], enabled=False, frame_enabled={"2": True})
|
||||
|
||||
# Frame 2: runs despite base-off.
|
||||
assert "z" in _run([m], loop_index=2), "expected 'z' bound on the frame-enabled frame 2"
|
||||
# Every other frame: base-off wins.
|
||||
for k in (0, 1, 3):
|
||||
assert "z" not in _run([m], loop_index=k), f"expected 'z' absent at frame {k} (base off)"
|
||||
|
||||
|
||||
def test_frame_disable_overrides_base_on_via_frame_enabled():
|
||||
"""A base-ON module turned OFF for frame 1 via frame_enabled runs on every
|
||||
frame except 1 — the symmetric inverse of the base-off case."""
|
||||
m = _wildcard_fe("z", ["alpha"], enabled=True, frame_enabled={"1": False})
|
||||
|
||||
assert "z" not in _run([m], loop_index=1), "expected 'z' absent on the frame-disabled frame 1"
|
||||
for k in (0, 2, 3):
|
||||
assert "z" in _run([m], loop_index=k), f"expected 'z' bound at frame {k}"
|
||||
@@ -65,12 +65,14 @@ def test_effective_chain_seed_loop_index_mixes_when_positive():
|
||||
assert b != c
|
||||
|
||||
|
||||
def test_effective_chain_seed_override_path_mixes_loop_index():
|
||||
a = effective_chain_seed(widget_seed=42, seed_override=10, loop_index=0)
|
||||
b = effective_chain_seed(widget_seed=42, seed_override=10, loop_index=1)
|
||||
assert a == 10
|
||||
assert b != 10
|
||||
assert b != 42
|
||||
def test_effective_chain_seed_override_used_as_is_ignores_loop_index():
|
||||
# An override IS the loop's already-per-iteration derived seed, so the
|
||||
# loop_index XOR must NOT be re-applied on top — the override is used
|
||||
# verbatim at every index. (The XOR only exists to vary a CONSTANT widget
|
||||
# seed when there is no override; see the override-OFF mixing test.)
|
||||
assert effective_chain_seed(widget_seed=42, seed_override=10, loop_index=0) == 10
|
||||
assert effective_chain_seed(widget_seed=42, seed_override=10, loop_index=1) == 10
|
||||
assert effective_chain_seed(widget_seed=42, seed_override=10, loop_index=3) == 10
|
||||
|
||||
|
||||
def test_effective_chain_seed_deterministic():
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
from engine.pipeline import PipelineEngine
|
||||
from engine.seed_derive import effective_chain_seed
|
||||
|
||||
|
||||
def _run(modules, *, widget_seed, loop_index):
|
||||
"""Run the engine the way wp_nodes/context_node.py does: a per-iteration
|
||||
chain seed plus a held seed (loop_index pinned to 0)."""
|
||||
chain_seed = effective_chain_seed(
|
||||
widget_seed=widget_seed, seed_override=None, loop_index=loop_index
|
||||
)
|
||||
hold_seed = effective_chain_seed(
|
||||
widget_seed=widget_seed, seed_override=None, loop_index=0
|
||||
)
|
||||
return PipelineEngine().run(
|
||||
modules, seed=chain_seed, hold_seed=hold_seed, loop_index=loop_index
|
||||
)
|
||||
|
||||
|
||||
def test_run_exposes_hold_seed_and_loop_index_in_ctx():
|
||||
ctx = _run([], widget_seed=42, loop_index=3)
|
||||
expected_hold = effective_chain_seed(
|
||||
widget_seed=42, seed_override=None, loop_index=0
|
||||
)
|
||||
assert ctx["__wp_loop_index__"] == 3
|
||||
assert ctx["__wp_node_seed_hold__"] == expected_hold
|
||||
assert ctx["__wp_node_seed__"] != ctx["__wp_node_seed_hold__"]
|
||||
|
||||
|
||||
def _wildcard(binding, values, *, seed_scope=None):
|
||||
instance = {"variable_binding": binding}
|
||||
if seed_scope is not None:
|
||||
instance["seed_scope"] = seed_scope
|
||||
options = [{"id": str(i), "value": v, "weight": 1.0} for i, v in enumerate(values)]
|
||||
return {
|
||||
"type": "wildcard",
|
||||
"payload": {"var_binding": binding, "options": options},
|
||||
"instance": instance,
|
||||
}
|
||||
|
||||
|
||||
VALUES = ["a", "b", "c", "d", "e", "f", "g", "h"]
|
||||
|
||||
|
||||
def test_hold_wildcard_is_identical_across_iterations():
|
||||
held = [
|
||||
_run([_wildcard("x", VALUES, seed_scope="hold")], widget_seed=42, loop_index=k)["x"]
|
||||
for k in range(4)
|
||||
]
|
||||
assert len(set(held)) == 1
|
||||
|
||||
|
||||
def test_vary_wildcard_changes_across_iterations():
|
||||
varied = [_run([_wildcard("x", VALUES)], widget_seed=42, loop_index=k)["x"] for k in range(6)]
|
||||
assert len(set(varied)) > 1
|
||||
|
||||
|
||||
def test_locked_seed_wins_over_hold():
|
||||
m = _wildcard("x", VALUES, seed_scope="hold")
|
||||
m["instance"]["locked_seed"] = 12345
|
||||
a = _run([m], widget_seed=42, loop_index=0)["x"]
|
||||
b = _run([m], widget_seed=999, loop_index=7)["x"]
|
||||
assert a == b
|
||||
|
||||
|
||||
def _combine(output_var, template, *, seed_scope=None):
|
||||
instance = {}
|
||||
if seed_scope is not None:
|
||||
instance["seed_scope"] = seed_scope
|
||||
return {
|
||||
"type": "combine",
|
||||
"payload": {"output_var": output_var, "template": template},
|
||||
"instance": instance,
|
||||
}
|
||||
|
||||
|
||||
COMBINE_TMPL = "{alpha|bravo|charlie|delta|echo|foxtrot|golf|hotel}"
|
||||
|
||||
|
||||
def test_hold_combine_is_identical_across_iterations():
|
||||
held = [
|
||||
_run([_combine("y", COMBINE_TMPL, seed_scope="hold")], widget_seed=42, loop_index=k)["y"]
|
||||
for k in range(4)
|
||||
]
|
||||
assert len(set(held)) == 1
|
||||
|
||||
|
||||
def test_vary_combine_changes_across_iterations():
|
||||
varied = [
|
||||
_run([_combine("y", COMBINE_TMPL)], widget_seed=42, loop_index=k)["y"]
|
||||
for k in range(6)
|
||||
]
|
||||
assert len(set(varied)) > 1
|
||||
|
||||
|
||||
# fixed_values: single entry with name="z" and alternation value so the resolver
|
||||
# picks one of the 8 options based on the seed — same mechanic as combine.
|
||||
FIXED_TMPL = "{a|b|c|d|e|f|g|h}"
|
||||
|
||||
|
||||
def _fixed(binding, *, seed_scope=None):
|
||||
instance = {}
|
||||
if seed_scope is not None:
|
||||
instance["seed_scope"] = seed_scope
|
||||
return {
|
||||
"type": "fixed_values",
|
||||
"payload": {"values": [{"name": binding, "value": FIXED_TMPL}]},
|
||||
"instance": instance,
|
||||
}
|
||||
|
||||
|
||||
def test_hold_fixed_values_is_identical_across_iterations():
|
||||
held = [
|
||||
_run([_fixed("z", seed_scope="hold")], widget_seed=42, loop_index=k)["z"]
|
||||
for k in range(4)
|
||||
]
|
||||
assert len(set(held)) == 1
|
||||
@@ -32,3 +32,14 @@ def test_execute_applies_seed_locks_when_override_on():
|
||||
assert payloads[2].internals["__wp_seed_override__"] == 7
|
||||
assert payloads[0].internals["__wp_seed_override__"] == 42
|
||||
assert payloads[2].internals["__wp_loop_seeds__"] == [42, 43, 7, 45]
|
||||
# The same series surfaces to the node UI so the frontend can offer
|
||||
# "lock previous" in the seed modal.
|
||||
assert out.ui == {"loop_seeds": [[42, 43, 7, 45]]}
|
||||
|
||||
|
||||
def test_execute_emits_empty_ui_seeds_when_override_off():
|
||||
# Override off → the Loop doesn't drive seeds, so there's no series to
|
||||
# offer; the empty UI list greys the "lock previous" button per frame.
|
||||
raw = json.dumps({"strategy": "sequential", "override_seed": False})
|
||||
out = WPContextLoop.execute(seed=42, count=4, wp_context_loop_config=raw)
|
||||
assert out.ui == {"loop_seeds": [[]]}
|
||||
|
||||
@@ -51,7 +51,7 @@ def _ctx_capture():
|
||||
captured: dict = {}
|
||||
|
||||
class _FakePipeline:
|
||||
def run(self, modules, *, ctx, seed):
|
||||
def run(self, modules, *, ctx, seed, hold_seed=None, loop_index=0):
|
||||
captured.clear()
|
||||
captured.update(ctx)
|
||||
return ctx
|
||||
|
||||
@@ -324,7 +324,11 @@ def test_execute_widget_mode_matches_derive_loop_seeds():
|
||||
wp_seed_list_config=_cfg(strategy="hash_index"),
|
||||
loop_config=None,
|
||||
)
|
||||
assert _seeds(out) == derive_loop_seeds(42, 4, "hash_index")
|
||||
seeds = _seeds(out)
|
||||
assert seeds == derive_loop_seeds(42, 4, "hash_index")
|
||||
# The series also surfaces to the node UI so the frontend can offer
|
||||
# "lock previous" in the seed modal.
|
||||
assert out.ui == {"loop_seeds": [[int(s) for s in seeds]]}
|
||||
|
||||
|
||||
def test_execute_widget_mode_ignores_wired_loop_config_when_all_off():
|
||||
|
||||
@@ -216,4 +216,13 @@ class WPContextLoop(io.ComfyNode):
|
||||
debug={},
|
||||
internals=internals,
|
||||
))
|
||||
return io.NodeOutput(payloads, loop_config_payload)
|
||||
# Surface the per-iteration series to the node UI so the frontend can
|
||||
# offer "lock previous" in the seed modal (captured in context_loop.ts).
|
||||
# Only meaningful when the Loop drives the seeds (override on); an empty
|
||||
# list otherwise greys the button out per-frame.
|
||||
ui_seeds = [int(s) for s in derived] if (has_override and derived) else []
|
||||
return io.NodeOutput(
|
||||
payloads,
|
||||
loop_config_payload,
|
||||
ui={"loop_seeds": [ui_seeds]},
|
||||
)
|
||||
|
||||
@@ -157,12 +157,24 @@ class WPContext(io.ComfyNode):
|
||||
# When ContextLoop is upstream with override_seed=true, the
|
||||
# override replaces the widget seed; loop_index>0 XORs in a
|
||||
# stable hash shift so each iteration walks a distinct seed.
|
||||
loop_index = int(upstream_internals.get("__wp_loop_index__", 0))
|
||||
chain_seed = effective_chain_seed(
|
||||
widget_seed=int(seed),
|
||||
seed_override=upstream_internals.get("__wp_seed_override__"),
|
||||
loop_index=int(upstream_internals.get("__wp_loop_index__", 0)),
|
||||
loop_index=loop_index,
|
||||
)
|
||||
hold_seed = effective_chain_seed(
|
||||
widget_seed=int(seed),
|
||||
seed_override=upstream_internals.get("__wp_seed_override__"),
|
||||
loop_index=0,
|
||||
)
|
||||
ctx = PipelineEngine().run(
|
||||
module_list,
|
||||
ctx=ctx,
|
||||
seed=chain_seed,
|
||||
hold_seed=hold_seed,
|
||||
loop_index=loop_index,
|
||||
)
|
||||
ctx = PipelineEngine().run(module_list, ctx=ctx, seed=chain_seed)
|
||||
|
||||
payload = build_payload(ctx, upstream_debug=upstream_debug, seed=chain_seed)
|
||||
# Emit two seed-tracking values via the UI payload so the
|
||||
@@ -193,7 +205,18 @@ class WPContext(io.ComfyNode):
|
||||
es = entry.get("seed")
|
||||
if isinstance(key, str) and key and isinstance(es, int):
|
||||
module_seeds[key] = es
|
||||
# The Loop's full derived per-iteration series (present only when the
|
||||
# Loop drives seeds via override). Captured by the frontend so the
|
||||
# per-frame seed lock pins the seed each frame ACTUALLY rolled THIS
|
||||
# run — robust to control_after_generate=randomize, where the live
|
||||
# seed widget has already rotated to the NEXT run's seed.
|
||||
_loop_seeds = upstream_internals.get("__wp_loop_seeds__")
|
||||
loop_seeds = [int(s) for s in _loop_seeds] if isinstance(_loop_seeds, list) else []
|
||||
return io.NodeOutput(
|
||||
payload,
|
||||
ui={"seed": [int(seed)], "module_seeds": [module_seeds]},
|
||||
ui={
|
||||
"seed": [int(seed)],
|
||||
"module_seeds": [module_seeds],
|
||||
"loop_seeds": [loop_seeds],
|
||||
},
|
||||
)
|
||||
|
||||
@@ -251,4 +251,6 @@ class WPSeedList(io.ComfyNode):
|
||||
derive_loop_seeds(resolved_base, resolved_count, resolved_strategy),
|
||||
cfg["seed_locks"],
|
||||
)
|
||||
return io.NodeOutput(seeds)
|
||||
# Surface the per-iteration series to the node UI so the frontend can
|
||||
# offer "lock previous" in the seed modal (captured in seed_list.ts).
|
||||
return io.NodeOutput(seeds, ui={"loop_seeds": [[int(s) for s in seeds]]})
|
||||
|
||||
Reference in New Issue
Block a user