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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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
@@ -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 }>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user