Files
DumiFlex-ComfyUI-Wildcard-P…/src/components/context/ModuleEditModal.test.ts
T
DumiFlex ab813bbca4 feat(canvas): @{} chips + autocomplete in derivation instance modal
The CANVAS derivation instance modal now reaches full parity with the
SPA derivation editor: the read-only rule summary renders nested
`@{uuid}` refs + `$var` references as chips (was raw
`@{ad00acd5#mood:warm}` text), and the per-branch override fields are
RichTextInputs with `@`/`$` autocomplete + the sub-category filter
pop-up — replacing the plain `<input>`s.

Reuses the existing machinery rather than rebuilding it:
- RulesSection summary chips delegate to a new RuleValueChips
  presentational component that copies the wildcard OptionRow's
  tokenizeRich-based chip markup + palette verbatim.
- The action + ELSE-action override fields mount the lazily-imported
  RichTextInput (`surface=derivation`, `allow-nested-refs`, the six
  buildWildcardRefData maps) — engine resolves `@{}` there as a carrier.
- The condition override field is RichTextInput too but WITHOUT
  allow-nested-refs / ref-data (engine compares condition.value RAW);
  it still gets `$var` suggestions, mirroring the SPA condition field.
- DerivationInstanceModal fetches /wp/api/modules once on mount and
  builds the ref-data via the shared buildWildcardRefData +
  collectLibraryWildcardRefs (self id excluded). The `@{}` source is
  the LIBRARY by identity, not the chain.
- ModuleEditModal forwards upstream/sibling vars (the `$var` source);
  sibling/chain MODULES are intentionally not forwarded.

