Initial Commit
This commit is contained in:
120
packages/foundation/data/templates/tests/dep-graph.test.ts
Normal file
120
packages/foundation/data/templates/tests/dep-graph.test.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Dependency-graph smoke test.
|
||||
*
|
||||
* Verifies that the package can be loaded and that the core exports resolve
|
||||
* to the expected types without import errors. This acts as a runtime
|
||||
* canary for tree-shaking and circular-dependency regressions.
|
||||
*/
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
describe("dep-graph", () => {
|
||||
it("imports TemplateEngine without errors", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { TemplateEngine } = await import(
|
||||
"../src/engine/TemplateEngine.js"
|
||||
);
|
||||
expect(typeof TemplateEngine).toBe("function");
|
||||
});
|
||||
|
||||
it("imports all error classes", { timeout: 30000 }, async () => {
|
||||
const { TemplateError, PluginError, MaxDepthError } = await import(
|
||||
"../src/errors/template-error.js"
|
||||
);
|
||||
expect(typeof TemplateError).toBe("function");
|
||||
expect(typeof PluginError).toBe("function");
|
||||
expect(typeof MaxDepthError).toBe("function");
|
||||
});
|
||||
|
||||
it("imports all preset factories", { timeout: 30000 }, async () => {
|
||||
const { minimalPreset } = await import("../src/presets/minimal.js");
|
||||
const { standardPreset } = await import("../src/presets/standard.js");
|
||||
const { fullPreset, fullPresetWithLoops } = await import(
|
||||
"../src/presets/full.js"
|
||||
);
|
||||
expect(typeof minimalPreset).toBe("function");
|
||||
expect(typeof standardPreset).toBe("function");
|
||||
expect(typeof fullPreset).toBe("function");
|
||||
expect(typeof fullPresetWithLoops).toBe("function");
|
||||
});
|
||||
|
||||
it("imports all plugin factories", { timeout: 30000 }, async () => {
|
||||
const { createDotNotationPlugin } = await import(
|
||||
"../src/plugins/variables/DotNotationPlugin.js"
|
||||
);
|
||||
const { createArrayIndexPlugin } = await import(
|
||||
"../src/plugins/variables/ArrayIndexPlugin.js"
|
||||
);
|
||||
const { createCaseTransformPlugin } = await import(
|
||||
"../src/plugins/transform/CaseTransformPlugin.js"
|
||||
);
|
||||
const { createRegexPlugin } = await import(
|
||||
"../src/plugins/transform/RegexPlugin.js"
|
||||
);
|
||||
const { createStringSplitPlugin } = await import(
|
||||
"../src/plugins/transform/StringSplitPlugin.js"
|
||||
);
|
||||
const { createCounterPlugin } = await import(
|
||||
"../src/plugins/dynamic/CounterPlugin.js"
|
||||
);
|
||||
const { createDateTimePlugin } = await import(
|
||||
"../src/plugins/dynamic/DateTimePlugin.js"
|
||||
);
|
||||
const { createUuidPlugin } = await import(
|
||||
"../src/plugins/dynamic/UuidPlugin.js"
|
||||
);
|
||||
const { createConditionalPlugin } = await import(
|
||||
"../src/plugins/control/ConditionalPlugin.js"
|
||||
);
|
||||
const { createLoopPlugin } = await import(
|
||||
"../src/plugins/control/LoopPlugin.js"
|
||||
);
|
||||
|
||||
for (const factory of [
|
||||
createDotNotationPlugin,
|
||||
createArrayIndexPlugin,
|
||||
createCaseTransformPlugin,
|
||||
createRegexPlugin,
|
||||
createStringSplitPlugin,
|
||||
createCounterPlugin,
|
||||
createDateTimePlugin,
|
||||
createUuidPlugin,
|
||||
createConditionalPlugin,
|
||||
createLoopPlugin,
|
||||
]) {
|
||||
expect(typeof factory).toBe("function");
|
||||
}
|
||||
});
|
||||
|
||||
it("imports all utility functions", { timeout: 30000 }, async () => {
|
||||
const escapeMod = await import("../src/utils/escape.js");
|
||||
const format = await import("../src/utils/format.js");
|
||||
const nested = await import("../src/utils/nested-value.js");
|
||||
|
||||
expect(typeof escapeMod.escapeRegex).toBe("function");
|
||||
expect(typeof escapeMod.isEscaped).toBe("function");
|
||||
expect(typeof escapeMod.unescapeTemplate).toBe("function");
|
||||
expect(typeof format.pad).toBe("function");
|
||||
expect(typeof format.formatDate).toBe("function");
|
||||
expect(typeof format.generateUuid).toBe("function");
|
||||
expect(typeof format.generateNanoid).toBe("function");
|
||||
expect(typeof nested.getNestedValue).toBe("function");
|
||||
expect(typeof nested.hasNestedPath).toBe("function");
|
||||
expect(typeof nested.setNestedValue).toBe("function");
|
||||
});
|
||||
|
||||
it("SKIP_PLUGIN is a symbol", { timeout: 30000 }, async () => {
|
||||
const { SKIP_PLUGIN } = await import("../src/types/index.js");
|
||||
expect(typeof SKIP_PLUGIN).toBe("symbol");
|
||||
});
|
||||
|
||||
it("PluginPriority enum has correct ordering", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { PluginPriority } = await import("../src/types/index.js");
|
||||
expect(PluginPriority.MINIMAL).toBeLessThan(PluginPriority.LOW);
|
||||
expect(PluginPriority.LOW).toBeLessThan(PluginPriority.MEDIUM);
|
||||
expect(PluginPriority.MEDIUM).toBeLessThan(PluginPriority.HIGH);
|
||||
expect(PluginPriority.HIGH).toBeLessThan(PluginPriority.CRITICAL);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* No-disallowed audit: verify that non-deterministic global APIs
|
||||
* (`Date`, `Math.random`, `crypto.randomUUID`) are only used in the
|
||||
* approved files listed below and never leak into business-logic modules.
|
||||
*
|
||||
* Approved list (files that legitimately need these APIs):
|
||||
* - src/utils/format.ts (generateUuid, generateNanoid, formatDate)
|
||||
* - src/plugins/dynamic/DateTimePlugin.ts (uses Date via clock fallback)
|
||||
* - src/plugins/dynamic/UuidPlugin.ts (delegates to format.ts)
|
||||
* - src/base/suaveplan-error.ts (error timestamp)
|
||||
*/
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
const SRC_ROOT = join(import.meta.dirname ?? __dirname, "../src");
|
||||
|
||||
const APPROVED = new Set([
|
||||
join(SRC_ROOT, "utils/format.ts"),
|
||||
join(SRC_ROOT, "plugins/dynamic/DateTimePlugin.ts"),
|
||||
join(SRC_ROOT, "plugins/dynamic/UuidPlugin.ts"),
|
||||
join(SRC_ROOT, "otel/templates-otel.ts"),
|
||||
]);
|
||||
|
||||
// Patterns that indicate direct usage of non-deterministic globals
|
||||
const DISALLOWED = [
|
||||
/\bDate\.now\b/,
|
||||
/\bnew Date\(\)/,
|
||||
/\bMath\.random\b/,
|
||||
/\bcrypto\.randomUUID\b/,
|
||||
];
|
||||
|
||||
function collectTs(dir: string): string[] {
|
||||
const results: string[] = [];
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
const stat = statSync(full);
|
||||
if (stat.isDirectory()) {
|
||||
results.push(...collectTs(full));
|
||||
} else if (
|
||||
entry.endsWith(".ts") &&
|
||||
!entry.endsWith(".test.ts") &&
|
||||
!entry.endsWith(".bench.ts")
|
||||
) {
|
||||
results.push(full);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
describe("no-disallowed globals in non-approved files", () => {
|
||||
const files = collectTs(SRC_ROOT).filter((f) => !APPROVED.has(f));
|
||||
|
||||
for (const file of files) {
|
||||
it(`${file.replace(SRC_ROOT, "src")} has no direct Date/Math.random usage`, {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const src = readFileSync(file, "utf-8");
|
||||
for (const pattern of DISALLOWED) {
|
||||
expect(
|
||||
pattern.test(src),
|
||||
`Found disallowed pattern ${pattern} in ${file}`
|
||||
).toBe(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
102
packages/foundation/data/templates/tests/presets.test.ts
Normal file
102
packages/foundation/data/templates/tests/presets.test.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Integration tests for all preset bundles.
|
||||
*/
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { createParseState } from "../src/engine/ParseState.js";
|
||||
import { TemplateEngine } from "../src/engine/TemplateEngine.js";
|
||||
import { fullPreset, fullPresetWithLoops } from "../src/presets/full.js";
|
||||
import { minimalPreset } from "../src/presets/minimal.js";
|
||||
import { standardPreset } from "../src/presets/standard.js";
|
||||
|
||||
type Ctx = Record<string, unknown>;
|
||||
|
||||
describe("minimalPreset", () => {
|
||||
it("resolves dot-notation", { timeout: 5000 }, () => {
|
||||
const e = new TemplateEngine<Ctx>().usePreset(minimalPreset<Ctx>());
|
||||
expect(e.parse("{user.name}", { user: { name: "Alice" } })).toBe(
|
||||
"Alice"
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves array index", { timeout: 5000 }, () => {
|
||||
const e = new TemplateEngine<Ctx>().usePreset(minimalPreset<Ctx>());
|
||||
expect(e.parse("{items[1]}", { items: ["a", "b"] })).toBe("b");
|
||||
});
|
||||
|
||||
it("has 2 plugins", { timeout: 5000 }, () => {
|
||||
const e = new TemplateEngine<Ctx>().usePreset(minimalPreset<Ctx>());
|
||||
expect(e.getPlugins()).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("standardPreset", () => {
|
||||
it("resolves variables, counter, case transform, conditional", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const e = new TemplateEngine<Ctx>().usePreset(standardPreset<Ctx>());
|
||||
const result = e.parse(
|
||||
"{if:active}Hello {name:title}! #{_counter}{/if}",
|
||||
{ active: true, name: "john doe" }
|
||||
);
|
||||
expect(result).toBe("Hello John Doe! #1");
|
||||
});
|
||||
|
||||
it("generates UUID in standard preset", { timeout: 5000 }, () => {
|
||||
const e = new TemplateEngine<Ctx>().usePreset(standardPreset<Ctx>());
|
||||
const result = e.parse("{_uuid}", {});
|
||||
expect(result).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fullPreset", () => {
|
||||
it("includes regex and string-split", { timeout: 5000 }, () => {
|
||||
const e = new TemplateEngine<Ctx>().usePreset(fullPreset<Ctx>());
|
||||
expect(
|
||||
e.parse("{phone:/\\D/g:replace:}", { phone: "(123) 456-7890" })
|
||||
).toBe("1234567890");
|
||||
expect(e.parse("{email:@[0]}", { email: "user@example.com" })).toBe(
|
||||
"user"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fullPresetWithLoops", () => {
|
||||
it("iterates array with loop plugin", { timeout: 5000 }, () => {
|
||||
const e = new TemplateEngine<Ctx>();
|
||||
const preset = fullPresetWithLoops<Ctx>((tpl, ctx, state) =>
|
||||
(
|
||||
e as unknown as {
|
||||
parseInternal: (
|
||||
t: string,
|
||||
c: Ctx,
|
||||
s: ReturnType<typeof createParseState>
|
||||
) => string;
|
||||
}
|
||||
).parseInternal(tpl, ctx, state)
|
||||
);
|
||||
e.usePreset(preset);
|
||||
const result = e.parse("{each:items}{item} {/each}", {
|
||||
items: ["X", "Y", "Z"],
|
||||
});
|
||||
expect(result).toBe("X Y Z ");
|
||||
});
|
||||
|
||||
it("preset name is full-with-loops", { timeout: 5000 }, () => {
|
||||
const preset = fullPresetWithLoops<Ctx>((t, c) => t);
|
||||
expect(preset.name).toBe("full-with-loops");
|
||||
});
|
||||
});
|
||||
|
||||
describe("preset registration idempotency", () => {
|
||||
it("applying same preset twice does not duplicate plugins", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const e = new TemplateEngine<Ctx>();
|
||||
e.usePreset(minimalPreset<Ctx>());
|
||||
const countAfterFirst = e.getPlugins().length;
|
||||
e.usePreset(minimalPreset<Ctx>());
|
||||
expect(e.getPlugins()).toHaveLength(countAfterFirst);
|
||||
});
|
||||
});
|
||||
107
packages/foundation/data/templates/tests/property.test.ts
Normal file
107
packages/foundation/data/templates/tests/property.test.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Property-based tests using fast-check.
|
||||
*
|
||||
* Covers:
|
||||
* - Idempotent rendering: re-parsing an already-rendered literal produces same output.
|
||||
* - Counter monotone: named counters increase within a single parse call.
|
||||
* - getNestedValue / setNestedValue round-trip.
|
||||
* - PluginPriority strict ordering.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import * as fc from "fast-check";
|
||||
import { TemplateEngine } from "../src/engine/TemplateEngine.js";
|
||||
import { createCounterPlugin } from "../src/plugins/dynamic/CounterPlugin.js";
|
||||
import { createDotNotationPlugin } from "../src/plugins/variables/DotNotationPlugin.js";
|
||||
import { PluginPriority } from "../src/types/index.js";
|
||||
import { getNestedValue, setNestedValue } from "../src/utils/nested-value.js";
|
||||
|
||||
describe("idempotent literal rendering", () => {
|
||||
it("re-parsing a rendered literal string returns the same string", {
|
||||
timeout: 30000,
|
||||
}, () => {
|
||||
const e = new TemplateEngine().use(createDotNotationPlugin());
|
||||
|
||||
fc.assert(
|
||||
fc.property(
|
||||
// Strings with no { or } — guaranteed literals after render
|
||||
fc.string({
|
||||
unit: fc.char().filter((c) => c !== "{" && c !== "}"),
|
||||
}),
|
||||
(literal) => {
|
||||
const first = e.parse(literal, {});
|
||||
const second = e.parse(first, {});
|
||||
expect(second).toBe(first);
|
||||
}
|
||||
),
|
||||
{ numRuns: 200 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("counter monotone within single parse", () => {
|
||||
it("counter values strictly increase left-to-right in one parse call", {
|
||||
timeout: 30000,
|
||||
}, () => {
|
||||
fc.assert(
|
||||
fc.property(fc.integer({ min: 2, max: 10 }), (count) => {
|
||||
const e = new TemplateEngine().use(createCounterPlugin());
|
||||
// Build a template with `count` counter tokens
|
||||
const template = Array.from(
|
||||
{ length: count },
|
||||
() => "{_counter}"
|
||||
).join(" ");
|
||||
const result = e.parse(template, {});
|
||||
const values = result
|
||||
.split(" ")
|
||||
.map((v) => Number.parseInt(v, 10));
|
||||
for (let i = 1; i < values.length; i++) {
|
||||
expect(values[i]).toBeGreaterThan(values[i - 1] as number);
|
||||
}
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getNestedValue / setNestedValue round-trip", () => {
|
||||
it("reading back after set returns the same primitive", {
|
||||
timeout: 30000,
|
||||
}, () => {
|
||||
// Safe path: only lowercase letters and digits, no array notation
|
||||
const safePath = fc
|
||||
.array(fc.stringMatching(/^[a-z][a-z0-9]{0,5}$/), {
|
||||
minLength: 1,
|
||||
maxLength: 4,
|
||||
})
|
||||
.map((parts) => parts.join("."));
|
||||
|
||||
fc.assert(
|
||||
fc.property(
|
||||
safePath,
|
||||
fc.oneof(
|
||||
fc.integer(),
|
||||
fc.string(),
|
||||
fc.boolean()
|
||||
// Note: null excluded because getNestedValue returns undefined for null values
|
||||
),
|
||||
(path, value) => {
|
||||
const obj: Record<string, unknown> = {};
|
||||
setNestedValue(obj, path, value);
|
||||
const retrieved = getNestedValue(obj, path);
|
||||
expect(retrieved).toBe(value);
|
||||
}
|
||||
),
|
||||
{ numRuns: 300 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PluginPriority strict ordering", () => {
|
||||
it("MINIMAL < LOW < MEDIUM < HIGH < CRITICAL", { timeout: 5000 }, () => {
|
||||
expect(PluginPriority.MINIMAL).toBeLessThan(PluginPriority.LOW);
|
||||
expect(PluginPriority.LOW).toBeLessThan(PluginPriority.MEDIUM);
|
||||
expect(PluginPriority.MEDIUM).toBeLessThan(PluginPriority.HIGH);
|
||||
expect(PluginPriority.HIGH).toBeLessThan(PluginPriority.CRITICAL);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
// Ensure `globalThis.crypto` is available in bare-Node vitest runs.
|
||||
import { webcrypto } from "node:crypto";
|
||||
|
||||
type WritableGlobal = { crypto?: Crypto };
|
||||
const g = globalThis as WritableGlobal;
|
||||
if (g.crypto === undefined) {
|
||||
g.crypto = webcrypto as unknown as Crypto;
|
||||
}
|
||||
69
packages/foundation/data/templates/tests/tree-shake.test.ts
Normal file
69
packages/foundation/data/templates/tests/tree-shake.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Tree-shake smoke test.
|
||||
*
|
||||
* Verifies that each sub-module can be imported in isolation without pulling
|
||||
* in unrelated code. This acts as a proxy for correct `sideEffects: false`
|
||||
* behaviour and clean ESM boundaries.
|
||||
*/
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
describe("tree-shake isolation", () => {
|
||||
it("can import only escape utils", { timeout: 30000 }, async () => {
|
||||
const mod = await import("../src/utils/escape.js");
|
||||
expect(typeof mod.escapeRegex).toBe("function");
|
||||
expect(typeof mod.isEscaped).toBe("function");
|
||||
expect(typeof mod.unescapeTemplate).toBe("function");
|
||||
});
|
||||
|
||||
it("can import only format utils", { timeout: 30000 }, async () => {
|
||||
const mod = await import("../src/utils/format.js");
|
||||
expect(typeof mod.pad).toBe("function");
|
||||
expect(typeof mod.formatDate).toBe("function");
|
||||
expect(typeof mod.generateUuid).toBe("function");
|
||||
expect(typeof mod.generateNanoid).toBe("function");
|
||||
});
|
||||
|
||||
it("can import only nested-value utils", { timeout: 30000 }, async () => {
|
||||
const mod = await import("../src/utils/nested-value.js");
|
||||
expect(typeof mod.getNestedValue).toBe("function");
|
||||
expect(typeof mod.hasNestedPath).toBe("function");
|
||||
expect(typeof mod.setNestedValue).toBe("function");
|
||||
});
|
||||
|
||||
it("can import ParseState without TemplateEngine", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const mod = await import("../src/engine/ParseState.js");
|
||||
expect(typeof mod.createParseState).toBe("function");
|
||||
expect(typeof mod.cloneParseState).toBe("function");
|
||||
});
|
||||
|
||||
it("can import a single plugin without loading all others", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const mod = await import(
|
||||
"../src/plugins/variables/DotNotationPlugin.js"
|
||||
);
|
||||
expect(typeof mod.createDotNotationPlugin).toBe("function");
|
||||
});
|
||||
|
||||
it("can import schemas in isolation", { timeout: 30000 }, async () => {
|
||||
const mod = await import("../src/schemas/templates.schemas.js");
|
||||
expect(mod.TemplateEngineOptionsSchema).toBeDefined();
|
||||
expect(mod.ParseOptionsSchema).toBeDefined();
|
||||
expect(mod.PluginPrioritySchema).toBeDefined();
|
||||
});
|
||||
|
||||
it("minimalPreset does not import dynamic plugins", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
// If minimalPreset somehow pulled in DateTimePlugin the import would
|
||||
// still succeed, but this test documents the intended isolation.
|
||||
const { minimalPreset } = await import("../src/presets/minimal.js");
|
||||
const preset = minimalPreset();
|
||||
expect(preset.plugins.map((p) => p.name)).toEqual(
|
||||
expect.arrayContaining(["array-index", "dot-notation"])
|
||||
);
|
||||
expect(preset.plugins).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user