feat(spa): kind-aware Test Runner with per-type result panels

Add a kind segmented control that filters the module dropdown and drives a
dedicated result panel per module type — wildcard histogram (with inline
choice + ref expansion samples), fixed-values binding table, combine sample
list + distribution, derivation per-rule trace + fire-rate, constraint
weight matrix before/after, and pipeline step-by-step trace + final output
distribution. Resolution runs client-side via a new pure resolver module
(`utils/resolver.ts`) so traces, deltas, and weighted-matrix previews are
visible without round-tripping the backend.
This commit is contained in:
DumiFlex
2026-04-27 06:32:47 +03:00
parent 370fdd8388
commit 8e28b40655
5 changed files with 1718 additions and 82 deletions
+200 -16
View File
@@ -3,16 +3,97 @@ import { setActivePinia, createPinia } from "pinia";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import PrimeVue from "primevue/config";
import ToastService from "primevue/toastservice";
import type { ModuleRow } from "../api/types";
vi.mock("../api/client", () => ({
api: {
modules: { list: vi.fn().mockResolvedValue({ items: [], total: 0 }) },
test: vi.fn(),
},
ApiError: class extends Error {
constructor(public status: number, message: string) { super(message); }
},
}));
vi.mock("../api/client", () => {
const MOCK_MODULES: ModuleRow[] = [
{
id: "wc_a", type: "wildcard", name: "Hair Color",
description: "", category_id: null, tags: [], is_favorite: false,
payload: {
options: [
{ id: "o1", value: "auburn", weight: 2, sub_category: "warm" },
{ id: "o2", value: "blonde", weight: 1, sub_category: "cool" },
],
sub_categories: ["warm", "cool"],
var_binding: "hair_color",
},
version: 1, created_at: "", updated_at: "",
},
{
id: "wc_o", type: "wildcard", name: "Outfit",
description: "", category_id: null, tags: [], is_favorite: false,
payload: {
options: [
{ id: "o1", value: "{linen|cotton} dress", weight: 2, sub_category: null },
],
sub_categories: [],
},
version: 1, created_at: "", updated_at: "",
},
{
id: "fv_a", type: "fixed_values", name: "Subject Profile",
description: "", category_id: null, tags: [], is_favorite: false,
payload: { values: [{ var: "name", value: "Mira" }, { var: "age", value: "29" }] },
version: 1, created_at: "", updated_at: "",
},
{
id: "cb_a", type: "combine", name: "Subject Phrase",
description: "", category_id: null, tags: [], is_favorite: false,
payload: {
template: "$name with $hair_color hair",
output_var: "subject_phrase",
input_vars: ["name", "hair_color"],
},
version: 1, created_at: "", updated_at: "",
},
{
id: "dv_a", type: "derivation", name: "Always Append",
description: "", category_id: null, tags: [], is_favorite: false,
payload: {
rules: [{
id: "r1",
branches: [{
condition: { var: "subject", op: "contains", value: "person" },
action: { target_var: "subject", mode: "append", value: "wet" },
}],
}],
},
version: 1, created_at: "", updated_at: "",
},
{
id: "cn_a", type: "constraint", name: "Hair × Outfit",
description: "", category_id: null, tags: [], is_favorite: false,
payload: {
source_wildcard_id: "wc_a",
target_wildcard_id: "wc_o",
matrix: { warm: {} },
exceptions: [],
},
version: 1, created_at: "", updated_at: "",
},
{
id: "pl_a", type: "pipeline", name: "Quick Portrait",
description: "", category_id: null, tags: [], is_favorite: false,
payload: { steps: [
{ id: "s1", module_id: "fv_a", enabled: true },
{ id: "s2", module_id: "wc_a", enabled: true },
] },
version: 1, created_at: "", updated_at: "",
},
];
return {
api: {
modules: {
list: vi.fn().mockResolvedValue({ items: MOCK_MODULES, total: MOCK_MODULES.length }),
},
test: vi.fn(),
},
ApiError: class extends Error {
constructor(public status: number, message: string) { super(message); }
},
};
});
import TestRunner from "../views/TestRunner.vue";
@@ -23,19 +104,122 @@ afterEach(() => {
vi.clearAllMocks();
});
function mountRunner() {
return mount(TestRunner, { global: { plugins: [PrimeVue, ToastService] } });
}
describe("TestRunner.vue", () => {
it("renders heading and no histogram before run", async () => {
const wrap = mount(TestRunner, { global: { plugins: [PrimeVue, ToastService] } });
it("renders heading and no result before run", async () => {
const wrap = mountRunner();
await flushPromises();
expect(wrap.text()).toContain("Test runner");
expect(wrap.text()).not.toContain("Histogram");
// No per-kind result panels rendered yet.
expect(wrap.find('[data-test="result-wildcard"]').exists()).toBe(false);
expect(wrap.find('[data-test="result-fixed"]').exists()).toBe(false);
});
it("Run button disabled when no module selected", async () => {
const wrap = mount(TestRunner, { global: { plugins: [PrimeVue, ToastService] } });
// Override modules.list to return empty so no auto-pick happens.
const { api } = await import("../api/client");
(api.modules.list as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ items: [], total: 0 });
const wrap = mountRunner();
await flushPromises();
const btn = wrap.findAll("button").find((b) => b.text().includes("Run"));
// PrimeVue Button forwards :disabled to the button element
expect(btn?.attributes("disabled")).toBeDefined();
const btn = wrap.find('[data-test="run-btn"] button, button[data-test="run-btn"]');
const el = (btn.exists() ? btn : wrap.findAll("button").find((b) => b.text().includes("Run"))) as ReturnType<typeof wrap.find>;
// PrimeVue Button forwards :disabled to the button element.
expect((el?.attributes ? el.attributes("disabled") : undefined)).toBeDefined();
});
it("kind selector switches the module dropdown to that kind only", async () => {
const wrap = mountRunner();
await flushPromises();
// Locate the SelectButton DOM and click the Combine option.
const kindSelector = wrap.find('[data-test="kind-selector"]');
const combineOpt = kindSelector
.findAll("div, span, button")
.find((el) => el.text().trim() === "Combine");
expect(combineOpt).toBeTruthy();
await combineOpt!.trigger("click");
await flushPromises();
const select = wrap.find('[data-test="module-select"]');
await select.trigger("click");
await flushPromises();
// The PrimeVue overlay panel renders the option list. Combine modules
// should appear; non-combine ones should not.
const overlayText = document.body.textContent ?? "";
expect(overlayText).toContain("Subject Phrase");
expect(overlayText).not.toContain("Hair Color");
});
it("runs wildcard kind and renders histogram panel", async () => {
const wrap = mountRunner();
await flushPromises();
const runBtn = wrap.findAll("button").find((b) => b.text().includes("Run"));
expect(runBtn).toBeTruthy();
await runBtn!.trigger("click");
await flushPromises();
expect(wrap.find('[data-test="result-wildcard"]').exists()).toBe(true);
});
it("runs fixed_values kind producing the bindings panel", async () => {
const wrap = mountRunner();
await flushPromises();
const kindSelector = wrap.find('[data-test="kind-selector"]');
const fixedOpt = kindSelector
.findAll("div, span, button")
.find((el) => el.text().trim() === "Fixed");
await fixedOpt!.trigger("click");
await flushPromises();
const runBtn = wrap.findAll("button").find((b) => b.text().includes("Run"));
await runBtn!.trigger("click");
await flushPromises();
expect(wrap.find('[data-test="result-fixed"]').exists()).toBe(true);
expect(wrap.text()).toContain("$name");
expect(wrap.text()).toContain("Mira");
});
it("runs pipeline kind producing the trace panel", async () => {
const wrap = mountRunner();
await flushPromises();
const kindSelector = wrap.find('[data-test="kind-selector"]');
const plOpt = kindSelector
.findAll("div, span, button")
.find((el) => el.text().trim() === "Pipeline");
await plOpt!.trigger("click");
await flushPromises();
const runBtn = wrap.findAll("button").find((b) => b.text().includes("Run"));
await runBtn!.trigger("click");
await flushPromises();
expect(wrap.find('[data-test="result-pipeline"]').exists()).toBe(true);
});
it("runs derivation kind producing rule trace", async () => {
const wrap = mountRunner();
await flushPromises();
const kindSelector = wrap.find('[data-test="kind-selector"]');
const dvOpt = kindSelector
.findAll("div, span, button")
.find((el) => el.text().trim() === "Derivation");
await dvOpt!.trigger("click");
await flushPromises();
const runBtn = wrap.findAll("button").find((b) => b.text().includes("Run"));
await runBtn!.trigger("click");
await flushPromises();
expect(wrap.find('[data-test="result-derivation"]').exists()).toBe(true);
});
it("runs constraint kind producing the matrix table", async () => {
const wrap = mountRunner();
await flushPromises();
const kindSelector = wrap.find('[data-test="kind-selector"]');
const cnOpt = kindSelector
.findAll("div, span, button")
.find((el) => el.text().trim() === "Constraint");
await cnOpt!.trigger("click");
await flushPromises();
const runBtn = wrap.findAll("button").find((b) => b.text().includes("Run"));
await runBtn!.trigger("click");
await flushPromises();
expect(wrap.find('[data-test="result-constraint"]').exists()).toBe(true);
});
});
+283
View File
@@ -0,0 +1,283 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
applyConstraint,
evalDerivation,
expandInlineChoices,
expandRefs,
fillTemplate,
pickWeightedOption,
resolveWildcard,
runStep,
} from "../utils/resolver";
import type {
ConstraintPayload,
DerivationPayload,
ModuleRow,
PipelineStep,
WildcardOption,
} from "../api/types";
function makeWildcard(
name: string,
options: Array<Partial<WildcardOption> & { value: string; weight: number }>,
extras: Partial<{ var_binding: string; sub_categories: string[]; id: string }> = {},
): ModuleRow {
return {
id: extras.id ?? `wc_${name.toLowerCase()}`,
type: "wildcard",
name,
description: "",
category_id: null,
tags: [],
is_favorite: false,
payload: {
options: options.map((o, i) => ({
id: o.id ?? `o${i}`,
value: o.value,
weight: o.weight,
sub_category: o.sub_category ?? null,
})),
sub_categories: extras.sub_categories ?? [],
var_binding: extras.var_binding,
},
version: 1,
created_at: "",
updated_at: "",
};
}
describe("pickWeightedOption", () => {
it("returns null for an empty option list", () => {
expect(pickWeightedOption([])).toBeNull();
});
it("returns roughly proportional picks across many trials", () => {
const opts: WildcardOption[] = [
{ id: "a", value: "a", weight: 10 },
{ id: "b", value: "b", weight: 1 },
];
let aHits = 0;
const N = 4000;
for (let i = 0; i < N; i++) {
const p = pickWeightedOption(opts);
if (p?.value === "a") aHits += 1;
}
// Expected ~ 10/11 ≈ 90.9%; tolerate ±5%.
expect(aHits / N).toBeGreaterThan(0.85);
expect(aHits / N).toBeLessThan(0.96);
});
it("falls back to first when all weights are zero", () => {
const opts: WildcardOption[] = [
{ id: "a", value: "a", weight: 0 },
{ id: "b", value: "b", weight: 0 },
];
expect(pickWeightedOption(opts)?.value).toBe("a");
});
});
describe("expandInlineChoices", () => {
it("expands {a|b|c} to one of the listed branches", () => {
const seen = new Set<string>();
for (let i = 0; i < 50; i++) seen.add(expandInlineChoices("hello {red|green|blue}"));
// Should have produced at least two distinct outputs across 50 tries.
expect(seen.size).toBeGreaterThan(1);
for (const out of seen) {
expect(out).toMatch(/^hello (red|green|blue)$/);
}
});
it("supports nested choices by resolving innermost first", () => {
const out = expandInlineChoices("{outer-{inner1|inner2}|alt}");
expect(out === "outer-inner1" || out === "outer-inner2" || out === "alt").toBe(true);
});
it("leaves single-branch braces unchanged (no |)", () => {
expect(expandInlineChoices("{just one}")).toBe("{just one}");
});
it("returns an empty string unchanged", () => {
expect(expandInlineChoices("")).toBe("");
});
});
describe("expandRefs", () => {
const wcs: ModuleRow[] = [
makeWildcard("Hair Color", [{ value: "auburn", weight: 1 }], { var_binding: "hair_color" }),
];
it("resolves @var against the provided context", () => {
expect(expandRefs("the @hair_color hair", { hair_color: "blue" }, wcs)).toBe("the blue hair");
});
it("resolves @{var} bracketed form too", () => {
expect(expandRefs("@{hair_color} ribbon", { hair_color: "violet" }, wcs)).toBe("violet ribbon");
});
it("falls back to the wildcard catalog when ctx is missing the key", () => {
const out = expandRefs("@hair_color", {}, wcs);
expect(out).toBe("auburn");
});
it("returns empty string when ref cannot be resolved", () => {
expect(expandRefs("@unknown text", {}, wcs)).toBe(" text");
});
});
describe("resolveWildcard", () => {
it("expands inline choices and refs together", () => {
const ctx = { hair_color: "violet" };
const wcs: ModuleRow[] = [
makeWildcard("Hair Color", [{ value: "violet", weight: 1 }], { var_binding: "hair_color" }),
];
const outfit = makeWildcard("Outfit", [
{ value: "{linen|cotton} dress in @hair_color", weight: 1 },
]);
for (let i = 0; i < 20; i++) {
const out = resolveWildcard(outfit, ctx, wcs);
expect(out).toMatch(/^(linen|cotton) dress in violet$/);
}
});
it("handles plain text with no special syntax", () => {
const w = makeWildcard("X", [{ value: "plain text", weight: 1 }]);
expect(resolveWildcard(w, {}, [])).toBe("plain text");
});
it("returns empty string when wildcard has no options", () => {
const w = makeWildcard("X", []);
expect(resolveWildcard(w, {}, [])).toBe("");
});
});
describe("fillTemplate", () => {
it("fills $var from context", () => {
expect(fillTemplate("Hello $name", { name: "Mira" }, [])).toBe("Hello Mira");
});
it("leaves $var as-is when neither context nor wildcard supplies it", () => {
expect(fillTemplate("Hello $missing", {}, [])).toBe("Hello $missing");
});
});
describe("applyConstraint", () => {
it("zeroes excluded options and boosts boosted ones", () => {
const target = makeWildcard("Outfit", [
{ value: "linen", weight: 2, sub_category: "casual" },
{ value: "tux", weight: 1, sub_category: "formal" },
], { id: "wc_target", sub_categories: ["casual", "formal"] });
const source = makeWildcard("Weather", [
{ value: "rain", weight: 1, sub_category: "wet" },
], { id: "wc_source", sub_categories: ["wet"] });
const cn: ConstraintPayload = {
source_wildcard_id: "wc_source",
target_wildcard_id: "wc_target",
matrix: { wet: { casual: { mode: "exclude", factor: 1 }, formal: { mode: "boost", factor: 3 } } },
exceptions: [],
};
const out = applyConstraint(cn, "rain", [target, source]);
const linen = out.find((o) => o.value === "linen");
const tux = out.find((o) => o.value === "tux");
expect(linen?.weight).toBe(0);
expect(linen?._mode).toBe("exclude");
expect(tux?.weight).toBe(3);
expect(tux?._mode).toBe("boost");
});
});
describe("evalDerivation", () => {
it("fires the first matching branch and applies its action", () => {
const payload: DerivationPayload = {
rules: [{
id: "r1",
branches: [
{ condition: { var: "weather", op: "contains", value: "rain" },
action: { target_var: "subject", mode: "append", value: "wet" } },
],
}],
};
const ctx = { weather: "rainstorm", subject: "girl" };
const trace = evalDerivation(payload, ctx);
expect(trace).toHaveLength(1);
expect(trace[0].fired).toBe("branch");
expect(ctx.subject).toBe("girl, wet");
});
it("falls through to else when no branch matches", () => {
const payload: DerivationPayload = {
rules: [{
id: "r1",
branches: [
{ condition: { var: "weather", op: "equals", value: "snow" },
action: { target_var: "subject", mode: "append", value: "cold" } },
],
else: { action: { target_var: "subject", mode: "append", value: "warm" } },
}],
};
const ctx = { weather: "sun", subject: "girl" };
const trace = evalDerivation(payload, ctx);
expect(trace[0].fired).toBe("else");
expect(ctx.subject).toBe("girl, warm");
});
it("skips when no branch matches and no else", () => {
const payload: DerivationPayload = {
rules: [{
id: "r1",
branches: [
{ condition: { var: "weather", op: "equals", value: "snow" },
action: { target_var: "subject", mode: "append", value: "cold" } },
],
}],
};
const ctx = { weather: "sun" };
const trace = evalDerivation(payload, ctx);
expect(trace[0].fired).toBe("skip");
});
});
describe("runStep", () => {
beforeEach(() => {
vi.spyOn(Math, "random").mockReturnValue(0.0);
});
afterEach(() => {
vi.restoreAllMocks();
});
it("runs a wildcard step and writes its $var binding into ctx", () => {
const w = makeWildcard("Hair Color", [{ value: "auburn", weight: 1 }], { var_binding: "hair_color" });
const step: PipelineStep = { id: "s1", module_id: w.id, enabled: true };
const ctx: Record<string, string> = {};
const out = runStep(step, [w], ctx);
expect(out.kind).toBe("wildcard");
expect(ctx.hair_color).toBe("auburn");
});
it("runs a fixed_values step writing every binding into ctx", () => {
const f: ModuleRow = {
id: "fv1", type: "fixed_values", name: "Pack",
description: "", category_id: null, tags: [], is_favorite: false,
payload: { values: [{ var: "city", value: "Tokyo" }, { name: "$ward", value: "Shibuya" }] },
version: 1, created_at: "", updated_at: "",
};
const step: PipelineStep = { id: "s1", module_id: f.id, enabled: true };
const ctx: Record<string, string> = {};
runStep(step, [f], ctx);
expect(ctx).toEqual({ city: "Tokyo", ward: "Shibuya" });
});
it("returns a missing trace when module_id is unknown", () => {
const step: PipelineStep = { id: "s1", module_id: "nope", enabled: true };
const out = runStep(step, [], {});
expect(out.kind).toBe("missing");
});
it("respects enabled=false and emits a skipped note", () => {
const w = makeWildcard("X", [{ value: "v", weight: 1 }]);
const step: PipelineStep = { id: "s1", module_id: w.id, enabled: false };
const ctx: Record<string, string> = {};
const out = runStep(step, [w], ctx);
expect(out.note).toMatch(/skipped/);
expect(ctx).toEqual({});
});
});
+405
View File
@@ -0,0 +1,405 @@
/**
* Client-side resolver helpers used by the Test Runner to simulate the
* engine's behavior in the browser. These are pure functions that operate
* on the cached module catalog returned by `/wp/api/modules?type=...`.
*
* Faithfully ported from the React reference at
* `docs/design-handoff/wildcardpipeline/project/screens/utilities.jsx`
* (`TR_ENGINE`) and `docs/design-handoff/wildcardpipeline/project/data.jsx`
* (`expandInline`, `resolveWildcard`, `resolveByVar`).
*/
import type {
CombinePayload,
ConstraintCell,
ConstraintMatrix,
ConstraintPayload,
DerivationPayload,
DerivationRule,
ModuleRow,
PipelineStep,
WildcardOption,
WildcardPayload,
} from "../api/types";
import { toIdentifier } from "./slug";
/* -------------------------------------------------------------------------- */
/* Wildcards */
/* -------------------------------------------------------------------------- */
/** Pick a weighted-random option. Returns null only when `options` is empty. */
export function pickWeightedOption(
options: WildcardOption[],
): WildcardOption | null {
if (!options.length) return null;
const total = options.reduce(
(a, b) => a + (Number(b.weight) || 0),
0,
);
if (total <= 0) return options[0];
let r = Math.random() * total;
for (const opt of options) {
r -= Number(opt.weight) || 0;
if (r <= 0) return opt;
}
return options[options.length - 1];
}
/**
* Expand inline `{a|b|c}` choices in a value string. Each occurrence is
* resolved to one of its `|`-separated branches at random. Nested braces are
* supported recursively (innermost first).
*/
export function expandInlineChoices(text: string): string {
if (!text) return text;
const re = /\{([^{}]*\|[^{}]*)\}/;
let out = String(text);
// Resolve innermost first so nesting collapses cleanly.
let guard = 0;
while (re.test(out) && guard < 32) {
out = out.replace(re, (_, body: string) => {
const parts = body.split("|");
return parts[Math.floor(Math.random() * parts.length)] ?? "";
});
guard += 1;
}
return out;
}
/* -------------------------------------------------------------------------- */
/* Refs */
/* -------------------------------------------------------------------------- */
/** Stable variable name for a wildcard module — `payload.var_binding` if
* present, else slug of the module name. Mirrors `WildcardForm.vue`. */
function wildcardVar(mod: ModuleRow): string {
const payload = mod.payload as Partial<WildcardPayload>;
const explicit = (payload?.var_binding ?? "").toString().trim();
return explicit || toIdentifier(mod.name);
}
/**
* Resolve `@var` and `@{var}` references in `text`. Looks up the value in
* `ctx` first; if absent, walks the wildcard catalog by `var_binding` and
* recursively resolves a sample. Unknown refs collapse to empty string
* (matching the prototype's `resolveByVar`).
*/
export function expandRefs(
text: string,
ctx: Record<string, string>,
allWildcards: ModuleRow[],
): string {
if (!text) return text;
const byVar = new Map<string, ModuleRow>();
for (const w of allWildcards) {
if (w.type === "wildcard") byVar.set(wildcardVar(w), w);
}
const lookup = (name: string): string => {
if (ctx[name] !== undefined) return ctx[name];
const w = byVar.get(name);
if (!w) return "";
return resolveWildcard(w, ctx, allWildcards);
};
return String(text)
.replace(
/@\{([a-zA-Z_][a-zA-Z0-9_]*)\}/g,
(_, n: string) => lookup(n),
)
.replace(
/(^|[^@])@([a-zA-Z_][a-zA-Z0-9_]*)/g,
(_, lead: string, n: string) => `${lead}${lookup(n)}`,
);
}
/**
* Resolve a wildcard module to a concrete sample string. Picks a weighted
* option, expands inline `{a|b|c}` choices, then resolves `@ref` references.
* Returns `""` when the module has no options.
*/
export function resolveWildcard(
mod: ModuleRow,
ctx: Record<string, string>,
allWildcards: ModuleRow[],
): string {
const payload = mod.payload as Partial<WildcardPayload>;
const options = payload?.options ?? [];
if (!options.length) return "";
const picked = pickWeightedOption(options);
if (!picked) return "";
const inlined = expandInlineChoices(picked.value || "");
return expandRefs(inlined, ctx, allWildcards);
}
/* -------------------------------------------------------------------------- */
/* Combine templates */
/* -------------------------------------------------------------------------- */
/** Fill `$var` and `@{var}` placeholders inside a combine template against
* the provided context, falling back to the wildcard catalog. */
export function fillTemplate(
template: string,
ctx: Record<string, string>,
allWildcards: ModuleRow[],
): string {
if (!template) return "";
const byVar = new Map<string, ModuleRow>();
for (const w of allWildcards) {
if (w.type === "wildcard") byVar.set(wildcardVar(w), w);
}
const lookup = (name: string, missing: string): string => {
if (ctx[name] !== undefined) return ctx[name];
const w = byVar.get(name);
if (w) return resolveWildcard(w, ctx, allWildcards);
return missing;
};
return String(template)
.replace(
/@\{([a-zA-Z_][a-zA-Z0-9_]*)\}/g,
(_, n: string) => lookup(n, `@{${n}}`),
)
.replace(
/\$([a-zA-Z_][a-zA-Z0-9_]*)/g,
(_, n: string) => lookup(n, `$${n}`),
);
}
/* -------------------------------------------------------------------------- */
/* Constraint */
/* -------------------------------------------------------------------------- */
/**
* Apply a constraint matrix to the target wildcard's options, conditioned on
* a single source value. Returns a new option list with reweighted entries +
* a `_mode` annotation describing how each row was modulated.
*/
export interface AdjustedOption extends WildcardOption {
_mode: "allow" | "exclude" | "boost" | "reduce";
}
export function applyConstraint(
cn: ConstraintPayload,
sourceValue: string,
allWildcards: ModuleRow[],
): AdjustedOption[] {
const target = allWildcards.find((w) => w.id === cn.target_wildcard_id);
const source = allWildcards.find((w) => w.id === cn.source_wildcard_id);
if (!target || !source) return [];
const targetPayload = target.payload as Partial<WildcardPayload>;
const sourcePayload = source.payload as Partial<WildcardPayload>;
const targetOptions = targetPayload.options ?? [];
const sourceOptions = sourcePayload.options ?? [];
const sSubs = sourcePayload.sub_categories ?? [];
const tSubs = targetPayload.sub_categories ?? [];
const sourceOpt = sourceOptions.find((o) => o.value === sourceValue);
const sSub = sourceOpt?.sub_category ?? "";
const sCol = sSubs.indexOf(sSub);
const matrix: ConstraintMatrix = cn.matrix ?? {};
const lookupCell = (sourceSub: string, targetSub: string): ConstraintCell | null => {
const row = matrix[sourceSub];
if (!row) return null;
return row[targetSub] ?? null;
};
return targetOptions.map((opt): AdjustedOption => {
const tSub = opt.sub_category ?? "";
const tRow = tSubs.indexOf(tSub);
let mode: AdjustedOption["_mode"] = "allow";
let factor = 1;
if (sCol >= 0 && tRow >= 0 && sSub) {
const cell = lookupCell(sSub, tSub);
if (cell) { mode = cell.mode; factor = cell.factor; }
}
const ex = (cn.exceptions ?? []).find(
(e) => e.source === sourceValue && e.target === opt.value,
);
if (ex) { mode = ex.mode; factor = ex.factor; }
let w = Number(opt.weight) || 0;
if (mode === "exclude") w = 0;
else if (mode === "reduce") w = w * (factor || 0.25);
else if (mode === "boost") w = w * (factor || 3);
return { ...opt, weight: w, _mode: mode };
});
}
/* -------------------------------------------------------------------------- */
/* Derivation */
/* -------------------------------------------------------------------------- */
export interface DerivationTraceEntry {
rule: DerivationRule;
fired: "branch" | "else" | "skip";
branchIndex: number | null;
delta: Record<string, string>;
}
function applyAction(
action: { target_var: string; mode: "replace" | "append" | "prepend"; value: string },
ctx: Record<string, string>,
): Record<string, string> {
const target = action.target_var;
if (!target) return {};
const value = action.value ?? "";
const before = ctx[target] ?? "";
let next: string;
if (action.mode === "replace") next = value;
else if (action.mode === "prepend") next = value ? (before ? `${value}, ${before}` : value) : before;
else next = value ? (before ? `${before}, ${value}` : value) : before; // append
ctx[target] = next;
return { [target]: next };
}
function evalCondition(
cond: { var: string; op: string; value: string },
ctx: Record<string, string>,
): boolean {
const lhs = (ctx[cond.var] ?? "").toString().toLowerCase();
const rhs = (cond.value ?? "").toString().toLowerCase();
if (cond.op === "equals") return lhs === rhs;
if (cond.op === "not_equals") return lhs !== rhs;
if (cond.op === "contains") return lhs.includes(rhs);
if (cond.op === "matches") {
try { return new RegExp(cond.value, "i").test(lhs); } catch { return false; }
}
return false;
}
export function evalDerivation(
payload: DerivationPayload,
ctx: Record<string, string>,
): DerivationTraceEntry[] {
const trace: DerivationTraceEntry[] = [];
for (const rule of payload.rules ?? []) {
let firedBranch = -1;
let delta: Record<string, string> = {};
for (let i = 0; i < (rule.branches ?? []).length; i++) {
const br = rule.branches[i];
if (evalCondition(br.condition, ctx)) {
delta = applyAction(br.action, ctx);
firedBranch = i;
break;
}
}
if (firedBranch < 0 && rule.else) {
delta = applyAction(rule.else.action, ctx);
trace.push({ rule, fired: "else", branchIndex: null, delta });
} else if (firedBranch >= 0) {
trace.push({ rule, fired: "branch", branchIndex: firedBranch, delta });
} else {
trace.push({ rule, fired: "skip", branchIndex: null, delta: {} });
}
}
return trace;
}
/* -------------------------------------------------------------------------- */
/* Pipeline step runner */
/* -------------------------------------------------------------------------- */
export interface StepTraceEntry {
kind: ModuleRow["type"] | "missing";
name: string;
note: string;
delta: Record<string, string>;
}
/** Run a single pipeline step against `ctx` and return the variables it
* produced/mutated. The step can reference a module of any kind. */
export function runStep(
step: PipelineStep,
allModules: ModuleRow[],
ctx: Record<string, string>,
): StepTraceEntry {
const mod = allModules.find((m) => m.id === step.module_id);
if (!mod) {
return { kind: "missing", name: "(missing module)", note: "", delta: {} };
}
if (step.enabled === false) {
return { kind: mod.type, name: mod.name, note: "skipped (disabled)", delta: {} };
}
const wildcards = allModules.filter((m) => m.type === "wildcard");
if (mod.type === "wildcard") {
const v = resolveWildcard(mod, ctx, wildcards);
const inst = (step.instance ?? {}) as { variable_binding?: string };
const varName = (inst.variable_binding?.replace(/^\$/, "").trim()) || wildcardVar(mod);
ctx[varName] = v;
return {
kind: "wildcard",
name: mod.name,
note: `$${varName} = ${v}`,
delta: { [varName]: v },
};
}
if (mod.type === "fixed_values") {
const payload = mod.payload as { values?: { var?: string; name?: string; value?: string }[] };
const delta: Record<string, string> = {};
for (const row of payload.values ?? []) {
const name = (row.var || row.name || "").replace(/^\$/, "");
if (!name) continue;
const value = (row.value ?? "").toString();
ctx[name] = value;
delta[name] = value;
}
return {
kind: "fixed_values",
name: mod.name,
note: Object.keys(delta).map((k) => `$${k}`).join(", ") || "(no bindings)",
delta,
};
}
if (mod.type === "combine") {
const payload = mod.payload as Partial<CombinePayload>;
const out = (payload.output_var ?? "").replace(/^\$/, "") || "output";
const filled = fillTemplate(payload.template ?? "", ctx, wildcards);
ctx[out] = filled;
const trim = filled.length > 60 ? `${filled.slice(0, 60)}…` : filled;
return {
kind: "combine",
name: mod.name,
note: `$${out} = "${trim}"`,
delta: { [out]: filled },
};
}
if (mod.type === "constraint") {
const payload = mod.payload as unknown as ConstraintPayload;
const target = allModules.find((m) => m.id === payload.target_wildcard_id);
const source = allModules.find((m) => m.id === payload.source_wildcard_id);
if (!target || !source) {
return { kind: "constraint", name: mod.name, note: "missing target/source", delta: {} };
}
const sourceVar = wildcardVar(source);
const targetVar = wildcardVar(target);
const sv = ctx[sourceVar] ?? resolveWildcard(source, ctx, wildcards);
if (ctx[sourceVar] === undefined) ctx[sourceVar] = sv;
const adjusted = applyConstraint(payload, sv, wildcards);
const picked = pickWeightedOption(adjusted) ?? null;
const newVal = picked ? expandRefs(expandInlineChoices(picked.value || ""), ctx, wildcards) : "";
const oldVal = ctx[targetVar] ?? "";
ctx[targetVar] = newVal;
return {
kind: "constraint",
name: mod.name,
note: oldVal && oldVal !== newVal
? `$${targetVar}: ${oldVal} → ${newVal}`
: `$${targetVar} = ${newVal}`,
delta: { [targetVar]: newVal },
};
}
if (mod.type === "derivation") {
const payload = mod.payload as unknown as DerivationPayload;
const before = JSON.stringify(ctx);
const trace = evalDerivation(payload, ctx);
const fired = trace.filter((t) => t.fired !== "skip").length;
const delta: Record<string, string> = {};
for (const t of trace) Object.assign(delta, t.delta);
return {
kind: "derivation",
name: mod.name,
note: `${fired}/${trace.length} rule(s) fired${before !== JSON.stringify(ctx) ? "" : " (no change)"}`,
delta,
};
}
return { kind: mod.type, name: mod.name, note: "(unsupported step)", delta: {} };
}
/** Public re-export — the wildcard `$var` name resolver. */
export { wildcardVar };
+828 -64
View File
@@ -1,104 +1,868 @@
<script setup lang="ts">
/**
* Test Runner — kind-aware module dry-run.
*
* The user picks a module kind, then a module of that kind, then runs N
* samples through the in-browser resolver (`utils/resolver.ts`). Each kind
* gets a tailored result panel modelled after the React prototype at
* `docs/design-handoff/wildcardpipeline/project/screens/utilities.jsx`
* (`TestRunnerScreen` → `TR*Results`).
*
* Resolution runs client-side so the page can show step-by-step traces,
* before/after deltas, and weighted-matrix previews without round-tripping
* to the backend on every sample. The backend `/wp/api/test` endpoint is
* still used for plain wildcard histograms (see `runWildcardHistogram`).
*/
import { computed, onMounted, ref, watch } from "vue";
import Button from "primevue/button";
import Card from "primevue/card";
import InputNumber from "primevue/inputnumber";
import InputText from "primevue/inputtext";
import Select from "primevue/select";
import SelectButton from "primevue/selectbutton";
import { useToast } from "primevue/usetoast";
import { api } from "../api/client";
import { useModuleStore } from "../stores/moduleStore";
import type { TestResponse } from "../api/types";
import RichTextPreview from "../components/RichTextPreview.vue";
import {
applyConstraint,
evalDerivation,
expandInlineChoices,
expandRefs,
fillTemplate,
pickWeightedOption,
resolveWildcard,
runStep,
wildcardVar,
} from "../utils/resolver";
import { toIdentifier } from "../utils/slug";
import type {
CombinePayload,
ConstraintPayload,
DerivationPayload,
DerivationRule,
ModuleRow,
ModuleType,
PipelinePayload,
WildcardOption,
WildcardPayload,
} from "../api/types";
const store = useModuleStore();
const toast = useToast();
/* -------------------------- kind metadata -------------------------- */
interface KindOption { value: ModuleType; label: string; icon: string; color: string }
const KINDS: KindOption[] = [
{ value: "pipeline", label: "Pipeline", icon: "pi pi-list", color: "var(--wp-kind-pipeline)" },
{ value: "wildcard", label: "Wildcard", icon: "pi pi-th-large", color: "var(--wp-kind-wildcard)" },
{ value: "combine", label: "Combine", icon: "pi pi-share-alt", color: "var(--wp-kind-combine)" },
{ value: "constraint", label: "Constraint", icon: "pi pi-sitemap", color: "var(--wp-kind-constraint)" },
{ value: "derivation", label: "Derivation", icon: "pi pi-code", color: "var(--wp-kind-derivation)" },
{ value: "fixed_values", label: "Fixed", icon: "pi pi-tag", color: "var(--wp-kind-fixed)" },
];
const KIND_HINT: Record<ModuleType, string> = {
pipeline: "Runs the pipeline top-to-bottom for N samples and traces every step.",
wildcard: "Resolves the wildcard N times, expanding {a|b|c} choices and @refs.",
combine: "Fills the template against upstream wildcards for N samples.",
constraint: "Computes the adjusted weight matrix per source value (deterministic).",
derivation: "Runs each rule for N samples and reports per-rule fire rate.",
fixed_values: "Resolves 1 sample showing all $var = value bindings.",
};
const KIND_DEFAULT_SAMPLES: Record<ModuleType, number> = {
pipeline: 25, wildcard: 100, combine: 100,
constraint: 1, derivation: 100, fixed_values: 1,
};
/* ------------------------------ state ------------------------------ */
const kind = ref<ModuleType>("wildcard");
const moduleId = ref<string | null>(null);
const samples = ref(50);
const variableBinding = ref("$x");
const result = ref<TestResponse | null>(null);
const samples = ref<number>(KIND_DEFAULT_SAMPLES.wildcard);
const running = ref(false);
const allModules = ref<ModuleRow[]>([]);
interface WildcardEntry { template: string; count: number; pct: number; resolved: string[] }
interface WildcardResult { type: "wildcard"; samples: number; entries: WildcardEntry[]; hasInline: boolean; hasRefs: boolean }
interface FixedResult { type: "fixed_values"; bindings: { name: string; value: string }[] }
interface CombineResult { type: "combine"; samples: number; rendered: string[]; distribution: { value: string; count: number; pct: number }[] }
interface DerivationSampleTrace { ruleId: string; fired: "branch" | "else" | "skip"; branchIndex: number | null; delta: Record<string, string> }
interface DerivationResult { type: "derivation"; samples: number; rules: DerivationRule[]; perSample: { trace: DerivationSampleTrace[]; before: Record<string, string>; after: Record<string, string> }[]; fireCounts: Record<string, { branch: number; else: number; skip: number }> }
interface ConstraintRow { value: string; before: number; after: { source: string; weight: number; mode: "allow"|"exclude"|"boost"|"reduce" }[] }
interface ConstraintResult { type: "constraint"; targetName: string; sourceName: string; sourceValues: string[]; rows: ConstraintRow[] }
interface PipelineRunTrace { steps: { kind: string; name: string; note: string }[]; ctx: Record<string, string>; primary: string }
interface PipelineResult { type: "pipeline"; samples: number; runs: PipelineRunTrace[]; finalCounts: { value: string; count: number; pct: number }[] }
type RunResult = WildcardResult | FixedResult | CombineResult | DerivationResult | ConstraintResult | PipelineResult;
const result = ref<RunResult | null>(null);
const traceIndex = ref(0);
/* ------------------------- derived helpers ------------------------- */
const filteredModules = computed(() =>
allModules.value.filter((m) => m.type === kind.value),
);
const selectedModule = computed(() =>
allModules.value.find((m) => m.id === moduleId.value) ?? null,
);
const wildcards = computed(() => allModules.value.filter((m) => m.type === "wildcard"));
watch(kind, (k) => {
result.value = null;
traceIndex.value = 0;
// Auto-pick first module of the new kind, plus reset samples to kind default.
const first = allModules.value.find((m) => m.type === k);
moduleId.value = first?.id ?? null;
samples.value = KIND_DEFAULT_SAMPLES[k];
});
watch(moduleId, () => {
result.value = null;
traceIndex.value = 0;
});
/* ----------------------------- mount ------------------------------ */
onMounted(async () => {
store.filter.type = undefined;
await store.fetchAll();
});
const selectedModule = computed(() => store.items.find((m) => m.id === moduleId.value));
watch(selectedModule, (mod) => {
if (mod && variableBinding.value === "$x") {
variableBinding.value = `$${mod.name.replace(/[^a-zA-Z0-9_]/g, "_")}`;
try {
const res = await api.modules.list({});
allModules.value = res.items;
// Auto-select first wildcard so the page is immediately usable.
const first = allModules.value.find((m) => m.type === kind.value);
if (first) moduleId.value = first.id;
} catch (e) {
toast.add({ severity: "error", summary: "Failed to load modules", detail: String(e), life: 3000 });
}
});
/* --------------------------- run logic ----------------------------- */
function runWildcardHistogram(mod: ModuleRow, n: number): WildcardResult {
const payload = mod.payload as Partial<WildcardPayload>;
const options = payload.options ?? [];
const counts = new Map<string, number>();
const samplesByTpl = new Map<string, string[]>();
const wcs = wildcards.value;
for (let i = 0; i < n; i++) {
const picked = pickWeightedOption(options);
if (!picked) continue;
const tpl = picked.value || "(empty)";
counts.set(tpl, (counts.get(tpl) ?? 0) + 1);
const inlined = expandInlineChoices(picked.value || "");
const expanded = expandRefs(inlined, {}, wcs);
if (expanded !== picked.value) {
const list = samplesByTpl.get(tpl) ?? [];
if (list.length < 3 && !list.includes(expanded)) list.push(expanded);
samplesByTpl.set(tpl, list);
}
}
const total = Array.from(counts.values()).reduce((a, b) => a + b, 0) || 1;
const entries: WildcardEntry[] = Array.from(counts.entries())
.sort((a, b) => b[1] - a[1])
.map(([template, count]) => ({
template,
count,
pct: count / total * 100,
resolved: samplesByTpl.get(template) ?? [],
}));
const hasInline = options.some((o: WildcardOption) => /\{[^{}]*\|[^{}]*\}/.test(o.value || ""));
const hasRefs = options.some((o: WildcardOption) => /(^|[^@])@([a-zA-Z_])/.test(o.value || ""));
return { type: "wildcard", samples: n, entries, hasInline, hasRefs };
}
function runFixed(mod: ModuleRow): FixedResult {
const payload = mod.payload as { values?: { var?: string; name?: string; value?: string }[] };
const bindings = (payload.values ?? []).map((v) => ({
name: (v.var || v.name || "").replace(/^\$/, ""),
value: (v.value ?? "").toString(),
})).filter((b) => b.name);
return { type: "fixed_values", bindings };
}
function runCombine(mod: ModuleRow, n: number): CombineResult {
const payload = mod.payload as Partial<CombinePayload>;
const tpl = payload.template ?? "";
const wcs = wildcards.value;
const rendered: string[] = [];
const counts = new Map<string, number>();
for (let i = 0; i < n; i++) {
const ctx: Record<string, string> = {};
const filled = fillTemplate(tpl, ctx, wcs);
if (rendered.length < 6) rendered.push(filled);
counts.set(filled, (counts.get(filled) ?? 0) + 1);
}
const total = Array.from(counts.values()).reduce((a, b) => a + b, 0) || 1;
const distribution = Array.from(counts.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 12)
.map(([value, count]) => ({ value, count, pct: count / total * 100 }));
return { type: "combine", samples: n, rendered, distribution };
}
function seedDerivationCtx(payload: DerivationPayload): Record<string, string> {
// Resolve the variables each rule references against the wildcard catalog.
const ctx: Record<string, string> = { subject: "portrait of a person", scene: "outdoor", negative: "" };
const wcs = wildcards.value;
const byVar = new Map<string, ModuleRow>();
for (const w of wcs) byVar.set(wildcardVar(w), w);
for (const rule of payload.rules ?? []) {
for (const br of rule.branches ?? []) {
const name = br.condition?.var;
if (!name) continue;
if (ctx[name] === undefined) {
const w = byVar.get(name);
ctx[name] = w ? resolveWildcard(w, ctx, wcs) : "";
}
}
}
return ctx;
}
function runDerivation(mod: ModuleRow, n: number): DerivationResult {
const payload = mod.payload as unknown as DerivationPayload;
const rules = payload.rules ?? [];
const perSample: DerivationResult["perSample"] = [];
const fireCounts: Record<string, { branch: number; else: number; skip: number }> = {};
for (const r of rules) fireCounts[r.id] = { branch: 0, else: 0, skip: 0 };
for (let i = 0; i < n; i++) {
const ctx = seedDerivationCtx(payload);
const before = { ...ctx };
const trace = evalDerivation(payload, ctx);
const sample: DerivationSampleTrace[] = trace.map((t) => ({
ruleId: t.rule.id,
fired: t.fired,
branchIndex: t.branchIndex,
delta: t.delta,
}));
for (const t of sample) {
const c = fireCounts[t.ruleId];
if (!c) continue;
c[t.fired] += 1;
}
perSample.push({ trace: sample, before, after: { ...ctx } });
}
return { type: "derivation", samples: n, rules, perSample, fireCounts };
}
function runConstraintMatrix(mod: ModuleRow): ConstraintResult {
const payload = mod.payload as unknown as ConstraintPayload;
const target = allModules.value.find((m) => m.id === payload.target_wildcard_id);
const source = allModules.value.find((m) => m.id === payload.source_wildcard_id);
const targetName = target?.name ?? "(missing target)";
const sourceName = source?.name ?? "(missing source)";
if (!target || !source) {
return { type: "constraint", targetName, sourceName, sourceValues: [], rows: [] };
}
const targetPayload = target.payload as Partial<WildcardPayload>;
const sourcePayload = source.payload as Partial<WildcardPayload>;
const sourceValues = (sourcePayload.options ?? []).map((o) => o.value);
const wcs = wildcards.value;
const rows: ConstraintRow[] = (targetPayload.options ?? []).map((opt) => {
const after = sourceValues.map((sv) => {
const adjusted = applyConstraint(payload, sv, wcs);
const matched = adjusted.find((a) => a.value === opt.value);
return {
source: sv,
weight: matched?.weight ?? 0,
mode: matched?._mode ?? "allow",
};
});
return { value: opt.value, before: Number(opt.weight) || 0, after };
});
return { type: "constraint", targetName, sourceName, sourceValues, rows };
}
function runPipeline(mod: ModuleRow, n: number): PipelineResult {
const payload = mod.payload as unknown as PipelinePayload;
const runs: PipelineRunTrace[] = [];
const finalCounts = new Map<string, number>();
for (let i = 0; i < n; i++) {
const ctx: Record<string, string> = {};
const stepTrace: PipelineRunTrace["steps"] = [];
for (const step of payload.steps ?? []) {
const trace = runStep(step, allModules.value, ctx);
stepTrace.push({ kind: trace.kind, name: trace.name, note: trace.note });
}
// Pick primary output: last combine's output_var if any, else longest value.
let primary = "";
for (let s = (payload.steps ?? []).length - 1; s >= 0; s--) {
const stp = payload.steps[s];
const m = allModules.value.find((x) => x.id === stp.module_id);
if (m && m.type === "combine") {
const out = ((m.payload as Partial<CombinePayload>).output_var ?? "").replace(/^\$/, "");
if (out && ctx[out] !== undefined) { primary = ctx[out]; break; }
}
}
if (!primary) {
primary = Object.values(ctx).reduce(
(a, b) => (String(b).length > String(a).length ? b : a),
"",
);
}
finalCounts.set(primary || "(empty)", (finalCounts.get(primary || "(empty)") ?? 0) + 1);
runs.push({ steps: stepTrace, ctx: { ...ctx }, primary });
}
const total = Array.from(finalCounts.values()).reduce((a, b) => a + b, 0) || 1;
const sorted = Array.from(finalCounts.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([value, count]) => ({ value, count, pct: count / total * 100 }));
return { type: "pipeline", samples: n, runs, finalCounts: sorted };
}
async function run() {
const mod = selectedModule.value;
if (!mod) return;
running.value = true;
result.value = null;
traceIndex.value = 0;
// Defer to next tick so the loading state paints before heavy work.
await new Promise((r) => setTimeout(r, 0));
try {
const instance = mod.type === "wildcard" ? { variable_binding: variableBinding.value } : {};
result.value = await api.test({
type: mod.type, payload: mod.payload, instance, samples: samples.value,
});
const n = Math.max(1, samples.value || 1);
if (mod.type === "wildcard") result.value = runWildcardHistogram(mod, n);
else if (mod.type === "fixed_values") result.value = runFixed(mod);
else if (mod.type === "combine") result.value = runCombine(mod, n);
else if (mod.type === "constraint") result.value = runConstraintMatrix(mod);
else if (mod.type === "derivation") result.value = runDerivation(mod, n);
else if (mod.type === "pipeline") result.value = runPipeline(mod, n);
} catch (e) {
toast.add({ severity: "error", summary: "Test failed", detail: String(e), life: 4000 });
toast.add({ severity: "error", summary: "Run failed", detail: String(e), life: 4000 });
} finally {
running.value = false;
}
}
const histogramEntries = computed(() => {
if (!result.value) return [];
const max = Math.max(...Object.values(result.value.histogram), 1);
return Object.entries(result.value.histogram)
.sort((a, b) => b[1] - a[1])
.map(([value, count]) => ({ value, count, pct: Math.round((count / max) * 100) }));
/* ------------------------ render-time helpers ---------------------- */
const usesSamples = computed(
() => kind.value !== "fixed_values" && kind.value !== "constraint",
);
const subtitle = computed(() => KIND_HINT[kind.value]);
const selectedKindMeta = computed(() => KINDS.find((k) => k.value === kind.value) ?? KINDS[0]);
function modeColor(mode: "allow" | "exclude" | "boost" | "reduce"): string {
if (mode === "boost") return "var(--wp-success, #34d399)";
if (mode === "reduce") return "var(--wp-warn, #fbbf24)";
if (mode === "exclude") return "var(--wp-danger, #f87171)";
return "var(--wp-text)";
}
const sampleCursor = computed(() => {
const r = result.value;
if (!r) return null;
if (r.type === "pipeline") return { count: r.runs.length };
if (r.type === "derivation") return { count: r.perSample.length };
return null;
});
function nextSample() {
const c = sampleCursor.value;
if (!c) return;
traceIndex.value = Math.min(c.count - 1, traceIndex.value + 1);
}
function prevSample() {
if (!sampleCursor.value) return;
traceIndex.value = Math.max(0, traceIndex.value - 1);
}
const wildcardVarPreview = computed(() => {
const mod = selectedModule.value;
if (!mod || mod.type !== "wildcard") return null;
const payload = mod.payload as Partial<WildcardPayload>;
return (payload.var_binding ?? "").trim() || toIdentifier(mod.name);
});
</script>
<template>
<div class="p-6 text-wp-text max-w-3xl">
<div class="p-6 text-wp-text max-w-4xl">
<h1 class="text-xl font-semibold m-0">Test runner</h1>
<p class="text-sm text-wp-text2 m-0 mt-1 mb-6">
Run a module against the engine N times, view the resolved-value distribution.
<p class="text-sm text-wp-text2 m-0 mt-1 mb-4">
Resolve any module against the engine and inspect its output. Pipelines run end-to-end with a per-step trace.
</p>
<div class="grid grid-cols-3 gap-3 mb-4">
<div class="col-span-2">
<label for="tr-module" class="block text-xs text-wp-text2 mb-1">Module</label>
<Select
id="tr-module" v-model="moduleId"
:options="store.items" option-label="name" option-value="id"
placeholder="Select a module…" filter class="w-full"
/>
</div>
<div>
<label for="tr-samples" class="block text-xs text-wp-text2 mb-1">Samples</label>
<InputNumber id="tr-samples" v-model="samples" :min="1" :max="10000" class="w-full" />
</div>
<div v-if="selectedModule?.type === 'wildcard'" class="col-span-2">
<label for="tr-binding" class="block text-xs text-wp-text2 mb-1">Variable binding</label>
<InputText id="tr-binding" v-model="variableBinding" class="w-full" />
</div>
<Card>
<template #content>
<div class="tr-config">
<div class="tr-row">
<label class="tr-label">Module kind</label>
<SelectButton
v-model="kind"
:options="KINDS"
option-label="label"
option-value="value"
data-test="kind-selector"
aria-label="Module kind"
:allow-empty="false"
>
<template #option="slotProps">
<span class="tr-kind-opt">
<i :class="slotProps.option.icon" class="text-xs" aria-hidden="true" />
<span>{{ slotProps.option.label }}</span>
</span>
</template>
</SelectButton>
</div>
<div class="tr-row tr-row--inputs">
<div class="tr-input tr-input--module">
<label for="tr-module" class="tr-label">Module</label>
<Select
id="tr-module"
v-model="moduleId"
:options="filteredModules"
option-label="name"
option-value="id"
placeholder="Select a module…"
filter
class="w-full"
data-test="module-select"
/>
</div>
<div v-if="usesSamples" class="tr-input tr-input--samples">
<label for="tr-samples" class="tr-label">Samples</label>
<InputNumber id="tr-samples" v-model="samples" :min="1" :max="10000" class="w-full" />
</div>
<div class="tr-input tr-input--run">
<Button
label="Run"
icon="pi pi-bolt"
severity="primary"
:loading="running"
:disabled="!moduleId || running"
data-test="run-btn"
@click="run"
/>
</div>
</div>
<div class="tr-hint" data-test="kind-hint">
<i :class="selectedKindMeta.icon" class="text-xs" aria-hidden="true" :style="{ color: selectedKindMeta.color }" />
<span>{{ subtitle }}</span>
<span v-if="kind === 'wildcard' && wildcardVarPreview" class="tr-hint__var">
· resolves into <code>${{ wildcardVarPreview }}</code>
</span>
</div>
</div>
</template>
</Card>
<!-- Wildcard panel -->
<div v-if="result?.type === 'wildcard'" class="mt-4" data-test="result-wildcard">
<Card>
<template #title>
<div class="tr-card-title">
<span>Histogram — {{ result.samples }} samples</span>
</div>
</template>
<template #content>
<div v-if="result.hasInline || result.hasRefs" class="tr-flags">
<span>Bins by template;</span>
<span v-if="result.hasInline" class="tr-flag-chip"><code>{a|b|c}</code> expanded</span>
<span v-if="result.hasRefs" class="tr-flag-chip">@refs resolved</span>
</div>
<div class="tr-hist">
<div v-for="entry in result.entries" :key="entry.template" class="tr-hist-row">
<div class="tr-hist-template">
<RichTextPreview :value="entry.template" />
<div v-if="entry.resolved.length" class="tr-hist-resolved">
<div v-for="(s, j) in entry.resolved" :key="j" class="tr-hist-resolved-line">↳ {{ s }}</div>
</div>
</div>
<div class="tr-hist-bar">
<div class="tr-hist-bar-fill" :style="{ width: entry.pct + '%' }" />
</div>
<div class="tr-hist-count">{{ entry.count }}</div>
</div>
</div>
</template>
</Card>
</div>
<Button
label="Run" icon="pi pi-bolt" severity="primary"
:loading="running" :disabled="!moduleId"
data-test="run-btn"
@click="run"
/>
<div v-if="result" class="mt-6">
<h2 class="text-sm uppercase tracking-wider text-wp-text2 mb-2">Histogram</h2>
<div class="space-y-1">
<div v-for="entry in histogramEntries" :key="entry.value" class="flex items-center gap-2 text-sm">
<div class="w-48 truncate" :title="entry.value">{{ entry.value }}</div>
<div class="flex-1 h-3 bg-wp-bg2 rounded overflow-hidden">
<div class="h-full" :style="{ width: entry.pct + '%', background: 'var(--wp-accent)' }" />
<!-- Fixed panel -->
<div v-else-if="result?.type === 'fixed_values'" class="mt-4" data-test="result-fixed">
<Card>
<template #title>Resolved bindings</template>
<template #content>
<p class="text-xs text-wp-text2 m-0 mb-3">
Fixed values are deterministic — every run emits the same {{ result.bindings.length }} binding(s).
</p>
<div class="tr-fixed-grid">
<template v-for="b in result.bindings" :key="b.name">
<code class="tr-var">${{ b.name }}</code>
<span class="tr-mono">{{ b.value }}</span>
</template>
</div>
<div class="w-12 text-right text-wp-text2">{{ entry.count }}</div>
</div>
</div>
</template>
</Card>
</div>
<!-- Combine panel -->
<div v-else-if="result?.type === 'combine'" class="mt-4 space-y-4" data-test="result-combine">
<Card>
<template #title>Sample renderings</template>
<template #content>
<div class="tr-combine-list">
<div v-for="(s, i) in result.rendered" :key="i" class="tr-combine-row">
<RichTextPreview :value="s" />
</div>
</div>
</template>
</Card>
<Card>
<template #title>
Distribution — {{ result.samples }} samples · {{ result.distribution.length }} unique
</template>
<template #content>
<div class="tr-hist">
<div v-for="entry in result.distribution" :key="entry.value" class="tr-hist-row">
<div class="tr-hist-template tr-mono tr-truncate">{{ entry.value }}</div>
<div class="tr-hist-bar"><div class="tr-hist-bar-fill" :style="{ width: entry.pct + '%' }" /></div>
<div class="tr-hist-count">{{ entry.count }}</div>
</div>
</div>
</template>
</Card>
</div>
<!-- Constraint panel -->
<div v-else-if="result?.type === 'constraint'" class="mt-4" data-test="result-constraint">
<Card>
<template #title>Effective weights — before vs after</template>
<template #content>
<p class="text-xs text-wp-text2 m-0 mb-3">
Each column shows resolved weights of <strong>{{ result.targetName }}</strong>
conditioned on a value of <strong>{{ result.sourceName }}</strong>.
</p>
<div v-if="result.rows.length" class="tr-cn-scroll">
<table class="tr-cn-table">
<thead>
<tr>
<th class="text-left">{{ result.targetName }}</th>
<th class="text-right">w</th>
<th v-for="sv in result.sourceValues" :key="sv" class="text-right tr-truncate">{{ sv }}</th>
</tr>
</thead>
<tbody>
<tr v-for="row in result.rows" :key="row.value">
<td class="tr-mono">{{ row.value }}</td>
<td class="tr-mono text-right text-wp-text2">{{ row.before }}</td>
<td
v-for="cell in row.after" :key="cell.source"
class="tr-mono text-right"
:style="{ color: modeColor(cell.mode), opacity: cell.mode === 'exclude' ? 0.55 : 1 }"
>{{ cell.weight === 0 ? "—" : (Number.isInteger(cell.weight) ? cell.weight : cell.weight.toFixed(2)) }}</td>
</tr>
</tbody>
</table>
</div>
<div v-else class="text-xs text-wp-text2">Constraint references a wildcard that no longer exists.</div>
<div class="tr-cn-legend">
<span><span class="tr-dot" style="color: var(--wp-success, #34d399)">●</span> boost</span>
<span><span class="tr-dot">●</span> allow</span>
<span><span class="tr-dot" style="color: var(--wp-warn, #fbbf24)">●</span> reduce</span>
<span><span class="tr-dot" style="color: var(--wp-danger, #f87171)">●</span> exclude</span>
</div>
</template>
</Card>
</div>
<!-- Derivation panel -->
<div v-else-if="result?.type === 'derivation'" class="mt-4 space-y-4" data-test="result-derivation">
<Card>
<template #title>
<div class="tr-card-title-row">
<span>Rule trace — sample {{ traceIndex + 1 }} of {{ result.perSample.length }}</span>
<div class="tr-trace-nav">
<Button size="small" severity="secondary" outlined icon="pi pi-chevron-left" :disabled="traceIndex <= 0" @click="prevSample" />
<Button size="small" severity="secondary" outlined icon="pi pi-chevron-right" :disabled="traceIndex >= result.perSample.length - 1" @click="nextSample" />
</div>
</div>
</template>
<template #content>
<div class="tr-rule-trace">
<div v-for="(t, i) in result.perSample[traceIndex].trace" :key="i" class="tr-rule-row">
<span class="tr-rule-id">rule {{ String(i + 1).padStart(2, '0') }} <code class="text-wp-text3">{{ t.ruleId }}</code></span>
<span class="tr-rule-status" :data-status="t.fired">{{ t.fired === 'skip' ? 'skipped' : t.fired === 'else' ? 'else fired' : `branch ${ (t.branchIndex ?? 0) + 1 } fired` }}</span>
<div class="tr-rule-delta">
<template v-for="(v, k) in t.delta" :key="k">
<code>${{ k }} = "{{ v }}"</code>
</template>
<span v-if="!Object.keys(t.delta).length" class="text-wp-text3">(no change)</span>
</div>
</div>
</div>
</template>
</Card>
<Card>
<template #title>Per-rule fire rate — {{ result.samples }} samples</template>
<template #content>
<div class="tr-hist">
<div v-for="rule in result.rules" :key="rule.id" class="tr-hist-row">
<div class="tr-hist-template">
<code>{{ rule.id }}</code>
<div class="text-wp-text3 text-xs">
branch {{ result.fireCounts[rule.id]?.branch ?? 0 }} · else {{ result.fireCounts[rule.id]?.else ?? 0 }} · skip {{ result.fireCounts[rule.id]?.skip ?? 0 }}
</div>
</div>
<div class="tr-hist-bar">
<div
class="tr-hist-bar-fill"
:style="{ width: ((((result.fireCounts[rule.id]?.branch ?? 0) + (result.fireCounts[rule.id]?.else ?? 0)) / Math.max(1, result.samples)) * 100) + '%' }"
/>
</div>
<div class="tr-hist-count">
{{ Math.round(((((result.fireCounts[rule.id]?.branch ?? 0) + (result.fireCounts[rule.id]?.else ?? 0)) / Math.max(1, result.samples)) * 100)) }}%
</div>
</div>
</div>
</template>
</Card>
</div>
<!-- Pipeline panel -->
<div v-else-if="result?.type === 'pipeline'" class="mt-4 space-y-4" data-test="result-pipeline">
<Card>
<template #title>
<div class="tr-card-title-row">
<span>Pipeline trace — sample {{ traceIndex + 1 }} of {{ result.runs.length }}</span>
<div class="tr-trace-nav">
<Button size="small" severity="secondary" outlined icon="pi pi-chevron-left" :disabled="traceIndex <= 0" @click="prevSample" />
<Button size="small" severity="secondary" outlined icon="pi pi-chevron-right" :disabled="traceIndex >= result.runs.length - 1" @click="nextSample" />
</div>
</div>
</template>
<template #content>
<div class="tr-step-trace">
<div v-for="(s, i) in result.runs[traceIndex].steps" :key="i" class="tr-step-row" :data-kind="s.kind">
<span class="tr-step-idx">{{ String(i + 1).padStart(2, '0') }}</span>
<span class="tr-step-kind">{{ s.kind }}</span>
<span class="tr-step-name">{{ s.name }}</span>
<span class="tr-step-note">{{ s.note }}</span>
</div>
</div>
</template>
</Card>
<Card>
<template #title>Resolved context (final)</template>
<template #content>
<div class="tr-fixed-grid">
<template v-for="(v, k) in result.runs[traceIndex].ctx" :key="k">
<code class="tr-var">${{ k }}</code>
<span class="tr-mono tr-truncate">{{ v }}</span>
</template>
</div>
</template>
</Card>
<Card>
<template #title>
Output distribution — {{ result.samples }} run(s) · {{ result.finalCounts.length }} unique
</template>
<template #content>
<div class="tr-hist">
<div v-for="entry in result.finalCounts" :key="entry.value" class="tr-hist-row">
<div class="tr-hist-template tr-mono tr-truncate">{{ entry.value || '(empty)' }}</div>
<div class="tr-hist-bar"><div class="tr-hist-bar-fill" :style="{ width: entry.pct + '%' }" /></div>
<div class="tr-hist-count">{{ entry.count }}</div>
</div>
</div>
</template>
</Card>
</div>
</div>
</template>
<style scoped>
.tr-config { display: flex; flex-direction: column; gap: 12px; }
.tr-row { display: flex; flex-direction: column; gap: 6px; }
.tr-row--inputs {
display: grid;
grid-template-columns: minmax(220px, 1fr) 140px auto;
gap: 12px;
align-items: end;
}
.tr-row--inputs .tr-input--run { align-self: end; }
.tr-label {
font-size: 11px; text-transform: uppercase;
letter-spacing: 0.5px; color: var(--wp-text2);
}
.tr-kind-opt { display: inline-flex; align-items: center; gap: 6px; }
.tr-hint {
font-size: 11.5px; color: var(--wp-text2);
display: inline-flex; align-items: center; gap: 6px; flex-wrap: wrap;
margin-top: 4px;
}
.tr-hint__var code {
font-family: var(--wp-font-mono, ui-monospace, monospace);
color: var(--wp-accent-text, #c4b5fd);
}
.tr-card-title { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.tr-card-title-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.tr-trace-nav { display: flex; gap: 4px; }
.tr-flags {
font-size: 11.5px; color: var(--wp-text2);
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
margin-bottom: 10px;
}
.tr-flag-chip {
padding: 1px 6px; border-radius: 4px;
background: var(--wp-bg2);
font-family: var(--wp-font-mono, ui-monospace, monospace);
font-size: 11px;
}
.tr-hist { display: flex; flex-direction: column; gap: 6px; }
.tr-hist-row {
display: grid;
grid-template-columns: minmax(180px, 280px) 1fr 50px;
gap: 10px;
align-items: center;
}
.tr-hist-template { min-width: 0; font-size: 12px; }
.tr-hist-resolved { margin-top: 2px; font-size: 11px; color: var(--wp-text3); line-height: 1.45; }
.tr-hist-resolved-line { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tr-hist-bar {
height: 14px;
background: var(--wp-bg3);
border-radius: 5px;
overflow: hidden;
}
.tr-hist-bar-fill {
height: 100%;
background: linear-gradient(90deg, var(--wp-accent-500, #8b5cf6), color-mix(in oklab, var(--wp-accent-500, #8b5cf6) 60%, white));
border-radius: 5px;
transition: width .25s;
}
.tr-hist-count {
text-align: right;
font-family: var(--wp-font-mono, ui-monospace, monospace);
font-size: 12px;
color: var(--wp-text2);
}
.tr-fixed-grid {
display: grid;
grid-template-columns: 200px 1fr;
gap: 6px 14px;
font-size: 12.5px;
align-items: center;
}
.tr-var {
font-family: var(--wp-font-mono, ui-monospace, monospace);
color: var(--wp-accent-text, #c4b5fd);
font-size: 12px;
}
.tr-mono {
font-family: var(--wp-font-mono, ui-monospace, monospace);
font-size: 12px;
min-width: 0;
}
.tr-truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tr-combine-list { display: flex; flex-direction: column; gap: 6px; }
.tr-combine-row {
padding: 8px 10px;
background: var(--wp-bg2);
border: 1px solid var(--wp-border);
border-radius: 6px;
}
.tr-cn-scroll { overflow-x: auto; }
.tr-cn-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.tr-cn-table th {
padding: 6px 8px;
color: var(--wp-text2);
font-weight: 500;
border-bottom: 1px solid var(--wp-border);
white-space: nowrap;
}
.tr-cn-table td {
padding: 6px 8px;
border-bottom: 1px solid var(--wp-border);
}
.tr-cn-legend {
display: flex; gap: 14px; margin-top: 12px;
font-size: 11px; color: var(--wp-text2);
}
.tr-dot { color: var(--wp-text); }
.tr-rule-trace { display: flex; flex-direction: column; gap: 6px; }
.tr-rule-row {
display: grid;
grid-template-columns: 220px auto 1fr;
gap: 10px;
align-items: center;
padding: 6px 10px;
background: var(--wp-bg2);
border: 1px solid var(--wp-border);
border-radius: 6px;
font-size: 12px;
}
.tr-rule-id { font-family: var(--wp-font-mono, ui-monospace, monospace); color: var(--wp-text); }
.tr-rule-status {
font-size: 11px; padding: 2px 8px; border-radius: 4px;
background: var(--wp-bg3); color: var(--wp-text2);
}
.tr-rule-status[data-status="branch"] { background: color-mix(in oklab, var(--wp-kind-derivation, #fbbf24) 18%, transparent); color: var(--wp-kind-derivation, #fbbf24); }
.tr-rule-status[data-status="else"] { background: color-mix(in oklab, var(--wp-info, #60a5fa) 18%, transparent); color: var(--wp-info, #60a5fa); }
.tr-rule-delta {
display: flex; flex-wrap: wrap; gap: 6px;
font-family: var(--wp-font-mono, ui-monospace, monospace);
font-size: 11.5px;
min-width: 0;
}
.tr-rule-delta code {
background: var(--wp-bg3);
padding: 1px 6px; border-radius: 4px;
}
.tr-step-trace { display: flex; flex-direction: column; gap: 4px; }
.tr-step-row {
display: grid;
grid-template-columns: 32px 110px 180px 1fr;
gap: 10px;
padding: 6px 8px;
border-radius: 5px;
background: var(--wp-bg2);
border-left: 3px solid var(--step-color, var(--wp-border));
align-items: center;
}
.tr-step-row[data-kind="wildcard"] { --step-color: var(--wp-kind-wildcard); }
.tr-step-row[data-kind="fixed_values"] { --step-color: var(--wp-kind-fixed); }
.tr-step-row[data-kind="combine"] { --step-color: var(--wp-kind-combine); }
.tr-step-row[data-kind="derivation"] { --step-color: var(--wp-kind-derivation); }
.tr-step-row[data-kind="constraint"] { --step-color: var(--wp-kind-constraint); }
.tr-step-row[data-kind="pipeline"] { --step-color: var(--wp-kind-pipeline); }
.tr-step-idx {
font-family: var(--wp-font-mono, ui-monospace, monospace);
font-size: 11px; color: var(--wp-text3);
}
.tr-step-kind {
font-family: var(--wp-font-mono, ui-monospace, monospace);
font-size: 11px;
color: var(--step-color, var(--wp-text2));
}
.tr-step-name { font-size: 12.5px; color: var(--wp-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tr-step-note {
font-family: var(--wp-font-mono, ui-monospace, monospace);
font-size: 11.5px; color: var(--wp-text2);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
@media (max-width: 720px) {
.tr-row--inputs { grid-template-columns: 1fr; }
.tr-rule-row { grid-template-columns: 1fr; }
.tr-step-row { grid-template-columns: 28px 90px 1fr; }
.tr-step-row > .tr-step-note { grid-column: 1 / -1; padding-left: 130px; }
}
</style>
+2 -2
View File
@@ -7,8 +7,8 @@
<link rel="icon" type="image/svg+xml" href="/wp/images/favicon.svg" />
<link rel="apple-touch-icon" href="/wp/images/apple-touch-icon.png" />
<link rel="manifest" href="/wp/images/site.webmanifest" />
<script type="module" crossorigin src="/wp/assets/manager-5RQ_dRj0.js"></script>
<link rel="stylesheet" crossorigin href="/wp/assets/manager-DSYIIUG3.css">
<script type="module" crossorigin src="/wp/assets/manager-BLJAO7d9.js"></script>
<link rel="stylesheet" crossorigin href="/wp/assets/manager-B7C8brQ3.css">
</head>
<body>
<div id="app"></div>