fix(context): constraint-reach QA, node codenames, bundle-aware toolbar

QA + polish on the unmerged SP3 constraint-reach work, plus the node-id
redesign and bundle-aware bulk toolbar.

- reach badge reactivity: pairingBadgeEqual compares reach + pairingMapEqual
  compares contributors (context.ts) so a target_select edit re-renders.
- TargetReachSection: neutral section highlight + custom up/down count stepper.
- nested carrier renders ONE badge (constraint-pairs.ts: nested coverage goes
  to viaInbound only, not also contributors).
- instance target_select override reaches the badge: cross-node-pairings.ts
  folds the instance value over payload; the modal collapses to null only when
  it equals the library default (preserves an explicit "all"). Engine
  precedence (instance over payload) locked by tests.
- graph.ts writeBindings honors instance.variable_binding, fixing the duplicate
  shadow ("override") badge + missing-var scan + preview for renamed bindings.
- node identity: fixed, unique, POV-independent adjective-noun codename from the
  litegraph node id (node-codename.ts); read-only neutral chip; dropped the
  editable node_label + A/B/C letters.
- nested-pick label reads "@<target> in <carrier>" (target by display name).
- bulk toolbar: toggle-all flips bundle masters + top-level modules (not bundle
  children); collapse/expand-all flips bundle frames; buttons resized to the
  row-icon size.
