108 lines
4.0 KiB
TypeScript
108 lines
4.0 KiB
TypeScript
/**
|
|
* 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);
|
|
});
|
|
});
|