feat(expr): client-side TemplateEngine port with parity tests

This commit is contained in:
Claude
2026-08-30 10:44:28 +00:00
parent 1e5fd42619
commit 3fde792c53
2 changed files with 206 additions and 0 deletions

View File

@@ -0,0 +1,75 @@
import { describe, it, expect } from "vitest";
import { render } from "./templateEngine";
const J = (json: string) => JSON.parse(json);
describe("templateEngine.render — parity with VolcanicCore.Core.TemplateEngine", () => {
it("no placeholders returns the template unchanged", () => {
expect(render("hello world", J(`{"x":1}`))).toBe("hello world");
});
it("whole input renders as compact json", () => {
expect(render("got: {input}", J(`{"a":1,"b":"x"}`))).toBe(`got: {"a":1,"b":"x"}`);
});
it("top-level string field renders without json quotes", () => {
expect(render("value: {input.test}", J(`{"test":"hello"}`))).toBe("value: hello");
});
it("nested path lookup", () => {
expect(render("{input.a.b.c}", J(`{"a":{"b":{"c":42}}}`))).toBe("42");
});
it("array index lookup", () => {
expect(render("{input.tags[1]}", J(`{"tags":["x","y","z"]}`))).toBe("y");
});
it("missing property renders as empty string", () => {
expect(render("value: {input.missing}", J(`{"other":1}`))).toBe("value: ");
});
it("missing nested path renders as empty string", () => {
expect(render("[{input.a.b.c}]", J(`{"a":{"b":{}}}`))).toBe("[]");
});
it("array index out of range renders as empty string", () => {
expect(render("<{input.tags[9]}>", J(`{"tags":["only"]}`))).toBe("<>");
});
it("numeric and boolean and null leaves render as their string form", () => {
expect(render("{input.n}|{input.f}|{input.b}|{input.z}", J(`{"n":42,"f":1.5,"b":true,"z":null}`)))
.toBe("42|1.5|true|");
});
it("object and array leaves render as compact json", () => {
expect(render("{input.o}/{input.a}", J(`{"o":{"k":1},"a":[1,2]}`))).toBe(`{"k":1}/[1,2]`);
});
it("doubled braces render as literal single braces", () => {
expect(render("{{not a template}}", J(`{}`))).toBe("{not a template}");
});
it("doubled braces around a real placeholder", () => {
expect(render("{{{input.x}}}", J(`{"x":"hello"}`))).toBe("{hello}");
});
it("null input renders placeholders as empty string", () => {
expect(render("hi {input.name}", null)).toBe("hi ");
});
it("whole input placeholder with null input renders empty", () => {
expect(render("{input}", null)).toBe("");
});
it("unrecognised placeholder is left as literal text", () => {
expect(render("{node.foo}", J(`{"foo":1}`))).toBe("{node.foo}");
});
it("unclosed brace is left as literal text", () => {
expect(render("start {input.x and more", J(`{"x":1}`))).toBe("start {input.x and more");
});
it("bracketed string key lookup", () => {
expect(render(`{input["weird.key"]}`, J(`{"weird.key":"v"}`))).toBe("v");
});
});

View File

@@ -0,0 +1,131 @@
// Client-side port of VolcanicCore.Core.TemplateEngine.Render. MUST stay
// byte-exact with the backend — the dialog preview shows the user what the
// executor will produce. Divergence is a bug. Backend tests live in
// tests/VolcanicCore.Tests/TemplateEngineTests.cs; add a matching case here
// when adding one there.
export function render(template: string, input: unknown): string {
if (!template || template.indexOf("{") < 0) return template;
let out = "";
let i = 0;
while (i < template.length) {
const c = template[i];
if (c === "{") {
if (template[i + 1] === "{") {
out += "{";
i += 2;
continue;
}
const end = template.indexOf("}", i + 1);
if (end < 0) {
out += template.slice(i);
break;
}
const expr = template.slice(i + 1, end);
const evald = tryEvaluate(expr, input);
if (evald === undefined) {
out += "{" + expr + "}";
} else {
out += evald;
}
i = end + 1;
continue;
}
if (c === "}" && template[i + 1] === "}") {
out += "}";
i += 2;
continue;
}
out += c;
i++;
}
return out;
}
// Returns undefined if the expression is not one we own (so the caller
// passes it through verbatim), or a string (possibly empty) if it is.
function tryEvaluate(expr: string, input: unknown): string | undefined {
expr = expr.trim();
if (!expr.startsWith("input")) return undefined;
let rest = expr.slice(5);
// input is null/undefined → any well-formed `input`-rooted expression renders empty
if (input === null || input === undefined) {
if (rest.length === 0) return "";
return isWellFormedTail(rest) ? "" : undefined;
}
let walker: unknown = input;
let have = true;
while (rest.length > 0) {
if (rest[0] === ".") {
let j = 1;
while (j < rest.length && isIdentChar(rest[j])) j++;
if (j === 1) return undefined;
const name = rest.slice(1, j);
if (have && isPlainObject(walker) && name in walker) walker = walker[name];
else have = false;
rest = rest.slice(j);
continue;
}
if (rest[0] === "[") {
const close = rest.indexOf("]");
if (close < 0) return undefined;
const inside = rest.slice(1, close).trim();
if (inside.length >= 2 && inside[0] === `"` && inside[inside.length - 1] === `"`) {
const key = inside.slice(1, -1);
if (have && isPlainObject(walker) && key in walker) walker = walker[key];
else have = false;
} else if (/^-?\d+$/.test(inside)) {
const idx = Number(inside);
if (have && Array.isArray(walker) && idx >= 0 && idx < walker.length) walker = walker[idx];
else have = false;
} else {
return undefined;
}
rest = rest.slice(close + 1);
continue;
}
return undefined;
}
return have ? renderValue(walker) : "";
}
function isWellFormedTail(tail: string): boolean {
let i = 0;
while (i < tail.length) {
if (tail[i] === ".") {
let j = i + 1;
while (j < tail.length && isIdentChar(tail[j])) j++;
if (j === i + 1) return false;
i = j;
} else if (tail[i] === "[") {
const close = tail.slice(i).indexOf("]");
if (close < 0) return false;
i += close + 1;
} else {
return false;
}
}
return true;
}
function renderValue(v: unknown): string {
if (v === null || v === undefined) return "";
if (typeof v === "string") return v;
if (typeof v === "number" || typeof v === "boolean") return String(v);
return JSON.stringify(v);
}
function isIdentChar(c: string): boolean {
return /[A-Za-z0-9_]/.test(c);
}
function isPlainObject(v: unknown): v is Record<string, unknown> {
return typeof v === "object" && v !== null && !Array.isArray(v);
}