This commit is contained in:
DumiFlex
2026-06-11 16:38:56 +03:00
parent d826c6ba61
commit 73a00a3760
18 changed files with 994 additions and 335 deletions
+105 -63
View File
@@ -1,6 +1,7 @@
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
import { flushPromises, mount } from "@vue/test-utils";
import ContextWidget from "./ContextWidget.vue";
import { baseCodename } from "../../extension/node-codename";
import { _resetForTests as resetDriftStore } from "./drift-store";
beforeEach(() => {
@@ -2148,32 +2149,30 @@ describe("ContextWidget bundle MOD detection", () => {
});
});
// ── Node label header chip (SP3 P7 T18) ────────────────────────────────────
// A small editable chip in the node header surfaces the per-WP_Context
// `node_label` so cross-node UI (constraint reach pick-list, pair popovers)
// can name WHICH node a target instance lives in. Empty → auto default
// (the upstream→downstream letter the walker assigns, surfaced via
// chainModules; falls back to "A" for a lone node). Editing it persists
// into value.node_label through the same deep-watch onChange the module
// edits use.
// ── Node codename header chip ───────────────────────────────────────────────
// The node header shows a FIXED, read-only codename — a stable
// adjective-noun derived from the litegraph node id (node-codename.ts) — so
// cross-node UI (constraint reach pick-list, pair popovers) can name WHICH
// node a target instance lives in. POV-independent + unique on the canvas,
// and NOT editable (replaced the old editable label + walk-position letter).
describe("ContextWidget node label chip", () => {
it("renders [data-test=node-label] in the header showing the persisted label", () => {
describe("ContextWidget node codename chip", () => {
it("renders [data-test=node-label] showing the node's stable codename", () => {
const wrapper = mount(ContextWidget, {
props: {
nodeId: 7000,
initialJson: JSON.stringify({ version: 1, modules: [], node_label: "Base layer" }),
initialJson: '{"version":1,"modules":[]}',
upstreamVars: [],
onChange: () => {},
},
});
const chip = wrapper.find('[data-test="node-label"]');
expect(chip.exists()).toBe(true);
expect(chip.text()).toContain("Base layer");
expect(chip.text()).toContain(baseCodename(7000));
wrapper.unmount();
});
it("falls back to the auto default letter 'A' when node_label is empty", () => {
it("is read-only — exposes no edit input", () => {
const wrapper = mount(ContextWidget, {
props: {
nodeId: 7001,
@@ -2182,88 +2181,131 @@ describe("ContextWidget node label chip", () => {
onChange: () => {},
},
});
const chip = wrapper.find('[data-test="node-label"]');
expect(chip.exists()).toBe(true);
// Lone node, no chainModules prop → default letter A.
expect(chip.text()).toContain("A");
expect(wrapper.find('[data-test="node-label-input"]').exists()).toBe(false);
wrapper.unmount();
});
it("surfaces the walker-assigned letter from chainModules as the default", () => {
// chainModules carries this node's letter (rowKey `${nodeId}#...`).
// The chip reads it so its default matches what downstream UI sees.
it("derives the codename from the node id, ignoring chainModules / position", () => {
// The core fix for the old A/B/C scheme: the chip is purely id-derived
// now, so it never depends on walk position or any chainModules label.
const wrapper = mount(ContextWidget, {
props: {
nodeId: 7002,
initialJson: '{"version":1,"modules":[]}',
upstreamVars: [],
chainModules: [
{ id: "up", rowKey: "9#up-uid", type: "wildcard", payload: {}, nodeLabel: "A" },
{ id: "own", rowKey: "7002#own-uid", type: "wildcard", payload: {}, nodeLabel: "B" },
{ id: "own", rowKey: "7002#own-uid", type: "wildcard", payload: {}, nodeLabel: "ignored-label" },
],
onChange: () => {},
},
});
const chip = wrapper.find('[data-test="node-label"]');
expect(chip.exists()).toBe(true);
expect(chip.text()).toContain("B");
expect(chip.text()).toContain(baseCodename(7002));
expect(chip.text()).not.toContain("ignored-label");
wrapper.unmount();
});
});
it("editing the chip persists into value.node_label via onChange", async () => {
// ── Bulk toolbar: bundle-aware collapse / expand / toggle-all ────────────────
// Toggle-all must operate on TOP-LEVEL units — standalone modules + bundle
// MASTER toggles — never reach inside a bundle to flip child modules (the
// master gates them; effective = master AND child). Collapse/expand-all must
// flip bundle FRAMES too, not just module cards. The three buttons match the
// small row-icon size.
describe("ContextWidget bulk toolbar — bundle-aware", () => {
function mountMixed(opts: { bundleEnabled?: boolean; stdEnabled?: boolean } = {}) {
const bundleEnabled = opts.bundleEnabled ?? true;
const stdEnabled = opts.stdEnabled ?? true;
const modules = [
{
id: "ch0aaaaa", _uid: "u0", type: "wildcard", enabled: true, collapsed: false,
meta: { name: "ch0" }, entries: [], payload: { var_binding: "v0", options: [] },
payload_hash: "ph0", bundle_origin: "bUID",
},
{
id: "ch1aaaaa", _uid: "u1", type: "wildcard", enabled: true, collapsed: false,
meta: { name: "ch1" }, entries: [], payload: { var_binding: "v1", options: [] },
payload_hash: "ph1", bundle_origin: "bUID",
},
{
id: "stdaaaaa", _uid: "u2", type: "wildcard", enabled: stdEnabled, collapsed: false,
meta: { name: "std" }, entries: [], payload: { var_binding: "vs", options: [] },
payload_hash: "phs",
},
];
const bundles = [{
_uid: "bUID", library_id: "lib_b", start_idx: 0, end_idx: 1,
enabled: bundleEnabled, collapsed: false, name: "Group", color: null,
}];
const onChange = vi.fn();
const wrapper = mount(ContextWidget, {
attachTo: document.body,
props: {
nodeId: 7003,
initialJson: '{"version":1,"modules":[]}',
nodeId: 9400,
initialJson: JSON.stringify({ version: 1, modules, bundles }),
upstreamVars: [],
onChange,
},
});
return { wrapper, onChange };
}
function lastParsed(onChange: ReturnType<typeof vi.fn>) {
const calls = onChange.mock.calls;
return JSON.parse(calls[calls.length - 1][0] as string) as {
modules: Array<{ _uid: string; enabled: boolean; collapsed?: boolean }>;
bundles: Array<{ _uid: string; enabled: boolean; collapsed: boolean }>;
};
}
const findMod = (p: ReturnType<typeof lastParsed>, uid: string) =>
p.modules.find((m) => m._uid === uid)!;
it("toggle-all flips the bundle MASTER + standalone module, leaving bundle children untouched", async () => {
const { wrapper, onChange } = mountMixed();
onChange.mockClear();
await wrapper.find('[aria-label="Toggle all enabled"]').trigger("click");
const p = lastParsed(onChange);
expect(p.bundles[0].enabled).toBe(false); // master flipped off
expect(findMod(p, "u2").enabled).toBe(false); // standalone flipped off
// Children NOT reached — gated by the master, restore-safe.
expect(findMod(p, "u0").enabled).toBe(true);
expect(findMod(p, "u1").enabled).toBe(true);
wrapper.unmount();
});
// Activate the inline editor (double-click the chip), type a label,
// commit on Enter.
const chip = wrapper.find('[data-test="node-label"]');
await chip.trigger("dblclick");
const input = wrapper.find('[data-test="node-label-input"]');
expect(input.exists()).toBe(true);
await input.setValue("Detail pass");
await input.trigger("keydown", { key: "Enter" });
await flushPromises();
expect(onChange).toHaveBeenCalled();
const calls = onChange.mock.calls;
const last = calls[calls.length - 1][0] as string;
expect(JSON.parse(last).node_label).toBe("Detail pass");
it("toggle-all re-enables when every top-level unit is already off", async () => {
const { wrapper, onChange } = mountMixed({ bundleEnabled: false, stdEnabled: false });
onChange.mockClear();
await wrapper.find('[aria-label="Toggle all enabled"]').trigger("click");
const p = lastParsed(onChange);
expect(p.bundles[0].enabled).toBe(true);
expect(findMod(p, "u2").enabled).toBe(true);
wrapper.unmount();
});
it("commits the label on blur as well as Enter", async () => {
const onChange = vi.fn();
const wrapper = mount(ContextWidget, {
attachTo: document.body,
props: {
nodeId: 7004,
initialJson: '{"version":1,"modules":[]}',
upstreamVars: [],
onChange,
},
});
it("collapse-all collapses bundle FRAMES + module cards; expand-all expands both", async () => {
const { wrapper, onChange } = mountMixed();
onChange.mockClear();
await wrapper.find('[aria-label="Collapse all modules"]').trigger("click");
let p = lastParsed(onChange);
expect(p.bundles[0].collapsed).toBe(true);
expect(p.modules.every((m) => m.collapsed === true)).toBe(true);
onChange.mockClear();
const chip = wrapper.find('[data-test="node-label"]');
await chip.trigger("dblclick");
const input = wrapper.find('[data-test="node-label-input"]');
await input.setValue("Via blur");
await input.trigger("blur");
await flushPromises();
await wrapper.find('[aria-label="Expand all modules"]').trigger("click");
p = lastParsed(onChange);
expect(p.bundles[0].collapsed).toBe(false);
expect(p.modules.every((m) => m.collapsed === false)).toBe(true);
wrapper.unmount();
});
expect(onChange).toHaveBeenCalled();
const blurCalls = onChange.mock.calls;
const last = blurCalls[blurCalls.length - 1][0] as string;
expect(JSON.parse(last).node_label).toBe("Via blur");
it("the three bulk buttons use the small row-icon size class", () => {
const { wrapper } = mountMixed();
for (const label of ["Collapse all modules", "Expand all modules", "Toggle all enabled"]) {
const btn = wrapper.find(`[aria-label="${label}"]`);
expect(btn.exists()).toBe(true);
expect(btn.classes()).toContain("wp-btn--icon-sm");
expect(btn.classes()).not.toContain("wp-btn--icon");
}
wrapper.unmount();
});
});
+60 -122
View File
@@ -10,6 +10,7 @@ import {
import { type ResolvedValue } from "../../widgets/richTokenize";
import { scanConflicts, labelFor as conflictLabelFor, shortConflictLabel, type Conflict } from "../../extension/conflicts";
import type { ChainModule, PairingBadge, RowPairings } from "../../extension/constraint-pairs";
import { baseCodename } from "../../extension/node-codename";
import {
getCollapseMode,
getCollapsedByDefault,
@@ -3682,7 +3683,12 @@ function toggleCollapsed(idx: number) {
* cards already match the target state (the deep watcher will
* diff-eq and skip the onChange emit). */
function setAllCollapsed(collapsed: boolean) {
commitModules(value.value.modules.map((m) => ({ ...m, collapsed })));
const nextModules = value.value.modules.map((m) => ({ ...m, collapsed }));
// Bundle FRAMES carry their own `collapsed` flag (separate from the child
// module cards). Collapse/expand-all flips both so the action visibly folds
// bundle frames, not just loose modules.
const nextBundles = (value.value.bundles ?? []).map((b) => ({ ...b, collapsed }));
commitModules(nextModules, nextBundles);
}
/** Toolbar counts — total modules + how many are effectively enabled
@@ -3693,80 +3699,42 @@ const enabledCount = computed(() => {
return value.value.modules.filter((m) => isModuleEffectivelyEnabled(m, bundleEnabled)).length;
});
// ── Node label chip ────────────────────────────────────────────────────────
// A small editable chip in the header surfaces this WP_Context node's
// identity (`value.node_label`) so cross-node UI — the constraint reach
// pick-list + pair popovers built in later phases — can name WHICH node a
// target instance lives in. When the user hasn't named the node, the chip
// shows the same auto upstream→downstream letter the cross-node walker
// assigns, kept in sync by reading it off the `chainModules` prop.
/** Auto default label = the letter the walker stamped onto THIS node's
* modules. `chainModules` is keyed graph-wide via `${nodeId}#${_uid}`
* rowKeys, so the first entry whose rowKey starts with our nodeId carries
* our node's `nodeLabel`. Falls back to "A" for a lone / headless node
* with no chain (matches the walker's first-position letter). */
const defaultNodeLabel = computed<string>(() => {
const prefix = `${props.nodeId}#`;
const mine = (props.chainModules ?? []).find((m) => m.rowKey.startsWith(prefix));
return mine?.nodeLabel ?? "A";
});
/** What the chip renders: the user-set label when present, else the auto
* default letter. */
const displayNodeLabel = computed<string>(() => {
const explicit = value.value.node_label?.trim();
return explicit && explicit.length > 0 ? explicit : defaultNodeLabel.value;
});
const editingLabel = ref(false);
const labelDraft = ref("");
const labelInputEl = ref<HTMLInputElement | null>(null);
/** Enter inline-edit: seed the draft with the CURRENT label (the explicit
* one if set, otherwise the auto default so the user edits from the
* visible value rather than an empty box) + focus/select on next tick. */
function startLabelEdit(): void {
labelDraft.value = value.value.node_label?.trim() || defaultNodeLabel.value;
editingLabel.value = true;
void nextTick(() => {
labelInputEl.value?.focus();
labelInputEl.value?.select();
});
}
/** Commit the draft into `value.node_label` (deep-watch fires onChange so
* the workflow JSON persists). A draft equal to the auto default is still
* stored verbatim — the user explicitly chose it, and the walker only
* fills the default when the field is ABSENT, so round-tripping "A" keeps
* the chip showing "A" without surprise. Empty draft clears the override
* (deletes the field) so the chip reverts to the auto letter. */
function commitLabelEdit(): void {
if (!editingLabel.value) return;
editingLabel.value = false;
const next = labelDraft.value.trim();
const prev = value.value.node_label ?? "";
if (next === prev) return; // no-op — don't churn onChange
if (next.length === 0) {
// Clear the override → revert to auto default.
const { node_label: _drop, ...rest } = value.value;
value.value = { ...rest };
return;
}
value.value = { ...value.value, node_label: next };
}
function cancelLabelEdit(): void {
editingLabel.value = false;
}
// ── Node codename chip ──────────────────────────────────────────────────────
// A fixed, read-only CODENAME identifies THIS WP_Context node: a stable
// `adjective-noun` derived from the litegraph node id (node-codename.ts).
// POV-independent + unique on the canvas, and NOT user-editable — cross-node
// UI (constraint reach pick-list, pair popovers) shows the SAME codename to
// name WHICH node a target instance lives in. Replaced the old editable label
// + walk-position A/B/C letter, which shifted by viewer (so two chain heads
// both showed "A") and was editable (so not a stable identifier).
const nodeCodename = computed<string>(() => baseCodename(props.nodeId));
/** Toolbar bulk actions — wrappers so the toolbar template stays compact. */
function collapseAll(): void { setAllCollapsed(true); }
function expandAll(): void { setAllCollapsed(false); }
function toggleAllEnabled(): void {
const anyEnabled = value.value.modules.some((m) => m.enabled);
// If any are enabled, disable all; otherwise enable all.
commitModules(value.value.modules.map((m) => ({ ...m, enabled: !anyEnabled })));
// Operate at the TOP LEVEL only. A standalone module is one unit (its own
// `enabled`); a bundle is one unit (its MASTER `enabled` gate). Children
// inside a bundle are governed by the master (effective = master AND
// child), so toggle-all NEVER reaches in to flip them — disabling a bundle
// goes through its master, leaving each child's own `enabled` intact for a
// restore-safe re-enable. Nested bundles (parent_uid set) follow their
// parent master via the AND chain, so only top-level masters are flipped.
const isChild = (m: ModuleEntry): boolean =>
!!(m as ModuleEntry & { bundle_origin?: string | null }).bundle_origin;
const bundles = value.value.bundles ?? [];
const topModules = value.value.modules.filter((m) => !isChild(m));
const topBundles = bundles.filter((b) => !b.parent_uid);
// Direction: if ANY top-level unit is on, turn everything off; else on.
const anyOn =
topModules.some((m) => m.enabled !== false)
|| topBundles.some((b) => b.enabled !== false);
const next = !anyOn;
const nextModules = value.value.modules.map((m) =>
isChild(m) ? m : { ...m, enabled: next },
);
const nextBundles = bundles.map((b) => (b.parent_uid ? b : { ...b, enabled: next }));
commitModules(nextModules, nextBundles);
}
/** Open the SPA dashboard in a new tab. Mirrors `extension/topbar.ts`'s
@@ -4746,41 +4714,21 @@ provide(BundleFrameCtxKey, bundleFrameCtx);
:data-mode-label="isMuted ? muteLabel : undefined"
@dragleave="onContainerLeave"
>
<!-- Node identity strip. A small editable chip naming THIS WP_Context
node (value.node_label, or the auto upstream→downstream letter when
unset). Cross-node UI (constraint reach pick-list, pair popovers)
reads node_label to say WHICH node a target lives in. Sits above the
toolbar/hero so it shows in every state without touching their
layout. Double-click (or click the edit affordance) → inline input;
commit on Enter/blur, cancel on Escape. -->
<!-- Node identity strip. A fixed, read-only CODENAME naming THIS
WP_Context node — a stable `adjective-noun` derived from the
litegraph node id (node-codename.ts). POV-independent + unique on
the canvas, NOT editable. Cross-node UI (constraint reach pick-list,
pair popovers) shows the same codename to say WHICH node a target
lives in. Sits above the toolbar/hero so it shows in every state. -->
<div class="wp-node-id">
<span class="wp-node-id__tag">node</span>
<button
v-if="!editingLabel"
type="button"
class="wp-node-id__chip"
<span
class="wp-node-id__chip wp-node-id__chip--fixed"
data-test="node-label"
:title="`Name this Context node — referenced by cross-node constraint UI. (${displayNodeLabel})`"
@click="startLabelEdit"
@dblclick="startLabelEdit"
>
<span class="wp-node-id__text">{{ displayNodeLabel }}</span>
<i class="pi pi-pencil wp-node-id__edit" aria-hidden="true" />
</button>
<input
v-else
ref="labelInputEl"
v-model="labelDraft"
type="text"
class="wp-node-id__input"
data-test="node-label-input"
aria-label="Context node label"
maxlength="40"
placeholder="node label"
@keydown.enter.prevent="commitLabelEdit"
@keydown.esc.prevent="cancelLabelEdit"
@blur="commitLabelEdit"
:title="`Stable id for this Context node — referenced by cross-node constraint UI (${nodeCodename})`"
>
<span class="wp-node-id__text">{{ nodeCodename }}</span>
</span>
</div>
<!-- Corrupt-workflow recovery panel (5.6). Surfaces when JSON parse fails
@@ -4846,21 +4794,21 @@ provide(BundleFrameCtxKey, bundleFrameCtx);
<span class="wp-w-toolbar-spacer" />
<button
type="button"
class="wp-btn wp-btn--icon"
class="wp-btn--icon-sm"
title="Collapse all"
aria-label="Collapse all modules"
@click="collapseAll"
><i class="pi pi-chevron-up" /></button>
<button
type="button"
class="wp-btn wp-btn--icon"
class="wp-btn--icon-sm"
title="Expand all"
aria-label="Expand all modules"
@click="expandAll"
><i class="pi pi-chevron-down" /></button>
<button
type="button"
class="wp-btn wp-btn--icon"
class="wp-btn--icon-sm"
title="Toggle all enabled"
aria-label="Toggle all enabled"
@click="toggleAllEnabled"
@@ -5948,31 +5896,21 @@ provide(BundleFrameCtxKey, bundleFrameCtx);
max-width: 220px;
padding: 2px 8px;
font: 600 11px/1.2 var(--wp-font-sans, sans-serif);
color: var(--wp-amber, #fbbf24);
background: var(--wp-amber-bg, rgba(251, 191, 36, 0.12));
border: 1px solid var(--wp-amber, #fbbf24);
/* Neutral — a fixed codename is an identity, not an attention cue (was
amber). Muted text + a faint text-tinted wash + standard border, the
same neutral idiom used elsewhere in the widget; reads on light + dark. */
color: var(--wp-text-muted, var(--wp-text2));
background: color-mix(in srgb, var(--wp-text) 6%, transparent);
border: 1px solid var(--wp-border);
border-radius: var(--wp-radius-sm, 4px);
cursor: text;
transition: background var(--wp-motion-quick, 120ms) ease, border-color var(--wp-motion-quick, 120ms) ease;
}
.wp-node-id__chip:hover { background: var(--wp-amber, #fbbf24); color: var(--wp-bg, #1e1e1e); }
/* Fixed codename — a read-only identity, not an interactive control. */
.wp-node-id__chip--fixed { cursor: default; }
.wp-node-id__text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.wp-node-id__edit { font-size: 10px; opacity: 0.7; cursor: pointer; }
.wp-node-id__chip:hover .wp-node-id__edit { opacity: 1; }
.wp-node-id__input {
max-width: 220px;
padding: 2px 8px;
font: 600 11px/1.2 var(--wp-font-sans, sans-serif);
color: var(--wp-text, #ddd);
background: var(--wp-bg-2, var(--wp-bg2));
border: 1px solid var(--wp-amber, #fbbf24);
border-radius: var(--wp-radius-sm, 4px);
outline: none;
}
/* ── Generic button system (Task 10) ────────────────────────────────────
* .wp-btn--icon-sm (Task 9) already exists for inline card actions;
@@ -8,7 +8,8 @@
import { describe, it, expect } from "vitest";
import { mount } from "@vue/test-utils";
import ConstraintInstanceModal from "./ConstraintInstanceModal.vue";
import type { ModuleEntry } from "../../../../widgets/_shared";
import TargetReachSection from "./sections/TargetReachSection.vue";
import type { ModuleEntry, TargetSelect } from "../../../../widgets/_shared";
function makeModule(overrides: Partial<ModuleEntry> = {}): ModuleEntry {
return {
@@ -369,3 +370,61 @@ describe("ConstraintInstanceModal", () => {
expect(matrix.props("sourceSubs")).toEqual(["chain_red", "chain_blue"]);
});
});
describe("ConstraintInstanceModal — target reach instance override", () => {
// The modal collapses a reach choice to `null` ("inherit library") ONLY
// when it equals the EFFECTIVE library default — so a real per-instance
// override (including an explicit `all` over a non-all library default)
// is preserved and reaches the engine's `instance ?? payload ?? all`.
function emitReach(w: ReturnType<typeof mount>, next: TargetSelect) {
return w.findComponent(TargetReachSection).vm.$emit("update:modelValue", next);
}
function lastReach(w: ReturnType<typeof mount>): TargetSelect | null | undefined {
const updates = w.emitted("update") as Array<[Partial<ModuleEntry>]> | undefined;
if (!updates?.length) return undefined;
return updates[updates.length - 1][0].instance?.target_select;
}
function moduleWithLibraryReach(reach?: TargetSelect): ModuleEntry {
const m = makeModule();
if (reach) (m.payload as Record<string, unknown>).target_select = reach;
return m;
}
it("collapses 'all' to null when the library default is already all (inherit)", async () => {
const w = mount(ConstraintInstanceModal, { props: { module: moduleWithLibraryReach() } });
emitReach(w, { mode: "all" });
await w.vm.$nextTick();
expect(lastReach(w)).toBeNull();
});
it("stores an explicit 'all' override when the library default is non-all (first)", async () => {
// Regression guard: the old `mode === "all"` collapse dropped this to
// null, so the engine fell back to the library `first` and the user's
// widen-to-all was silently ignored.
const w = mount(ConstraintInstanceModal, {
props: { module: moduleWithLibraryReach({ mode: "first" }) },
});
emitReach(w, { mode: "all" });
await w.vm.$nextTick();
expect(lastReach(w)).toEqual({ mode: "all" });
});
it("collapses to null when the choice equals a non-all library default (first == first)", async () => {
const w = mount(ConstraintInstanceModal, {
props: { module: moduleWithLibraryReach({ mode: "first" }) },
});
emitReach(w, { mode: "first" });
await w.vm.$nextTick();
expect(lastReach(w)).toBeNull();
});
it("stores a non-default reach (next N) as an explicit override", async () => {
const w = mount(ConstraintInstanceModal, { props: { module: moduleWithLibraryReach() } });
emitReach(w, { mode: "next", count: 2 });
await w.vm.$nextTick();
expect(lastReach(w)).toEqual({ mode: "next", count: 2 });
});
});
@@ -304,17 +304,32 @@ const constraintUid = computed<string>(() => {
return hit?.rowKey ?? uid ?? props.module.id;
});
/** True when `next` is exactly what the constraint resolves to with NO
* per-instance override — the EFFECTIVE library default
* (`payload.target_select`, itself defaulting to `{mode:"all"}`). Only then
* is collapsing to `null` ("inherit library") lossless. */
function reachEqualsLibraryDefault(next: TargetSelect): boolean {
const lib = ((props.module.payload ?? {}) as ConstraintPayload).target_select ?? { mode: "all" };
if (next.mode !== lib.mode) return false;
if (next.mode === "next") return Number(next.count ?? 1) === Number(lib.count ?? 1);
// `pick` never matches the (pick-less) library default; first/all match on mode.
return next.mode !== "pick";
}
/** Persist a new `target_select` onto the instance. Mirrors the modal's
* other instance writes (full-replacement values, shallow-merged). The
* default `{mode:"all"}` is dropped to `null` so an untouched reach
* doesn't bloat the instance (keeps the override map minimal + the
* modified-state honest). */
* other instance writes (full-replacement values, shallow-merged).
* Collapse to `null` ONLY when `next` equals the effective library default
* (null = "inherit library") — keeps an untouched reach out of the instance
* while PRESERVING a real override. The old `mode === "all"` collapse lost
* an explicit `all` placed over a non-all library default: the engine's
* `instance ?? payload ?? all` then fell back to the library value, so the
* user's widen-to-all was silently ignored. */
function onTargetSelect(next: TargetSelect): void {
const isDefaultAll = next.mode === "all";
const inherits = reachEqualsLibraryDefault(next);
emit("update", {
instance: {
...(props.module.instance ?? {}),
target_select: isDefaultAll ? null : next,
target_select: inherits ? null : next,
},
});
}
@@ -425,6 +440,7 @@ function onSpaClick(): void {
:chain-modules="chainModules"
:constraint-uid="constraintUid"
:target-wildcard-id="targetWildcardId"
:target-name="targetName"
@update:model-value="onTargetSelect"
/>
<ExceptionsSection
@@ -55,13 +55,17 @@ function last(xs: TargetSelect[]): TargetSelect | undefined {
return xs[xs.length - 1];
}
function mountSection(modelValue: TargetSelect, opts: Partial<{ chainModules: ChainModule[] }> = {}) {
function mountSection(
modelValue: TargetSelect,
opts: Partial<{ chainModules: ChainModule[]; targetName: string }> = {},
) {
const updates: TargetSelect[] = [];
const w = mount(TargetReachSection, {
props: {
modelValue,
constraintUid: CONSTRAINT_UID,
targetWildcardId: TARGET_UUID,
targetName: opts.targetName ?? "test-target",
chainModules: opts.chainModules ?? makeChain(),
// Reflect emitted values back into the prop — the modal owns
// `target_select` state, so the section re-renders from the new
@@ -88,6 +92,25 @@ describe("TargetReachSection — segmented control", () => {
expect(last(updates)).toEqual({ mode: "next", count: 2 });
});
it("stepper ▲ button increments the count", async () => {
const { w, updates } = mountSection({ mode: "next", count: 2 });
await w.find('[data-test="reach-count-up"]').trigger("click");
expect(last(updates)).toEqual({ mode: "next", count: 3 });
});
it("stepper ▼ button decrements the count", async () => {
const { w, updates } = mountSection({ mode: "next", count: 3 });
await w.find('[data-test="reach-count-down"]').trigger("click");
expect(last(updates)).toEqual({ mode: "next", count: 2 });
});
it("stepper ▼ button is disabled at the minimum (count 1)", () => {
const { w } = mountSection({ mode: "next", count: 1 });
const down = w.find('[data-test="reach-count-down"]');
expect(down.exists()).toBe(true);
expect(down.attributes("disabled")).toBeDefined();
});
it("emits {mode:'all'} when 'all' clicked", async () => {
const { w, updates } = mountSection({ mode: "next", count: 3 });
await w.find('[data-test="reach-mode-all"]').trigger("click");
@@ -130,6 +153,20 @@ describe("TargetReachSection — pick checklist", () => {
});
});
it("nested row labels the @TARGET ref (display name) hosted in the carrier (display name, no @)", () => {
const { w } = mountSection({ mode: "pick", picks: [] }, { targetName: "test-target" });
const nestedRow = w.find('[data-test="reach-pick-2#backdrop-b::opt_a"]');
expect(nestedRow.exists()).toBe(true);
const txt = nestedRow.text();
// The nested ref is `@{target}` — the `@` belongs to the TARGET, shown by
// display name: "@test-target".
expect(txt).toContain("@test-target");
// Host carrier named by DISPLAY NAME, NOT @-prefixed (you don't @ the host).
expect(txt).toContain("in backdrop");
expect(txt).not.toContain("@backdrop");
expect(txt).not.toContain("@subject");
});
it("ticking a nested row emits a nested picks entry keyed by (bare carrier _uid, option_id)", async () => {
const { w, updates } = mountSection({ mode: "pick", picks: [] });
await w.find('[data-test="reach-pick-2#backdrop-b::opt_a"] .wp-check').trigger("click");
@@ -44,6 +44,7 @@ const bareUid = (rk: string): string => {
return i >= 0 ? rk.slice(i + 1) : rk;
};
const props = withDefaults(
defineProps<{
/** Current `target_select` (effective: instance override or library,
@@ -59,8 +60,12 @@ const props = withDefaults(
/** The constraint's `target_wildcard_id` — the uuid direct rows match
* on + nested carriers transitively ref. */
targetWildcardId: string;
/** Display name of the target wildcard (the thing being constrained).
* Used to label nested-pick rows as "<target> via @<carrier>" so the
* user sees WHAT is reached, not the carrier name twice. */
targetName?: string;
}>(),
{ modelValue: () => ({ mode: "all" }), chainModules: () => [] },
{ modelValue: () => ({ mode: "all" }), chainModules: () => [], targetName: "" },
);
const emit = defineEmits<{ "update:modelValue": [next: TargetSelect] }>();
@@ -75,6 +80,10 @@ const picks = computed<PickEntry[]>(() => {
return Array.isArray(p) ? p : [];
});
/** Target wildcard's display name for nested-pick labels; "target" when the
* modal didn't supply one (headless / legacy mounts). */
const targetLabel = computed<string>(() => props.targetName?.trim() || "target");
const MODES: Array<{ key: TargetSelect["mode"]; label: string }> = [
{ key: "first", label: "first" },
{ key: "next", label: "next N" },
@@ -106,6 +115,16 @@ function onCountInput(ev: Event): void {
emit("update:modelValue", { mode: "next", count: n });
}
/** Custom stepper ▲/▼. The native number-input spinners are suppressed
* (they only show on hover and render inconsistently inside ComfyUI's
* embedded DOM), so explicit buttons give the always-visible arrows the
* user asked for. Clamped at the engine minimum of 1 (a `next 0` reach
* covers nothing). */
function bumpCount(delta: number): void {
const next = Math.max(1, count.value + delta);
emit("update:modelValue", { mode: "next", count: next });
}
// ── Pick checklist ──────────────────────────────────────────────
//
// Build a uuid → option-value-strings lookup from the chain (first-seen
@@ -167,14 +186,18 @@ const pickRows = computed<PickRow[]>(() => {
// Transitive carrier — one row per matching option id.
const match = carrierOptionIdsFor(cm, tgt, lookup.value);
for (const optionId of match.optionIds) {
const carrierName = cm.displayName?.trim() || cm.id;
rows.push({
// `key`/`data-test` keep the full rowKey (display-only); the
// persisted `carrier_uid` is the BARE _uid the engine's nested
// carrier_ctx stamps (wildcard_handler stamps bare `_uid`).
key: `${cm.rowKey}::${optionId}`,
entry: { kind: "nested", carrier_uid: bareUid(cm.rowKey), option_id: optionId },
name: `${carrierName} via @${carrierName}`,
// The nested ref IS the TARGET (`@{target}`), hosted inside this
// carrier's option. Name it "@<target-display-name> in <carrier-
// display-name>" — the `@` belongs to the referenced TARGET, NOT the
// host; both shown by display name (not binding). The host carrier
// here happens to be the constraint's source wildcard.
name: `@${targetLabel.value} in ${cm.displayName?.trim() || cm.id}`,
kind: "nested",
nodeLabel: cm.nodeLabel,
});
@@ -262,11 +285,29 @@ const hint = computed<string>(() => {
type="number"
min="1"
step="1"
inputmode="numeric"
:value="count"
aria-label="Number of downstream targets"
data-test="reach-count"
@input="onCountInput"
/>
<span class="rh-step__arrows">
<button
type="button"
class="rh-step__arrow"
aria-label="Increase count"
data-test="reach-count-up"
@click="bumpCount(1)"
>▲</button>
<button
type="button"
class="rh-step__arrow"
aria-label="Decrease count"
:disabled="count <= 1"
data-test="reach-count-down"
@click="bumpCount(-1)"
>▼</button>
</span>
</span>
</div>
@@ -304,9 +345,11 @@ const hint = computed<string>(() => {
<style scoped>
.rh {
padding: 12px 16px;
/* Accent highlight (signed-off v6 contract) — visible on light + dark. */
background: color-mix(in oklab, var(--wp-accent) 14%, var(--wp-bg-2, var(--wp-bg2)));
box-shadow: inset 3px 0 0 var(--wp-accent);
/* Subtle neutral demarcation. The earlier accent wash + inset rail read
as too loud; a faint text-tinted panel (same idiom as `.rh-seg` /
row hover below) sets the section apart on both light + dark without
shouting. */
background: color-mix(in srgb, var(--wp-text) 3%, transparent);
border-bottom: 1px solid var(--wp-border-soft, var(--wp-border));
}
.rh__label {
@@ -348,7 +391,8 @@ const hint = computed<string>(() => {
color: #fff;
}
/* Stepper sits flush after the segmented group. */
/* Stepper sits flush after the segmented group: number field + a stacked
▲/▼ arrow column. */
.rh-step {
display: inline-flex;
align-items: stretch;
@@ -356,19 +400,48 @@ const hint = computed<string>(() => {
background: var(--wp-bg-deep, var(--wp-bg));
}
.rh-step__field {
width: 48px;
width: 38px;
background: transparent;
border: 0;
padding: 0 8px;
padding: 0 6px;
font: 600 12px var(--wp-font-mono);
color: var(--wp-text);
text-align: center;
-moz-appearance: textfield;
}
/* Native spinners are suppressed in favour of the custom always-visible
▲/▼ buttons (native arrows only appear on hover + render inconsistently
in ComfyUI's embedded DOM). */
.rh-step__field::-webkit-outer-spin-button,
.rh-step__field::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
.rh-step__field:focus { outline: none; }
.rh-step__arrows {
display: flex;
flex-direction: column;
border-left: 1px solid var(--wp-border);
}
.rh-step__arrow {
flex: 1 1 0;
display: flex;
align-items: center;
justify-content: center;
width: 18px;
padding: 0;
border: 0;
background: transparent;
color: var(--wp-text-muted, var(--wp-text2));
font-size: 7px;
line-height: 1;
cursor: pointer;
}
.rh-step__arrow + .rh-step__arrow { border-top: 1px solid var(--wp-border); }
.rh-step__arrow:hover:not(:disabled) {
color: var(--wp-text);
background: color-mix(in srgb, var(--wp-text) 8%, transparent);
}
.rh-step__arrow:disabled { opacity: 0.3; cursor: default; }
/* ── Pick checklist ─────────────────────────────────────────────── */
.rh-pick {
margin-top: 10px;
+13 -9
View File
@@ -189,8 +189,10 @@ describe("computePairings", () => {
const sender = full.get("cn1");
expect(sender?.direct?.via?.carrierRowKey).toBe("carrier#a");
expect(sender?.direct?.via?.optionIds).toEqual(["opt_a"]);
// Carrier is a contributor (via-routed).
expect(full.get("carrier#a")?.contributors.length).toBe(1);
// Carrier coverage lives in `viaInbound` (the `↪×N` chip), NOT
// `contributors` — a nested relationship renders as exactly one badge.
expect(full.get("carrier#a")?.viaInbound.length).toBe(1);
expect(full.get("carrier#a")?.contributors.length ?? 0).toBe(0);
// SP3: the later direct instance is ALSO covered under default `all`.
expect(full.get("target#a")?.contributors.length).toBe(1);
expect(full.get("target#a")?.direct?.number).toBe(1);
@@ -464,10 +466,11 @@ describe("computePairingsFull — SP3 reach + contributors", () => {
expect(full.get("t1#a")?.contributors.length).toBe(1);
});
it("contributors flow through a transitive carrier with via metadata", () => {
it("nested coverage flows through a transitive carrier's viaInbound with via metadata", () => {
// Default `all` reach over a carrier whose option transitively refs the
// target — the carrier row collects the constraint as a contributor,
// and the badge carries `via` (carrier + route).
// target — the carrier row collects the constraint in `viaInbound` (the
// `↪×N` chip), NOT `contributors`, and the badge carries `via` (carrier
// + route). One relationship → one badge.
const tUuid = "cccccccc";
const chain = [
module_("s1", "wildcard"),
@@ -475,9 +478,10 @@ describe("computePairingsFull — SP3 reach + contributors", () => {
module_("carrier", "wildcard", { options: [{ id: "opt_a", value: `@{${tUuid}}` }] }, "carrier#a"),
];
const full = computePairingsFull(chain);
const contribs = full.get("carrier#a")?.contributors ?? [];
expect(contribs.length).toBe(1);
expect(contribs[0]?.via?.carrierRowKey).toBe("carrier#a");
expect(contribs[0]?.via?.routeChain).toEqual([tUuid]);
expect(full.get("carrier#a")?.contributors.length ?? 0).toBe(0);
const inbound = full.get("carrier#a")?.viaInbound ?? [];
expect(inbound.length).toBe(1);
expect(inbound[0]?.via?.carrierRowKey).toBe("carrier#a");
expect(inbound[0]?.via?.routeChain).toEqual([tUuid]);
});
});
+34 -21
View File
@@ -125,13 +125,13 @@ export interface ChainModule {
* hold its own modules-list mirror. Optional — tests + legacy
* callers may omit. */
displayName?: string;
/** Identity of the WP_Context node this module was flattened from —
* the node's user-set `node_label`, or an auto upstream→downstream
* letter (A, B, C…) when unset. Stamped by `collectFullChainModules`
* so cross-node UI (constraint reach pick-list, pair popovers) can
* name WHICH node a target instance lives in. Purely additive /
* presentation-only; the pairing math never reads it. Optional —
* tests + legacy callers may omit. */
/** Identity of the WP_Context node this module was flattened from — its
* fixed CODENAME (`assignCodenames`: a stable `adjective-noun` derived
* from the litegraph node id, POV-independent + unique on the canvas).
* Stamped by `collectFullChainModules` so cross-node UI (constraint reach
* pick-list, pair popovers) can name WHICH node a target instance lives
* in. Purely additive / presentation-only; the pairing math never reads
* it. Optional — tests + legacy callers may omit. */
nodeLabel?: string;
}
@@ -347,13 +347,14 @@ export function carrierOptionIdsFor(
* options host nested refs one or more constraints reach their target
* through). Drives the collapsed `↪×N` chip. Empty when nothing
* routes through this row.
* - `contributors` (SP3): EVERY constraint whose reach selector covers
* THIS target-instance row, in per-target registration (`#N`) order.
* The SP3 mark-all model lets multiple constraints cover one instance
* (no exclusive claim), so this is the authoritative per-row list the
* next-task badge layer renders. Empty (default `[]`) on rows no
* constraint covers — including every constraint/source/non-target
* row.
* - `contributors` (SP3): every constraint whose reach selector covers
* THIS row via a DIRECT top-level encounter, in per-target registration
* (`#N`) order. The SP3 mark-all model lets multiple constraints cover
* one instance (no exclusive claim). Nested-via coverage is deliberately
* NOT here — it lands in `viaInbound` (the carrier's `↪×N` chip) so a
* single constraint→target relationship renders as exactly one badge,
* never a `#N` + `↪×N` pair on the same row. Empty (`[]`) on rows no
* constraint directly covers — including every constraint/source row.
*
* Sender rows that target via a nested ref still go through `direct` with
* `direct.via` set, so the constraint module itself only renders one
@@ -432,8 +433,10 @@ export function computePairingsFull(modules: ChainModule[]): Map<string, RowPair
// 2. Walk the chain ONCE in execution order. At each downstream
// target encounter (a direct top-level instance OR a transitive
// carrier), bump every upstream constraint's per-constraint hit
// counter and test coverage; push a contributor badge onto the
// encounter row for each covering constraint (no claim/break).
// counter and test coverage; record a badge on the encounter row
// for each covering constraint (a direct hit → `contributors`
// cluster; a nested carrier → `viaInbound` `↪×N` chip), no claim/
// break.
// 3. A constraint is an orphan iff its selector covered ZERO
// encounters. The sender badge carries `reach` + (the first
// covered carrier's) `via`.
@@ -516,9 +519,14 @@ export function computePairingsFull(modules: ChainModule[]): Map<string, RowPair
isOrphan: false,
...(via ? { via } : {}),
};
ensure(d.rowKey).contributors.push(badge);
// Carrier rows also feed the collapsed `↪×N` inbound chip.
// Route each badge to EXACTLY ONE bucket so a single
// constraint→target relationship never renders as two chips. A
// nested-via encounter is shown by the carrier's collapsed `↪×N`
// inbound chip; a direct top-level encounter by the `#N`
// contributor cluster. (Before SP3 QA: nested encounters pushed to
// BOTH, so a carrier row showed `#N` AND `↪×N` for one constraint.)
if (via) ensure(d.rowKey).viaInbound.push(badge);
else ensure(d.rowKey).contributors.push(badge);
}
}
@@ -541,9 +549,14 @@ export function computePairingsFull(modules: ChainModule[]): Map<string, RowPair
// contributor. Sender rows already set `direct` above; never overwrite
// them (a constraint row has no contributors of its own).
for (const entry of out.values()) {
if (entry.direct === null && entry.contributors.length > 0) {
entry.direct = entry.contributors[0];
}
if (entry.direct !== null) continue;
// Sender rows already set `direct` above. For a target/carrier row,
// mirror its first DIRECT contributor; else (a pure nested carrier with
// no direct coverage) its first inbound badge — keeps the legacy flat
// `computePairings` map populated for carrier rows now that nested
// coverage lives only in `viaInbound`.
if (entry.contributors.length > 0) entry.direct = entry.contributors[0];
else if (entry.viaInbound.length > 0) entry.direct = entry.viaInbound[0];
}
return out;
}
+79 -30
View File
@@ -1,5 +1,6 @@
import { describe, it, expect } from "vitest";
import { collectFullChainModules } from "./cross-node-pairings";
import { baseCodename } from "./node-codename";
import type { LiteGraphLike, LiteNodeLike } from "./graph";
/** Build a WP_Context node carrying `modules` + an optional persisted
@@ -10,10 +11,9 @@ import type { LiteGraphLike, LiteNodeLike } from "./graph";
function ctxNode(
id: number,
modules: Array<Record<string, unknown>>,
opts: { nodeLabel?: string; upstreamLink?: number; downstreamLinks?: number[] } = {},
opts: { upstreamLink?: number; downstreamLinks?: number[] } = {},
): LiteNodeLike {
const value: Record<string, unknown> = { version: 1, modules };
if (opts.nodeLabel !== undefined) value.node_label = opts.nodeLabel;
return {
id,
type: "WP_Context",
@@ -33,28 +33,27 @@ const wildcard = (id: string): Record<string, unknown> => ({
payload: { var_binding: id, options: [] },
});
describe("collectFullChainModules — nodeLabel tagging", () => {
it("tags each flattened module with its node's explicit node_label", () => {
// A → B chain. A is upstream of B; both carry explicit labels.
const a = ctxNode(1, [wildcard("aa")], { nodeLabel: "Base", downstreamLinks: [100] });
const b = ctxNode(2, [wildcard("bb")], { nodeLabel: "Detail", upstreamLink: 100 });
describe("collectFullChainModules — codename tagging", () => {
it("tags each flattened module with its node's stable codename (from node id)", () => {
// A → B chain. Codenames are fixed + derived from the litegraph node
// id — no editable label, no walk-position letters.
const a = ctxNode(1, [wildcard("aa")], { downstreamLinks: [100] });
const b = ctxNode(2, [wildcard("bb")], { upstreamLink: 100 });
const graph: LiteGraphLike = {
_nodes: [a, b],
links: { 100: { id: 100, origin_id: 1, origin_slot: 0, target_id: 2, target_slot: 0 } },
getNodeById: (n) => ({ 1: a, 2: b } as Record<number, LiteNodeLike>)[n] ?? null,
};
// Walk from B's POV: chain is [A (upstream), B (own)].
const out = collectFullChainModules(graph, b);
const aMod = out.find((m) => m.id === "aa");
const bMod = out.find((m) => m.id === "bb");
expect(aMod?.nodeLabel).toBe("Base");
expect(bMod?.nodeLabel).toBe("Detail");
expect(out.find((m) => m.id === "aa")?.nodeLabel).toBe(baseCodename(1));
expect(out.find((m) => m.id === "bb")?.nodeLabel).toBe(baseCodename(2));
});
it("falls back to auto upstream→downstream letters (A, B, C…) when node_label is unset", () => {
// A → B → C chain, no explicit labels. Walked from B's POV the order
// is [A (upstream), B (own), C (downstream)] → letters A, B, C.
it("is POV-independent: a node keeps the SAME codename regardless of which node we walk from", () => {
// The core fix for the old A/B/C scheme: walking from A vs from C must
// give node B the identical name (the pre-2026 letters shifted by POV
// and by empty upstream nodes, so two heads both became "A").
const a = ctxNode(1, [wildcard("aa")], { downstreamLinks: [100] });
const b = ctxNode(2, [wildcard("bb")], { upstreamLink: 100, downstreamLinks: [200] });
const c = ctxNode(3, [wildcard("cc")], { upstreamLink: 200 });
@@ -67,15 +66,16 @@ describe("collectFullChainModules — nodeLabel tagging", () => {
getNodeById: (n) => ({ 1: a, 2: b, 3: c } as Record<number, LiteNodeLike>)[n] ?? null,
};
const out = collectFullChainModules(graph, b);
expect(out.find((m) => m.id === "aa")?.nodeLabel).toBe("A");
expect(out.find((m) => m.id === "bb")?.nodeLabel).toBe("B");
expect(out.find((m) => m.id === "cc")?.nodeLabel).toBe("C");
const fromA = collectFullChainModules(graph, a).find((m) => m.id === "bb")?.nodeLabel;
const fromC = collectFullChainModules(graph, c).find((m) => m.id === "bb")?.nodeLabel;
expect(fromA).toBe(baseCodename(2));
expect(fromC).toBe(baseCodename(2));
expect(fromA).toBe(fromC);
});
it("mixes explicit + auto: a labelled node keeps its label, neighbours fall back to their letter", () => {
it("distinct nodes get distinct codenames across the chain", () => {
const a = ctxNode(1, [wildcard("aa")], { downstreamLinks: [100] });
const b = ctxNode(2, [wildcard("bb")], { nodeLabel: "Mid", upstreamLink: 100, downstreamLinks: [200] });
const b = ctxNode(2, [wildcard("bb")], { upstreamLink: 100, downstreamLinks: [200] });
const c = ctxNode(3, [wildcard("cc")], { upstreamLink: 200 });
const graph: LiteGraphLike = {
_nodes: [a, b, c],
@@ -85,22 +85,71 @@ describe("collectFullChainModules — nodeLabel tagging", () => {
},
getNodeById: (n) => ({ 1: a, 2: b, 3: c } as Record<number, LiteNodeLike>)[n] ?? null,
};
const out = collectFullChainModules(graph, b);
// A and C have no label → auto letters by position; B keeps "Mid".
expect(out.find((m) => m.id === "aa")?.nodeLabel).toBe("A");
expect(out.find((m) => m.id === "bb")?.nodeLabel).toBe("Mid");
expect(out.find((m) => m.id === "cc")?.nodeLabel).toBe("C");
const labels = [
out.find((m) => m.id === "aa")?.nodeLabel,
out.find((m) => m.id === "bb")?.nodeLabel,
out.find((m) => m.id === "cc")?.nodeLabel,
];
expect(new Set(labels).size).toBe(3);
});
it("single own node with no label defaults to letter A", () => {
const a = ctxNode(1, [wildcard("aa")]);
it("single own node → its own codename", () => {
const a = ctxNode(7, [wildcard("aa")]);
const graph: LiteGraphLike = {
_nodes: [a],
links: {},
getNodeById: (n) => ({ 7: a } as Record<number, LiteNodeLike>)[n] ?? null,
};
const out = collectFullChainModules(graph, a);
expect(out.find((m) => m.id === "aa")?.nodeLabel).toBe(baseCodename(7));
});
});
describe("collectFullChainModules — instance.target_select override (SP3 reach)", () => {
/** Constraint carrying a library payload reach + an instance-level
* override. The editor modal writes reach edits to `instance.target_select`
* (default `all` dropped to null), so the flatten MUST fold that override
* over `payload.target_select` — otherwise computePairingsFull reads the
* stale library value and the canvas badge never reflects the edit. */
const constraintMod = (
instanceTargetSelect: unknown,
payloadTargetSelect: unknown,
): Record<string, unknown> => ({
id: "cn0",
_uid: "cn0-uid",
type: "constraint",
enabled: true,
meta: { name: "pairing" },
payload: {
source_wildcard_id: "s0",
target_wildcard_id: "t0",
target_select: payloadTargetSelect,
},
instance: { target_select: instanceTargetSelect },
});
function flattenOne(mod: Record<string, unknown>) {
const a = ctxNode(1, [mod]);
const graph: LiteGraphLike = {
_nodes: [a],
links: {},
getNodeById: (n) => ({ 1: a } as Record<number, LiteNodeLike>)[n] ?? null,
};
const out = collectFullChainModules(graph, a);
expect(out.find((m) => m.id === "aa")?.nodeLabel).toBe("A");
const flat = collectFullChainModules(graph, a).find((m) => m.id === "cn0");
return (flat?.payload as { target_select?: unknown }).target_select;
}
it("folds a present instance.target_select over the library payload value", () => {
// Library says `all`; instance overrides to `next 2`. Effective = next 2.
expect(flattenOne(constraintMod({ mode: "next", count: 2 }, { mode: "all" }))).toEqual({
mode: "next",
count: 2,
});
});
it("inherits the library payload value when the instance override is null", () => {
// null = "no per-instance override" → library `first` shows through.
expect(flattenOne(constraintMod(null, { mode: "first" }))).toEqual({ mode: "first" });
});
});
+46 -56
View File
@@ -34,6 +34,7 @@ import {
type PairingBadge,
type RowPairings,
} from "./constraint-pairs";
import { assignCodenames, baseCodename } from "./node-codename";
/** Compose a globally-unique row key. Module `_uid` is per-Context-node
* unique; prefixing with the litegraph node id makes it graph-wide
@@ -69,33 +70,21 @@ function extractModules(node: LiteNodeLike): ModuleEntry[] {
return v.modules;
}
/** Read a Context node's persisted `node_label` from its `wp_modules`
* widget. Returns the trimmed label, or `undefined` when unset / blank
* so the caller can fall back to the auto letter. Parses the same
* widget JSON `extractModules` does — cheap (one parse) and tolerant of
* malformed payloads (returns undefined). */
function nodeLabelOf(node: LiteNodeLike): string | undefined {
const raw = widgetValue(node, "wp_modules");
const v = parseWidgetJson<ContextWidgetValue>(
typeof raw === "string" ? raw : "",
{ version: 1, modules: [] },
);
const label = typeof v.node_label === "string" ? v.node_label.trim() : "";
return label.length > 0 ? label : undefined;
}
/** Auto upstream→downstream letter for the node at walk position
* `index` (0 → "A", 1 → "B", …). Past 26 nodes it wraps to two-letter
* spreadsheet-style names (AA, AB, …) so labels stay unique on
* pathologically long chains. */
export function autoNodeLetter(index: number): string {
let n = index;
let out = "";
do {
out = String.fromCharCode(65 + (n % 26)) + out;
n = Math.floor(n / 26) - 1;
} while (n >= 0);
return out;
/** Effective payload for the badge layer: the library `m.payload` with the
* per-instance `target_select` override folded in when present. SP3 reach
* edits in the editor modal write to `instance.target_select` (the default
* `all` collapsed to `null`), but `computePairingsFull` reads
* `payload.target_select`. Without folding the override here, an
* instance-level reach change never reaches the canvas badge — it keeps
* showing the library reach (`all`). Precedence mirrors
* `ConstraintInstanceModal.targetSelect`: a non-null instance override wins;
* `null`/absent inherits the library payload value. Only constraints carry
* `target_select`; for every other module the spread is a harmless copy. */
function effectiveChainPayload(m: ModuleEntry): Record<string, unknown> {
const base = (m.payload ?? {}) as Record<string, unknown>;
const override = m.instance?.target_select;
if (override === undefined || override === null) return base;
return { ...base, target_select: override };
}
/**
@@ -104,46 +93,47 @@ export function autoNodeLetter(index: number): string {
* graph-wide-unique `rowKey` so pairings work across duplicate library
* instances + across Context-node boundaries.
*
* Every flattened module is also tagged with `nodeLabel` — its source
* Context node's user-set `node_label`, or an auto upstream→downstream
* letter (A, B, C…) when unset. The letter is assigned by walk position
* (upstream-first, then own, then downstream), so it stays stable + reads
* left-to-right along the pipeline. Cross-node UI reads this to name
* WHICH node a target instance lives in.
* Every flattened module is tagged with `nodeLabel` — its source Context
* node's CODENAME (`assignCodenames`): a fixed `adjective-noun` derived from
* the litegraph node id. Unlike the pre-2026 walk-position letters, a
* codename is POV-independent (the same node reads the same name from any
* viewer — the old letters shifted by viewer + by empty upstream nodes, so
* two chain heads both showed "A") and unique on the canvas. Cross-node UI
* reads this to name WHICH node a target instance lives in.
*/
export function collectFullChainModules(
rootGraph: LiteGraphLike,
node: LiteNodeLike,
): ChainModule[] {
const out: ChainModule[] = [];
// Walk-position counter shared across upstream → own → downstream so the
// auto letters run A, B, C… in pipeline order regardless of which nodes
// carry an explicit label.
let nodeIndex = 0;
function labelForNode(n: LiteNodeLike): string {
const explicit = nodeLabelOf(n);
const letter = autoNodeLetter(nodeIndex);
nodeIndex++;
return explicit ?? letter;
}
// Upstream chain — already returns ContextWidgetValue.modules arrays
// upstream-first. We need the matching node ids for rowKey, but the
// existing helper returns just modules. Re-walk locally to get the
// node + modules pairs in the same order.
// (collectUpstreamChain returns modules without nodeId; cheaper to
// re-walk than re-tag.)
// Re-walk the upstream Context nodes locally so we have their node refs —
// collectUpstreamChain returns modules without their owning node.
const upstreamMods = collectUpstreamChain(rootGraph, node) as ModuleEntry[][];
const upstreamNodes = upstreamContextNodes(rootGraph, node);
const downstreamNodes = collectDownstreamContextNodes(rootGraph, node);
// Assign a fixed, unique codename to every Context node in the chain,
// keyed by litegraph node id. POV-independent — `assignCodenames` sorts
// by id internally, so a node reads the same name regardless of which
// node we walked from. `labelOf` falls back to the bare base codename for
// any node the assignment somehow missed (defensive; shouldn't happen).
const codenames = assignCodenames([
...upstreamNodes.map((n) => n.id),
node.id,
...downstreamNodes.map((n) => n.id),
]);
const labelOf = (n: LiteNodeLike): string =>
codenames.get(String(n.id)) ?? baseCodename(n.id);
for (let i = 0; i < Math.min(upstreamMods.length, upstreamNodes.length); i++) {
const upNode = upstreamNodes[i];
const nodeLabel = labelForNode(upNode);
const nodeLabel = labelOf(upNode);
for (const m of upstreamMods[i]) {
out.push({
id: m.id,
rowKey: rowKey(upNode.id, m),
type: m.type,
payload: (m.payload ?? {}) as Record<string, unknown>,
payload: effectiveChainPayload(m),
displayName: m.meta?.name,
nodeLabel,
});
@@ -151,27 +141,27 @@ export function collectFullChainModules(
}
// Own node's modules.
const ownLabel = labelForNode(node);
const ownLabel = labelOf(node);
for (const m of extractModules(node)) {
out.push({
id: m.id,
rowKey: rowKey(node.id, m),
type: m.type,
payload: (m.payload ?? {}) as Record<string, unknown>,
payload: effectiveChainPayload(m),
displayName: m.meta?.name,
nodeLabel: ownLabel,
});
}
// Downstream chain.
for (const dn of collectDownstreamContextNodes(rootGraph, node)) {
const nodeLabel = labelForNode(dn);
for (const dn of downstreamNodes) {
const nodeLabel = labelOf(dn);
for (const m of extractModules(dn)) {
out.push({
id: m.id,
rowKey: rowKey(dn.id, m),
type: m.type,
payload: (m.payload ?? {}) as Record<string, unknown>,
payload: effectiveChainPayload(m),
displayName: m.meta?.name,
nodeLabel,
});
+64
View File
@@ -500,6 +500,70 @@ describe("collectUpstreamResolved nested @{uuid} fallback", () => {
// ── Combine v2 instance overrides — preview must reflect modal edits ──
describe("collectUpstreamResolved — wildcard instance variable_binding override", () => {
beforeEach(() => _resetForTests());
/** A duplicated wildcard keeps `payload.var_binding="mood"` but carries
* `instance.variable_binding="mood_2"` (the auto-suffix). The engine
* writes ctx["mood_2"]; the static walker MUST mirror that, else the
* upstream-resolved map keys by the library name and cross-node shadow
* detection misses the renamed twin (the duplicate's "override" badge
* goes missing — SP3 QA report). */
function ctxWithRenamedWildcard(instanceBinding: string | null): {
asm: LiteNodeLike;
graph: LiteGraphLike;
} {
const inst: Record<string, unknown> = {};
if (instanceBinding !== null) inst.variable_binding = instanceBinding;
const ctx: LiteNodeLike = {
id: 1,
type: "WP_Context",
inputs: [{ name: "upstream", link: null }],
outputs: [{ name: "context", links: [], type: "PIPELINE_CONTEXT" }],
widgets: [{
name: "wp_modules",
value: JSON.stringify({
version: 1,
modules: [{
id: "aaaaaaaa",
_uid: "u-dup",
type: "wildcard",
enabled: true,
meta: { name: "mood dup" },
entries: [],
payload: { var_binding: "mood", options: [{ id: "o1", value: "calm" }] },
...(Object.keys(inst).length ? { instance: inst } : {}),
}],
}),
}],
};
const asm: LiteNodeLike = {
id: 2,
type: "WP_PromptAssembler",
inputs: [{ name: "context", link: 100 }],
};
const graph: LiteGraphLike = {
_nodes: [ctx, asm],
links: { 100: { id: 100, origin_id: 1, origin_slot: 0, target_id: 2, target_slot: 0 } },
getNodeById: (id) => ({ 1: ctx, 2: asm } as Record<number, LiteNodeLike>)[id] ?? null,
};
return { asm, graph };
}
it("keys the resolved map by instance.variable_binding, not payload.var_binding", () => {
const { graph, asm } = ctxWithRenamedWildcard("mood_2");
const resolved = collectUpstreamResolved(graph, asm);
expect(Object.keys(resolved)).toContain("mood_2");
expect(Object.keys(resolved)).not.toContain("mood");
expect(resolved.mood_2).toBe("calm");
});
it("falls back to payload.var_binding when no instance override", () => {
const { graph, asm } = ctxWithRenamedWildcard(null);
expect(collectUpstreamResolved(graph, asm)).toEqual({ mood: "calm" });
});
});
describe("collectUpstreamResolved — combine instance overrides", () => {
beforeEach(() => _resetForTests());
+12 -1
View File
@@ -981,7 +981,18 @@ function writeBindings(
}
const p = (m.payload ?? {}) as Record<string, unknown>;
if (m.type === "wildcard") {
const binding = (p.var_binding as string | undefined)?.replace(/^\$/, "").trim();
// Honor `instance.variable_binding` over the library `payload.var_binding`
// — the engine + the conflict scanner's `bindingNameOf` both resolve the
// EFFECTIVE binding this way. Reading payload-only made a per-instance
// rename (e.g. a duplicate's auto-suffixed `$mood_2`) collapse back to the
// library name here, so the upstream-resolved map keyed by `mood` and
// cross-node shadow detection missed the renamed twin (the duplicate's
// `shadows_upstream` "override" badge went missing). Mirrors the combine
// branch below.
const overrideBinding = ((m.instance as { variable_binding?: string | null } | undefined)
?.variable_binding ?? "").replace(/^\$/, "").trim();
const binding =
overrideBinding || (p.var_binding as string | undefined)?.replace(/^\$/, "").trim();
const options =
(p.options as
| Array<{ value?: string; id?: string; sub_categories?: string[]; is_null?: boolean }>
+69
View File
@@ -0,0 +1,69 @@
import { describe, it, expect } from "vitest";
import { baseCodename, assignCodenames } from "./node-codename";
describe("baseCodename", () => {
it("is a lowercase adjective-noun pair", () => {
expect(baseCodename(7)).toMatch(/^[a-z]+-[a-z]+$/);
});
it("is deterministic — same id always yields the same name", () => {
expect(baseCodename(42)).toBe(baseCodename(42));
expect(baseCodename("node-99")).toBe(baseCodename("node-99"));
});
it("treats numeric and string forms of an id identically", () => {
// Litegraph ids are numbers; some call sites stringify. Both must agree.
expect(baseCodename(13)).toBe(baseCodename("13"));
});
it("spreads distinct ids across distinct names (mostly)", () => {
// Not a uniqueness guarantee (that's assignCodenames' job) — just a
// sanity check that the hash actually varies the output.
const names = new Set(Array.from({ length: 50 }, (_, i) => baseCodename(i)));
expect(names.size).toBeGreaterThan(40);
});
});
describe("assignCodenames", () => {
it("maps each id to its base codename when there are no collisions", () => {
const ids = [1, 2, 3];
const map = assignCodenames(ids);
for (const id of ids) {
// No collision among these → pure base name, no suffix.
expect(map.get(String(id))).toBe(baseCodename(id));
}
});
it("GUARANTEES uniqueness across a large id set (the core contract)", () => {
// Even if base codenames collide within this set, every assigned name
// must be distinct — that's the "does not duplicate on the canvas"
// guarantee. 300 ids comfortably forces some base collisions at 4096
// combos, exercising the dedup path.
const ids = Array.from({ length: 300 }, (_, i) => i + 1);
const map = assignCodenames(ids);
const values = [...map.values()];
expect(values.length).toBe(ids.length);
expect(new Set(values).size).toBe(ids.length); // all unique
});
it("deduped names keep the adjective-noun base + a numeric suffix", () => {
const ids = Array.from({ length: 300 }, (_, i) => i + 1);
for (const name of assignCodenames(ids).values()) {
expect(name).toMatch(/^[a-z]+-[a-z]+(-\d+)?$/);
}
});
it("is order-independent: the assigned name for an id doesn't depend on input order", () => {
// Stable identity — feeding the same id set in any order yields the same
// per-id name (dedup resolves deterministically by id, not arrival order).
const a = assignCodenames([10, 20, 30, 40]);
const b = assignCodenames([40, 10, 30, 20]);
for (const id of [10, 20, 30, 40]) {
expect(b.get(String(id))).toBe(a.get(String(id)));
}
});
it("handles an empty input", () => {
expect(assignCodenames([]).size).toBe(0);
});
});
+104
View File
@@ -0,0 +1,104 @@
/**
* Stable, unique, human-readable codenames for WP_Context nodes.
*
* A WP_Context node needs a name the cross-node constraint UI (reach
* pick-list, pair popovers) can use to say WHICH node a target instance
* lives in. The pre-2026 design used an editable label with an auto
* `A`/`B`/`C` fallback assigned by upstream→downstream WALK POSITION — but
* that was (a) computed from each viewing node's own perspective, so the
* same physical node showed different letters to different viewers and two
* chain heads both became "A", and (b) editable, so not a stable identifier.
*
* This module replaces it with a fixed `adjective-noun` codename derived
* deterministically from the litegraph `node.id` (stable for a node's
* lifetime, unique per canvas, preserved across save/load). The name never
* depends on graph topology or viewer, and is not user-editable.
*
* `assignCodenames` adds a hard uniqueness guarantee: if two nodes' base
* codenames ever collide (≈4096-combo namespace makes this rare), the
* collision is broken with a deterministic numeric suffix by id order, so a
* canvas NEVER shows two identical node names.
*
* Pure data — no Vue / DOM / litegraph imports. Mirrors the engine-side
* isolation discipline of the other `extension/*` helpers.
*/
// 64 adjectives × 64 nouns = 4096 base combinations. Lowercase single
// words, neutral/calm palette (colours, textures, moods) so a codename
// reads cleanly in a chip. Lengths are intentionally powers of two so the
// hash split below distributes evenly across both lists.
const ADJECTIVES: readonly string[] = [
"amber", "azure", "brisk", "calm", "cedar", "clay", "cobalt", "copper",
"coral", "crimson", "dusk", "dawn", "ember", "fern", "frost", "gilded",
"golden", "gray", "hazel", "indigo", "ivory", "jade", "lilac", "lunar",
"maple", "misty", "mossy", "navy", "ochre", "olive", "onyx", "opal",
"pearl", "plum", "quiet", "rapid", "rosy", "ruby", "rust", "sage",
"sandy", "scarlet", "silent", "silver", "slate", "snowy", "solar", "spruce",
"stark", "steel", "stone", "sunny", "swift", "teal", "tidal", "umber",
"velvet", "vivid", "warm", "willow", "windy", "wren", "zephyr", "zinc",
];
const NOUNS: readonly string[] = [
"otter", "fox", "heron", "lynx", "marten", "raven", "finch", "falcon",
"badger", "beaver", "bison", "crane", "dove", "eagle", "egret", "gecko",
"hare", "ibis", "jay", "kestrel", "koi", "lark", "lemur", "marmot",
"mink", "moth", "newt", "oriole", "osprey", "owl", "panther", "puffin",
"quail", "rabbit", "robin", "sable", "salmon", "shrike", "sparrow", "stoat",
"stork", "swan", "tanager", "thrush", "toad", "vole", "weasel", "wolf",
"brook", "canyon", "cliff", "cove", "delta", "dune", "fjord", "glade",
"grove", "harbor", "hollow", "knoll", "marsh", "mesa", "ridge", "summit",
];
/** djb2 string hash → unsigned 32-bit. Same family as `varColorIndex`'s
* token hash; kept local so this module stays dependency-free. */
function djb2(s: string): number {
let h = 5381;
for (let i = 0; i < s.length; i++) {
h = (((h << 5) + h) + s.charCodeAt(i)) >>> 0;
}
return h >>> 0;
}
/**
* Deterministic `adjective-noun` codename for a litegraph node id. Numeric
* and string forms of the same id hash identically (call sites mix them).
* Adjective is taken from the low part of the hash, noun from the high
* part, so the two vary independently across ids.
*/
export function baseCodename(nodeId: string | number): string {
const h = djb2(String(nodeId));
const adj = ADJECTIVES[h % ADJECTIVES.length];
const noun = NOUNS[Math.floor(h / ADJECTIVES.length) % NOUNS.length];
return `${adj}-${noun}`;
}
/**
* Assign a UNIQUE codename to every node id on the canvas. Returns a Map
* keyed by the STRING form of each id. Base codenames come straight from
* {@link baseCodename}; the rare collision (two ids → same base) is broken
* deterministically — sort the colliding ids ascending and append `-2`,
* `-3`, … to all but the first. This is order-independent (the result for
* a given id doesn't depend on input order) and stable (a node keeps its
* name unless its lower-id collision partner is removed), while making
* duplicate names on one canvas impossible.
*/
export function assignCodenames(nodeIds: Array<string | number>): Map<string, string> {
// Sort by numeric id when both are numeric (the litegraph norm), else
// lexicographically — gives a stable, input-order-independent ordering
// so dedup suffixes are assigned consistently.
const sorted = [...nodeIds].sort((a, b) => {
const na = Number(a);
const nb = Number(b);
if (Number.isFinite(na) && Number.isFinite(nb) && na !== nb) return na - nb;
return String(a).localeCompare(String(b));
});
const out = new Map<string, string>();
const seen = new Map<string, number>();
for (const id of sorted) {
const base = baseCodename(id);
const n = seen.get(base) ?? 0;
seen.set(base, n + 1);
out.set(String(id), n === 0 ? base : `${base}-${n + 1}`);
}
return out;
}
-13
View File
@@ -383,19 +383,6 @@ export interface ContextWidgetValue {
* `@{uuid}` ref resolution.
*/
modules: ModuleEntry[];
/**
* User-facing identity for THIS WP_Context node. Surfaces in cross-node
* UI (the constraint reach pick-list + pair popovers) so a target
* instance living in another Context node can be named ("target lives
* in Detail pass"). Edited via the small header chip in ContextWidget;
* persisted in the widget JSON so it survives workflow save/load.
*
* Optional / absent on nodes the user never named — the cross-node
* walker (`collectFullChainModules`) then assigns an auto default
* (upstream→downstream letter A, B, C…), and the header chip shows
* the same fallback. Presentation-only: the engine never reads it.
*/
node_label?: string;
/**
* Library-tracked groupings wrapping a contiguous range of
* `modules[]`. The engine ignores this field entirely — bundle
+104
View File
@@ -0,0 +1,104 @@
import { describe, it, expect } from "vitest";
import { pairingBadgeEqual, pairingMapEqual } from "./context";
import type {
PairingBadge,
RowPairings,
TargetSelect,
} from "../extension/constraint-pairs";
/** Minimal sender badge carrying a `reach` selector — the only badge
* kind that does. Contributor / carrier badges leave `reach` undefined. */
function senderBadge(reach: TargetSelect): PairingBadge {
return { number: 1, targetUuid: "t1", colorIndex: 3, isOrphan: false, reach };
}
/** Plain contributor badge (no `reach`). */
function badge(number: number): PairingBadge {
return { number, targetUuid: "t1", colorIndex: 3, isOrphan: false };
}
function row(partial: Partial<RowPairings>): RowPairings {
return { direct: null, viaInbound: [], contributors: [], ...partial };
}
describe("pairingBadgeEqual — reach selector (SP3 QA)", () => {
it("differs when reach mode changes (all → first)", () => {
// The defect: a `target_select` edit flips the sender chip's reach
// suffix (·all → ·first) but the gate said 'equal', freezing the badge.
expect(
pairingBadgeEqual(senderBadge({ mode: "all" }), senderBadge({ mode: "first" })),
).toBe(false);
});
it("differs when next-N count changes", () => {
expect(
pairingBadgeEqual(
senderBadge({ mode: "next", count: 1 }),
senderBadge({ mode: "next", count: 2 }),
),
).toBe(false);
});
it("differs when a pick's identity changes", () => {
expect(
pairingBadgeEqual(
senderBadge({ mode: "pick", picks: [{ kind: "direct", uid: "a" }] }),
senderBadge({ mode: "pick", picks: [{ kind: "direct", uid: "b" }] }),
),
).toBe(false);
});
it("differs when a pick is added", () => {
expect(
pairingBadgeEqual(
senderBadge({ mode: "pick", picks: [{ kind: "direct", uid: "a" }] }),
senderBadge({
mode: "pick",
picks: [
{ kind: "direct", uid: "a" },
{ kind: "nested", carrier_uid: "c", option_id: "o" },
],
}),
),
).toBe(false);
});
it("equal when reach is identical", () => {
expect(
pairingBadgeEqual(
senderBadge({ mode: "next", count: 2 }),
senderBadge({ mode: "next", count: 2 }),
),
).toBe(true);
});
it("equal when both badges carry no reach (contributor/carrier rows)", () => {
// Must NOT churn: most badges have reach === undefined, and a false
// negative here would re-render every ContextWidget every poll.
expect(pairingBadgeEqual(badge(1), badge(1))).toBe(true);
});
});
describe("pairingMapEqual — contributors list (SP3 QA)", () => {
it("differs when a NON-first contributor drops (direct mirror unchanged)", () => {
// Row covered by two constraints [#1, #2]. Constraint #2's reach stops
// covering it → contributors becomes [#1]. `direct` mirrors only
// contributors[0] (#1) so it's unchanged — the cluster still goes from
// two chips to one, which the gate must detect.
const a = new Map<string, RowPairings>([
["t1#u", row({ direct: badge(1), contributors: [badge(1), badge(2)] })],
]);
const b = new Map<string, RowPairings>([
["t1#u", row({ direct: badge(1), contributors: [badge(1)] })],
]);
expect(pairingMapEqual(a, b)).toBe(false);
});
it("equal when contributors are identical", () => {
const mk = () =>
new Map<string, RowPairings>([
["t1#u", row({ direct: badge(1), contributors: [badge(1), badge(2)] })],
]);
expect(pairingMapEqual(mk(), mk())).toBe(true);
});
});
+48 -3
View File
@@ -18,7 +18,7 @@ import {
type LiteNodeLike,
} from "../extension/graph";
import { collectCrossNodePairingsFull, collectFullChainModules } from "../extension/cross-node-pairings";
import type { ChainModule, PairingBadge, RowPairings } from "../extension/constraint-pairs";
import type { ChainModule, PairingBadge, RowPairings, TargetSelect } from "../extension/constraint-pairs";
import { reactiveFromGraph, stringArrayEqual } from "../extension/reactive";
import { applyVarAccessor, type ResolvedValue } from "./richTokenize";
@@ -40,13 +40,49 @@ function stringMapEqual(
return true;
}
function pairingBadgeEqual(a: PairingBadge, b: PairingBadge): boolean {
/** Deep-equal for a constraint's reach selector. Only the SENDER badge
* carries `reach` (mode + count + picks); a `target_select` edit in the
* modal must flip this so the gated `pairingsRef` re-renders the canvas
* chip's reach suffix (·all → ·first → ·n2 → ·pick). Compares mode, count,
* and the picks list element-by-element (order-sensitive — the UI appends
* picks in toggle order so identical selections share order; a reorder
* only costs one harmless extra render). Both-undefined short-circuits true
* so the common case (every contributor / carrier badge) never churns. */
function targetSelectEqual(
a: TargetSelect | undefined,
b: TargetSelect | undefined,
): boolean {
if (a === b) return true;
if (!a || !b) return false;
if (a.mode !== b.mode || a.count !== b.count) return false;
const ap = a.picks ?? [];
const bp = b.picks ?? [];
if (ap.length !== bp.length) return false;
for (let i = 0; i < ap.length; i++) {
const x = ap[i];
const y = bp[i];
if (x.kind !== y.kind) return false;
if (x.kind === "direct" && y.kind === "direct") {
if (x.uid !== y.uid) return false;
} else if (x.kind === "nested" && y.kind === "nested") {
if (x.carrier_uid !== y.carrier_uid || x.option_id !== y.option_id) return false;
}
}
return true;
}
export function pairingBadgeEqual(a: PairingBadge, b: PairingBadge): boolean {
if (
a.number !== b.number ||
a.targetUuid !== b.targetUuid ||
a.colorIndex !== b.colorIndex ||
a.isOrphan !== b.isOrphan
) return false;
// SP3 reach: only sender `direct` badges carry it; a selector edit must
// re-render the chip's reach suffix even when every scalar above is
// unchanged. targetSelectEqual short-circuits both-undefined, so the
// overwhelming majority of badges (no reach) don't pay any churn cost.
if (!targetSelectEqual(a.reach, b.reach)) return false;
const av = a.via;
const bv = b.via;
if (!av && !bv) return true;
@@ -63,7 +99,7 @@ function pairingBadgeEqual(a: PairingBadge, b: PairingBadge): boolean {
* when an entry's `direct` badge OR any `viaInbound` badge actually
* changes — gate prevents per-frame poll churn re-rendering every
* ContextWidget when nothing's changed. */
function pairingMapEqual(
export function pairingMapEqual(
a: Map<string, RowPairings>,
b: Map<string, RowPairings>,
): boolean {
@@ -85,6 +121,15 @@ function pairingMapEqual(
for (let i = 0; i < av.viaInbound.length; i++) {
if (!pairingBadgeEqual(av.viaInbound[i], bv.viaInbound[i])) return false;
}
// contributors (SP3 mark-all): the authoritative per-row coverage list
// the badge cluster renders. A reach edit can add/drop a NON-FIRST
// contributor without touching `direct` (which mirrors only
// contributors[0]), so the cluster would keep a stale chip count unless
// we compare the full list here.
if (av.contributors.length !== bv.contributors.length) return false;
for (let i = 0; i < av.contributors.length; i++) {
if (!pairingBadgeEqual(av.contributors[i], bv.contributors[i])) return false;
}
}
return true;
}
@@ -510,3 +510,57 @@ def test_resolve_instance_target_select_overrides_payload():
ctx,
)
assert ctx["__wp_constraints__"][-1]["target_select"] == {"mode": "first"}
# ── SP3: per-instance reach override beats the library default for EVERY
# mode (first / next / all / pick). The canvas modal writes the placement's
# reach to `instance.target_select`; the engine MUST apply that, not the
# published library `payload.target_select`. `apply_constraints_for_target`
# reads `meta["target_select"]`, so these lock the value the apply pass sees.
def _recorded_target_select(payload_ts, instance_ts):
"""Resolve with the given library (payload) + per-instance reach and
return the `target_select` recorded into ctx — exactly what
`apply_constraints_for_target` reads to decide coverage."""
ctx: dict = {"__wp_current_module_uid__": "u1", "__wp_current_module_id__": "lib1"}
payload = _ts_payload(
**({"target_select": payload_ts} if payload_ts is not None else {})
)
instance = {"target_select": instance_ts} if instance_ts is not None else {}
ConstraintHandler.resolve(payload, instance, ctx)
return ctx["__wp_constraints__"][-1]["target_select"]
def test_instance_reach_next_overrides_library():
# Library default `all`; instance overrides to `next 3` → engine uses next 3.
assert _recorded_target_select(
{"mode": "all"}, {"mode": "next", "count": 3}
) == {"mode": "next", "count": 3}
def test_instance_reach_pick_overrides_library():
# Library default `first`; instance overrides to an explicit pick list.
picks = [{"kind": "direct", "uid": "abc123def456"}]
assert _recorded_target_select(
{"mode": "first"}, {"mode": "pick", "picks": picks}
) == {"mode": "pick", "picks": picks}
def test_instance_reach_explicit_all_overrides_nonall_library():
# The critical "all" case: library default `first`, instance explicitly
# widens to `all`. The engine honors the PRESENT instance value (its
# `instance or payload or default` chain), so an explicit `{mode:"all"}`
# override wins over a non-all library default. (The canvas modal must
# actually SEND `{mode:"all"}` here rather than collapsing it to null —
# see ConstraintInstanceModal.onTargetSelect.)
assert _recorded_target_select(
{"mode": "first"}, {"mode": "all"}
) == {"mode": "all"}
def test_library_reach_used_when_instance_absent():
# No per-instance override → fall back to the published library value.
assert _recorded_target_select(
{"mode": "next", "count": 2}, None
) == {"mode": "next", "count": 2}