From 73a00a3760d31cbfd4e397cdd2cf60aef01ee3fb Mon Sep 17 00:00:00 2001 From: DumiFlex <94956983+DumiFlex@users.noreply.github.com> Date: Thu, 11 Jun 2026 16:38:56 +0300 Subject: [PATCH] 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 "@ in " (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. --- src/components/context/ContextWidget.test.ts | 168 ++++++++++------ src/components/context/ContextWidget.vue | 182 ++++++------------ .../ConstraintInstanceModal.test.ts | 61 +++++- .../constraint/ConstraintInstanceModal.vue | 28 ++- .../sections/TargetReachSection.test.ts | 39 +++- .../sections/TargetReachSection.vue | 91 ++++++++- src/extension/constraint-pairs.test.ts | 22 ++- src/extension/constraint-pairs.ts | 55 ++++-- src/extension/cross-node-pairings.test.ts | 109 ++++++++--- src/extension/cross-node-pairings.ts | 102 +++++----- src/extension/graph.test.ts | 64 ++++++ src/extension/graph.ts | 13 +- src/extension/node-codename.test.ts | 69 +++++++ src/extension/node-codename.ts | 104 ++++++++++ src/widgets/_shared.ts | 13 -- src/widgets/context-pairing-equal.test.ts | 104 ++++++++++ src/widgets/context.ts | 51 ++++- .../engine/modules/test_constraint_handler.py | 54 ++++++ 18 files changed, 994 insertions(+), 335 deletions(-) create mode 100644 src/extension/node-codename.test.ts create mode 100644 src/extension/node-codename.ts create mode 100644 src/widgets/context-pairing-equal.test.ts diff --git a/src/components/context/ContextWidget.test.ts b/src/components/context/ContextWidget.test.ts index 4a349f39..741b0f07 100644 --- a/src/components/context/ContextWidget.test.ts +++ b/src/components/context/ContextWidget.test.ts @@ -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) { + 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, 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(); }); }); diff --git a/src/components/context/ContextWidget.vue b/src/components/context/ContextWidget.vue index 8d96f855..a1e82070 100644 --- a/src/components/context/ContextWidget.vue +++ b/src/components/context/ContextWidget.vue @@ -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(() => { - 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(() => { - 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(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(() => 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 -