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