RichTextInput stays a single shared lazy chunk (Vite dedupes the
import combine's TemplateSection already pulls), so the extension
bundle stays within budget.
2026-06-12 07:44:25 +03:00

693 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { mount, flushPromises } from "@vue/test-utils";
import { nextTick } from "vue";
import ModuleEditModal from "./ModuleEditModal.vue";
import type { ModuleEntry } from "../../widgets/_shared";
import { _resetForTests } from "../../extension/preview-resolver";
// The v2 DerivationInstanceModal fetches /wp/api/modules on open to build
// its `@{}` ref-data. Stub fetch so that round-trip settles harmlessly in
// the derivation dispatcher tests below (shape mirrors ModulePickerModal).
function stubEmptyModulesFetch(): void {
vi.stubGlobal(
"fetch",
vi.fn(async () => ({
ok: true,
status: 200,
json: async () => ({ items: [], total: 0 }),
})) as unknown as typeof fetch,
);
}
// ModalShell uses <Teleport to="body">. VTU's `find` only walks the
// component's own subtree, so disable teleport globally for these tests
// — content lands inline where wrapper.find can reach it.
const mountOpts = { global: { stubs: { teleport: true } } } as const;
function makeWildcard(): ModuleEntry {
return {
id: "ab12cd34",
type: "wildcard",
enabled: true,
meta: { name: "outfit" },
entries: [],
payload: {
options: [
{ id: "o1", value: "red", weight: 1 },
{ id: "o2", value: "blue", weight: 2 },
{ id: "o3", value: "green", weight: 1 },
],
},
payload_hash: "deadbeefcafef00d",
};
}
function makeFixedValues(): ModuleEntry {
return {
id: "fv012345",
type: "fixed_values",
enabled: true,
meta: { name: "presets" },
entries: [
{ variable_name: "lens", value: "85mm" },
{ variable_name: "angle", value: "wide" },
],
payload: {
values: [
{ id: "v1", name: "lens", value: "85mm" },
{ id: "v2", name: "angle", value: "wide" },
],
},
payload_hash: "h_lib",
};
}
function makeCombine(): ModuleEntry {
return {
id: "11111111", type: "combine", enabled: true,
meta: { name: "Greeting" }, entries: [],
payload: { template: "Hello $name", output_var: "greeting", input_vars: ["name"] },
payload_hash: "h",
};
}
function makeDerivation(): ModuleEntry {
return {
id: "22222222", type: "derivation", enabled: true,
meta: { name: "Mood" }, entries: [],
payload: { rules: [] },
payload_hash: "h",
};
}
function makeConstraint(): ModuleEntry {
return {
id: "cccccccc", type: "constraint", enabled: true,
meta: { name: "Hair × Outfit" }, entries: [],
payload: {
source_wildcard_id: "aaaaaaaa",
target_wildcard_id: "bbbbbbbb",
matrix: {},
exceptions: [],
},
payload_hash: "h",
};
}
// ── Shell-level tests (Task 14) ──────────────────────────────────────────────
// These test the shell header/footer and kind dispatcher. Kind-specific body
// tests move to per-kind body test files in Tasks 15-19.
describe("ModuleEditModal — shell header", () => {
beforeEach(() => _resetForTests());
// v1 .wp-medit shell tests removed in 2026-05-10 cycle — every kind
// (wildcard / fixed_values / combine / derivation / constraint) now
// routes to its own v2 single-pane modal. Per-modal header coverage
// lives in each kind's *.InstanceModal.test.ts.
it("fixed_values kind goes through v2 modal (no v1 .wp-medit shell)", async () => {
const wrapper = mount(ModuleEditModal, {
...mountOpts,
props: { visible: true, module: makeFixedValues() },
});
await nextTick();
// v2 dispatch — name editing happens in IdentitySection now.
expect(wrapper.findComponent({ name: "FixedValuesInstanceModal" }).exists()).toBe(true);
expect(wrapper.find('[data-test="id-name"]').exists()).toBe(true);
});
it("renders nothing when module is null", async () => {
const wrapper = mount(ModuleEditModal, {
...mountOpts,
props: { visible: true, module: null },
});
await nextTick();
expect(wrapper.find(".wp-medit").exists()).toBe(false);
});
it("renders nothing when not visible", async () => {
const wrapper = mount(ModuleEditModal, {
...mountOpts,
props: { visible: false, module: makeConstraint() },
});
await nextTick();
expect(wrapper.find(".wp-medit").exists()).toBe(false);
});
});
describe("ModuleEditModal — kind dispatcher (scaffold placeholders)", () => {
beforeEach(() => _resetForTests());
// wildcard → WildcardInstanceModal (v2 single-pane) — see
// src/components/context/editors/wildcard/WildcardInstanceModal.test.ts.
// Removed from the v1 dispatcher set.
// fixed_values → FixedValuesInstanceModal (v2 single-pane) — see
// src/components/context/editors/fixed-values/FixedValuesInstanceModal.test.ts.
// Removed from the v1 dispatcher set.
// combine → CombineInstanceModal (v2 single-pane) — see
// src/components/context/editors/combine/CombineInstanceModal.test.ts.
// Removed from the v1 dispatcher set.
it("combine → routes to v2 CombineInstanceModal (no v1 .wp-medit shell)", async () => {
const wrapper = mount(ModuleEditModal, {
...mountOpts,
props: { visible: true, module: makeCombine() },
});
await nextTick();
// v2 modal renders its own .cbm root (CombineInstanceModal.vue);
// v1 fallback renders .wp-medit shell. Both anchors verify dispatch.
expect(wrapper.find(".cbm").exists()).toBe(true);
expect(wrapper.find(".wp-medit").exists()).toBe(false);
});
// derivation → DerivationInstanceModal (v2 single-pane) — see
// src/components/context/editors/derivation/DerivationInstanceModal.test.ts.
// Removed from the v1 dispatcher set.
it("derivation → routes to v2 DerivationInstanceModal (no v1 .wp-medit shell)", async () => {
const wrapper = mount(ModuleEditModal, {
...mountOpts,
props: { visible: true, module: makeDerivation() },
});
await nextTick();
expect(wrapper.find(".dvm").exists()).toBe(true);
expect(wrapper.find(".wp-medit").exists()).toBe(false);
});
// ─ "constraint → ConstraintEditorBody" test removed.
// Constraint migrated to v2 single-pane modal in 2026-05-10 cycle.
// See: src/components/context/editors/constraint/ConstraintInstanceModal.test.ts
// for header / sections / footer / drift kebab coverage.
it("constraint → routes to v2 ConstraintInstanceModal (no v1 .wp-medit shell)", async () => {
const wrapper = mount(ModuleEditModal, {
...mountOpts,
props: { visible: true, module: makeConstraint() },
});
await nextTick();
expect(wrapper.find(".cnm").exists()).toBe(true);
expect(wrapper.find(".wp-medit").exists()).toBe(false);
});
});
describe("ModuleEditModal — footer / save / cancel", () => {
beforeEach(() => _resetForTests());
// v1 .wp-medit shell footer tests removed in 2026-05-10 cycle.
// Per-kind modal Save/Cancel coverage lives in each kind's
// *.InstanceModal.test.ts (data-test="<prefix>-save" / "cancel").
// Ctrl+Enter shortcut still wired in the shell-level keydown
// handler — covered indirectly by per-kind save event tests.
it("save with fixed_values (inline, no payload_hash) writes payload.values via v2 reconciliation", async () => {
const inline: ModuleEntry = {
id: "inline01",
type: "fixed_values",
enabled: true,
meta: { name: "scratch" },
entries: [{ variable_name: "tag", value: "noir" }],
// No payload, no payload_hash → inline-created.
};
const wrapper = mount(ModuleEditModal, {
...mountOpts,
props: { visible: true, module: inline },
});
await flushPromises();
// v2 modal Save button.
await wrapper.find('[data-test="fvm-save"]').trigger("click");
const saved = wrapper.emitted("save")?.[0][0] as ModuleEntry;
// Inline path: payload.values carries the entry, no override created.
const vals = (saved.payload as { values: Array<{ name: string; value: string }> }).values;
expect(vals[0]).toMatchObject({ name: "tag", value: "noir" });
const ov = (saved.instance as { values_overrides?: unknown } | undefined)?.values_overrides;
expect(ov).toBeUndefined();
});
it("save with library-tracked fixed_values and no edits leaves no override", async () => {
const wrapper = mount(ModuleEditModal, {
...mountOpts,
props: { visible: true, module: makeFixedValues() },
});
await flushPromises();
await wrapper.find('[data-test="fvm-save"]').trigger("click");
const saved = wrapper.emitted("save")?.[0][0] as ModuleEntry;
const ov = (saved.instance as { values_overrides?: unknown } | undefined)?.values_overrides;
expect(ov).toBeUndefined();
});
});
// ─ "ModuleEditModal — combine editor body" describe block removed.
// Combine migrated to v2 single-pane modal in 2026-05-08 cycle. New
// per-section coverage lives in:
// src/components/context/editors/combine/sections/IdentitySection.test.ts
// src/components/context/editors/combine/sections/TemplateSection.test.ts
// src/components/context/editors/combine/sections/RuntimeSection.test.ts
// src/components/context/editors/combine/CombineInstanceModal.test.ts
// ─ "ModuleEditModal — derivation editor body" describe block removed.
// Derivation migrated to v2 single-pane modal in 2026-05-09 cycle. New
// per-section coverage lives in:
// src/components/context/editors/derivation/sections/IdentitySection.test.ts
// src/components/context/editors/derivation/sections/RulesSection.test.ts
// src/components/context/editors/derivation/DerivationInstanceModal.test.ts
// Library-level rule editing (add/remove rule, IF branches, ELIF, ELSE)
// stays in SPA — modal exposes only per-rule disable toggle now.
// ─ "ModuleEditModal — constraint editor body" describe block removed.
// Constraint migrated to v2 single-pane modal in 2026-05-10 cycle. New
// per-section coverage lives in:
// src/components/context/editors/constraint/sections/IdentitySection.test.ts
// src/components/context/editors/constraint/sections/MatrixSection.test.ts
// src/components/context/editors/constraint/sections/ExceptionsSection.test.ts
// src/components/context/editors/constraint/CellRulePopover.test.ts
// src/components/context/editors/constraint/MatrixLegend.test.ts
// src/components/context/editors/constraint/ConstraintInstanceModal.test.ts
// Library-level matrix authoring (source/target picker, cell mode/factor
// authoring, exceptions add/remove) stays in SPA — modal exposes only
// per-cell + per-exception runtime overrides + extras.
// ── V2 + V3: two-line modal header (mockup v5 lines 1039-1040, 1180, 1260, 1317, 1436) ─
// V2 two-line header + V3 kind chip v1-shell tests removed in
// 2026-05-10 cycle — every kind now has its own v2 modal with header
// / sub / chip wired internally. Per-modal coverage lives in each
// kind's *.InstanceModal.test.ts.
describe("ModuleEditModal — V3 kind chip in header", () => {
beforeEach(() => _resetForTests());
it("v2 fixed_values modal renders its own kind chip via FixedValuesInstanceModal", async () => {
const wrapper = mount(ModuleEditModal, {
...mountOpts,
props: { visible: true, module: makeFixedValues() },
});
await nextTick();
// v2 modal owns the chip now (data-test="fvm-chip"), not the v1
// .wp-medit__head shell.
const chip = wrapper.find('[data-test="fvm-chip"]');
expect(chip.exists()).toBe(true);
expect(chip.text().toLowerCase()).toBe("fixed");
});
});
// Tab strip + dispatcher (v1 kinds) tests removed in 2026-05-10
// cycle — every kind now routes to its v2 single-pane modal which
// has no tab strip. Modified-state + clear-all-overrides behaviors
// covered per-kind in *.InstanceModal.test.ts; cross-kind Reset
// behavior covered by the "Reset preserves Identity + Runtime"
// describe block below.
describe("ModuleEditModal — Reset preserves Identity + Runtime across all v2 kinds", () => {
beforeEach(() => _resetForTests());
async function clearOverridesViaModal(wrapper: ReturnType<typeof mount>): Promise<void> {
await wrapper.find('[data-test="clear-all-overrides"], [data-test="wcm-clear-all"], [data-test="cbm-clear-all"], [data-test="fvm-clear-all"], [data-test="dvm-clear-all"]').trigger("click");
await nextTick();
const confirmBtn = wrapper.find('[data-test="confirm-confirm"]');
if (confirmBtn.exists()) await confirmBtn.trigger("click");
await nextTick();
}
it("combine Reset clears template_override but preserves locked_seed + internal", async () => {
const m: ModuleEntry = {
...makeCombine(),
instance: {
template_override: "$x",
locked_seed: 4242,
internal: true,
},
};
const wrapper = mount(ModuleEditModal, {
...mountOpts,
props: { visible: true, module: m, siblingModules: [] },
});
await nextTick();
await clearOverridesViaModal(wrapper);
await wrapper.find('[data-test="cbm-save"]').trigger("click");
const saved = wrapper.emitted("save")?.[0][0] as ModuleEntry;
expect(saved.instance?.template_override).toBeNull();
// Runtime preserved
expect(saved.instance?.locked_seed).toBe(4242);
expect(saved.instance?.internal).toBe(true);
});
it("fixed_values Reset clears values_overrides but preserves locked_seed + internal", async () => {
const m: ModuleEntry = {
...makeFixedValues(),
instance: {
values_overrides: [{ id: "v1", name: "lens", value: "50mm" }],
locked_seed: 99,
internal: true,
},
};
const wrapper = mount(ModuleEditModal, {
...mountOpts,
props: { visible: true, module: m, siblingModules: [] },
});
await nextTick();
await clearOverridesViaModal(wrapper);
await wrapper.find('[data-test="fvm-save"]').trigger("click");
const saved = wrapper.emitted("save")?.[0][0] as ModuleEntry;
// fixed_values save reconciliation deletes the override key entirely
// when it matches library — null OR undefined both mean "no override".
expect(saved.instance?.values_overrides ?? null).toBeNull();
expect(saved.instance?.locked_seed).toBe(99);
expect(saved.instance?.internal).toBe(true);
});
it("derivation Reset clears rule-group fields but preserves locked_seed + internal", async () => {
const m: ModuleEntry = {
...makeDerivation(),
instance: {
disabled_rule_ids: ["r1"],
disabled_branch_keys: ["r1:1"],
action_value_overrides: { r1: { "0": "fiery" } },
condition_value_overrides: { r1: { "0": "purple" } },
rule_order_override: ["r2", "r1"],
locked_seed: 4242,
internal: true,
},
};
const wrapper = mount(ModuleEditModal, {
...mountOpts,
props: { visible: true, module: m, siblingModules: [] },
});
await nextTick();
await clearOverridesViaModal(wrapper);
await wrapper.find('[data-test="dvm-save"]').trigger("click");
const saved = wrapper.emitted("save")?.[0][0] as ModuleEntry;
// Rule-group cleared
expect(saved.instance?.disabled_rule_ids).toBeNull();
expect(saved.instance?.disabled_branch_keys).toBeNull();
expect(saved.instance?.action_value_overrides).toBeNull();
expect(saved.instance?.condition_value_overrides).toBeNull();
expect(saved.instance?.rule_order_override).toBeNull();
// Runtime preserved
expect(saved.instance?.locked_seed).toBe(4242);
expect(saved.instance?.internal).toBe(true);
});
});
// ── Wildcard v2 dispatcher branch ─────────────────────────────────────────
describe("ModuleEditModal — wildcard v2 dispatcher", () => {
beforeEach(() => _resetForTests());
it("renders WildcardInstanceModal (no tab strip) for wildcard kind", async () => {
const wrapper = mount(ModuleEditModal, {
...mountOpts,
props: { visible: true, module: makeWildcard() },
});
await nextTick();
expect(wrapper.findComponent({ name: "WildcardInstanceModal" }).exists()).toBe(true);
expect(wrapper.find('[data-test="tab-library"]').exists()).toBe(false);
expect(wrapper.find('[data-test="tab-instance"]').exists()).toBe(false);
});
// ─ "renders v1 tab strip for kinds still on v1 (constraint)" test removed.
// Constraint migrated to v2 single-pane modal in 2026-05-10 cycle.
// Cross-kind v2 routing now covered by per-kind dispatcher describe
// blocks below.
it("forwards WildcardInstanceModal update event into draft mutation", async () => {
const wrapper = mount(ModuleEditModal, {
...mountOpts,
props: { visible: true, module: makeWildcard() },
});
await nextTick();
const wcm = wrapper.findComponent({ name: "WildcardInstanceModal" });
wcm.vm.$emit("update", { instance: { variable_binding: "renamed" } });
await nextTick();
// Save and check the emitted draft has the binding override applied.
await wrapper.find('[data-test="wcm-save"]').trigger("click");
const saved = wrapper.emitted("save")?.[0][0] as ModuleEntry;
expect(saved.instance?.variable_binding).toBe("renamed");
});
});
// ── Fixed-values v2 dispatcher branch ───────────────────────────────────
describe("ModuleEditModal — fixed_values v2 dispatcher", () => {
beforeEach(() => _resetForTests());
it("renders FixedValuesInstanceModal (no tab strip) for fixed_values kind", async () => {
const wrapper = mount(ModuleEditModal, {
...mountOpts,
props: { visible: true, module: makeFixedValues() },
});
await nextTick();
expect(wrapper.findComponent({ name: "FixedValuesInstanceModal" }).exists()).toBe(true);
expect(wrapper.find('[data-test="tab-library"]').exists()).toBe(false);
expect(wrapper.find('[data-test="tab-instance"]').exists()).toBe(false);
});
it("forwards FixedValuesInstanceModal update events into draft mutation", async () => {
const wrapper = mount(ModuleEditModal, {
...mountOpts,
props: { visible: true, module: makeFixedValues() },
});
await nextTick();
const fvm = wrapper.findComponent({ name: "FixedValuesInstanceModal" });
// v2 ValuesSection always emits `entries` in sync with the
// values_overrides shape, so save()'s reconciliation re-derives
// the same patch. Mirror that here.
fvm.vm.$emit("update", {
instance: {
values_overrides: [
{ id: "v1", name: "lens", value: "50mm" },
{ id: "v2", name: "angle", value: "wide" },
],
},
entries: [
{ variable_name: "lens", value: "50mm" },
{ variable_name: "angle", value: "wide" },
],
});
await nextTick();
await wrapper.find('[data-test="fvm-save"]').trigger("click");
const saved = wrapper.emitted("save")?.[0][0] as ModuleEntry;
expect(saved.instance?.values_overrides?.[0].value).toBe("50mm");
});
it("v2 ValuesSection emit + save() round-trip writes payload.values for inline modules", async () => {
const inline: ModuleEntry = {
id: "inline01",
type: "fixed_values",
enabled: true,
meta: { name: "scratch" },
entries: [{ variable_name: "tag", value: "noir" }],
payload: { values: [{ id: "x1", name: "tag", value: "noir" }] },
};
const wrapper = mount(ModuleEditModal, {
...mountOpts,
props: { visible: true, module: inline },
});
await nextTick();
const fvm = wrapper.findComponent({ name: "FixedValuesInstanceModal" });
// Mirror ValuesSection's emit shape — entries kept in sync.
fvm.vm.$emit("update", {
instance: { values_overrides: null, enabled_options: null },
entries: [{ variable_name: "tag", value: "vivid" }],
});
await nextTick();
await wrapper.find('[data-test="fvm-save"]').trigger("click");
const saved = wrapper.emitted("save")?.[0][0] as ModuleEntry;
const vals = (saved.payload as { values: Array<{ name: string; value: string }> }).values;
expect(vals[0].value).toBe("vivid");
});
});
// ── Combine v2 dispatcher branch ────────────────────────────────────────
describe("ModuleEditModal — combine v2 dispatcher", () => {
beforeEach(() => _resetForTests());
it("renders CombineInstanceModal (no tab strip) for combine kind", async () => {
const wrapper = mount(ModuleEditModal, {
...mountOpts,
props: { visible: true, module: makeCombine() },
});
await nextTick();
expect(wrapper.findComponent({ name: "CombineInstanceModal" }).exists()).toBe(true);
expect(wrapper.find('[data-test="tab-library"]').exists()).toBe(false);
expect(wrapper.find('[data-test="tab-instance"]').exists()).toBe(false);
});
it("forwards CombineInstanceModal update event into draft mutation", async () => {
const wrapper = mount(ModuleEditModal, {
...mountOpts,
props: { visible: true, module: makeCombine() },
});
await nextTick();
const cbm = wrapper.findComponent({ name: "CombineInstanceModal" });
cbm.vm.$emit("update", { instance: { template_override: "Hello $world" } });
await nextTick();
await wrapper.find('[data-test="cbm-save"]').trigger("click");
const saved = wrapper.emitted("save")?.[0][0] as ModuleEntry;
expect(saved.instance?.template_override).toBe("Hello $world");
});
});
// ── Derivation v2 dispatcher branch ────────────────────────────────────
describe("ModuleEditModal — derivation v2 dispatcher", () => {
beforeEach(() => {
_resetForTests();
stubEmptyModulesFetch();
});
afterEach(() => vi.unstubAllGlobals());
it("renders DerivationInstanceModal (no tab strip) for derivation kind", async () => {
const wrapper = mount(ModuleEditModal, {
...mountOpts,
props: { visible: true, module: makeDerivation() },
});
await nextTick();
expect(wrapper.findComponent({ name: "DerivationInstanceModal" }).exists()).toBe(true);
expect(wrapper.find('[data-test="tab-library"]').exists()).toBe(false);
expect(wrapper.find('[data-test="tab-instance"]').exists()).toBe(false);
});
it("forwards DerivationInstanceModal update event into draft mutation", async () => {
const wrapper = mount(ModuleEditModal, {
...mountOpts,
props: { visible: true, module: makeDerivation() },
});
await nextTick();
const dvm = wrapper.findComponent({ name: "DerivationInstanceModal" });
dvm.vm.$emit("update", { instance: { disabled_rule_ids: ["r1"] } });
await nextTick();
await wrapper.find('[data-test="dvm-save"]').trigger("click");
const saved = wrapper.emitted("save")?.[0][0] as ModuleEntry;
expect(saved.instance?.disabled_rule_ids).toEqual(["r1"]);
});
// Bug parity: the shell must thread upstream + sibling vars into the
// derivation modal so its action/condition override fields can offer
// `$var` autocomplete — the SAME props the wildcard + combine modals
// already receive. The `@{}` source is the LIBRARY (fetched inside the
// modal), so sibling/chain MODULES are intentionally NOT forwarded.
it("forwards upstream-vars + sibling-vars to DerivationInstanceModal", async () => {
const wrapper = mount(ModuleEditModal, {
...mountOpts,
props: {
visible: true,
module: makeDerivation(),
upstreamVars: ["age", "season"],
siblingVars: ["mood"],
},
});
await nextTick();
const dvm = wrapper.findComponent({ name: "DerivationInstanceModal" });
expect(dvm.props("upstreamVars")).toEqual(["age", "season"]);
expect(dvm.props("siblingVars")).toEqual(["mood"]);
});
});
// ── Constraint v2 dispatcher branch ────────────────────────────────────
describe("ModuleEditModal — constraint v2 dispatcher", () => {
beforeEach(() => _resetForTests());
it("renders ConstraintInstanceModal (no tab strip) for constraint kind", async () => {
const wrapper = mount(ModuleEditModal, {
...mountOpts,
props: { visible: true, module: makeConstraint() },
});
await nextTick();
expect(wrapper.findComponent({ name: "ConstraintInstanceModal" }).exists()).toBe(true);
expect(wrapper.find('[data-test="tab-library"]').exists()).toBe(false);
expect(wrapper.find('[data-test="tab-instance"]').exists()).toBe(false);
});
it("forwards ConstraintInstanceModal update event into draft mutation", async () => {
const wrapper = mount(ModuleEditModal, {
...mountOpts,
props: { visible: true, module: makeConstraint() },
});
await nextTick();
const cnm = wrapper.findComponent({ name: "ConstraintInstanceModal" });
cnm.vm.$emit("update", {
instance: { cell_mode_overrides: { '["a","b"]': "exclude" } },
});
await nextTick();
await wrapper.find('[data-test="cnm-save"]').trigger("click");
const saved = wrapper.emitted("save")?.[0][0] as ModuleEntry;
expect(saved.instance?.cell_mode_overrides).toEqual({ '["a","b"]': "exclude" });
});
// Cross-node chain (SP3 P6 T16): the mount layer threads the flattened
// chain as `chainModules`; the shell must forward it verbatim to the
// constraint modal so a target wildcard in another Context node still
// resolves. Resolution itself is covered in ConstraintInstanceModal.test.ts.
it("forwards chainModules prop through to ConstraintInstanceModal", async () => {
const chain = [
{
id: "bbbbbbbb",
rowKey: "99#bbbbbbbb",
type: "wildcard",
displayName: "outfit",
payload: { sub_categories: ["tee", "gown"], options: [] },
},
];
const wrapper = mount(ModuleEditModal, {
...mountOpts,
props: { visible: true, module: makeConstraint(), chainModules: chain },
});
await nextTick();
const cnm = wrapper.findComponent({ name: "ConstraintInstanceModal" });
expect(cnm.props("chainModules")).toEqual(chain);
});
it("leaves the child chainModules undefined when the prop is omitted", async () => {
const wrapper = mount(ModuleEditModal, {
...mountOpts,
props: { visible: true, module: makeConstraint() },
});
await nextTick();
const cnm = wrapper.findComponent({ name: "ConstraintInstanceModal" });
expect(cnm.props("chainModules")).toBeUndefined();
});
it("Reset overrides clears all override fields, preserves Identity", async () => {
const m = {
...makeConstraint(),
meta: { ...makeConstraint().meta, name: "color-rules" },
instance: {
disabled_matrix_cells: ['["a","b"]'],
cell_mode_overrides: { '["a","b"]': "exclude" as const },
cell_factor_overrides: { '["a","b"]': 5 },
exception_mode_overrides: { '["x","y"]': "boost" as const },
extra_exceptions: [{ source_value: "p", target_value: "q", mode: "allow" as const, factor: 1 }],
},
};
const wrapper = mount(ModuleEditModal, {
...mountOpts,
props: { visible: true, module: m, siblingModules: [] },
});
await nextTick();
await wrapper.find('[data-test="cnm-clear-all"]').trigger("click");
await nextTick();
const confirmBtn = wrapper.find('[data-test="confirm-confirm"]');
if (confirmBtn.exists()) await confirmBtn.trigger("click");
await nextTick();
await wrapper.find('[data-test="cnm-save"]').trigger("click");
const saved = wrapper.emitted("save")?.[0][0] as ModuleEntry;
// All override fields cleared
expect(saved.instance?.disabled_matrix_cells).toBeNull();
expect(saved.instance?.cell_mode_overrides).toBeNull();
expect(saved.instance?.cell_factor_overrides).toBeNull();
expect(saved.instance?.exception_mode_overrides).toBeNull();
expect(saved.instance?.extra_exceptions).toBeNull();
// Identity preserved
expect(saved.meta?.name).toBe("color-rules");
});
});