271 lines
8.5 KiB
TypeScript
271 lines
8.5 KiB
TypeScript
import { describe, expect, it } from "@suaveplan/testing/runner";
|
|
import { and, condition, or } from "../builder/index.js";
|
|
import type { Rule } from "../types/index.js";
|
|
import {
|
|
analyzeRule,
|
|
countRules,
|
|
estimateCost,
|
|
findFields,
|
|
hasAsyncComponents,
|
|
isOptimisationStable,
|
|
optimizeRule,
|
|
} from "./optimizer.js";
|
|
|
|
describe("optimizeRule — flatten", () => {
|
|
it("flattens nested AND of AND", { timeout: 5000 }, () => {
|
|
const r = and(
|
|
and(condition("a", "equals", 1), condition("b", "equals", 2)),
|
|
condition("c", "equals", 3)
|
|
);
|
|
const opt = optimizeRule(r);
|
|
const rules = (opt.rule as { rules: readonly Rule[] }).rules;
|
|
expect(rules.length).toBe(3);
|
|
});
|
|
|
|
it("flattens nested OR of OR", { timeout: 5000 }, () => {
|
|
const r = or(
|
|
or(condition("a", "equals", 1), condition("b", "equals", 2)),
|
|
condition("c", "equals", 3)
|
|
);
|
|
const opt = optimizeRule(r);
|
|
expect((opt.rule as { rules: readonly Rule[] }).rules.length).toBe(3);
|
|
});
|
|
|
|
it("does not flatten AND of OR", { timeout: 5000 }, () => {
|
|
const r = and(
|
|
or(condition("a", "equals", 1), condition("b", "equals", 2)),
|
|
condition("c", "equals", 3)
|
|
);
|
|
const opt = optimizeRule(r, {
|
|
dedupe: false,
|
|
simplify: false,
|
|
sort: false,
|
|
});
|
|
expect((opt.rule as { rules: readonly Rule[] }).rules.length).toBe(2);
|
|
});
|
|
|
|
it("flatten can be disabled", { timeout: 5000 }, () => {
|
|
const r = and(
|
|
and(condition("a", "equals", 1), condition("b", "equals", 2)),
|
|
condition("c", "equals", 3)
|
|
);
|
|
const opt = optimizeRule(r, {
|
|
flatten: false,
|
|
dedupe: false,
|
|
simplify: false,
|
|
sort: false,
|
|
});
|
|
expect((opt.rule as { rules: readonly Rule[] }).rules.length).toBe(2);
|
|
});
|
|
});
|
|
|
|
describe("optimizeRule — dedupe", () => {
|
|
it("removes structural duplicates", { timeout: 5000 }, () => {
|
|
const c = condition("a", "equals", 1);
|
|
const r = and(c, c, condition("b", "equals", 2));
|
|
const opt = optimizeRule(r, { sort: false, simplify: false });
|
|
expect((opt.rule as { rules: readonly Rule[] }).rules.length).toBe(2);
|
|
});
|
|
|
|
it("dedupe recurses into groups", { timeout: 5000 }, () => {
|
|
const group: Rule = {
|
|
type: "group",
|
|
name: "g",
|
|
rules: [condition("a", "equals", 1), condition("a", "equals", 1)],
|
|
};
|
|
const opt = optimizeRule(group);
|
|
expect((opt.rule as { rules: readonly Rule[] }).rules.length).toBe(1);
|
|
});
|
|
|
|
it("dedupe can be disabled", { timeout: 5000 }, () => {
|
|
const c = condition("a", "equals", 1);
|
|
const r = and(c, c);
|
|
const opt = optimizeRule(r, { dedupe: false, simplify: false });
|
|
expect((opt.rule as { rules: readonly Rule[] }).rules.length).toBe(2);
|
|
});
|
|
});
|
|
|
|
describe("optimizeRule — simplify", () => {
|
|
it("unwraps single-child AND/OR", { timeout: 5000 }, () => {
|
|
const r: Rule = {
|
|
type: "logical",
|
|
operator: "and",
|
|
rules: [condition("a", "equals", 1)],
|
|
};
|
|
const opt = optimizeRule(r, {
|
|
flatten: false,
|
|
dedupe: false,
|
|
sort: false,
|
|
});
|
|
expect(opt.rule.type).toBe("condition");
|
|
});
|
|
|
|
it("preserves NOT (single-child by design)", { timeout: 5000 }, () => {
|
|
const r: Rule = {
|
|
type: "logical",
|
|
operator: "not",
|
|
rules: [condition("a", "equals", 1)],
|
|
};
|
|
const opt = optimizeRule(r);
|
|
expect(opt.rule.type).toBe("logical");
|
|
expect((opt.rule as { operator: string }).operator).toBe("not");
|
|
});
|
|
|
|
it("simplify can be disabled", { timeout: 5000 }, () => {
|
|
const r: Rule = {
|
|
type: "logical",
|
|
operator: "and",
|
|
rules: [condition("a", "equals", 1)],
|
|
};
|
|
const opt = optimizeRule(r, {
|
|
simplify: false,
|
|
flatten: false,
|
|
dedupe: false,
|
|
sort: false,
|
|
});
|
|
expect(opt.rule.type).toBe("logical");
|
|
});
|
|
});
|
|
|
|
describe("optimizeRule — sort", () => {
|
|
it("sorts AND children by ascending cost", { timeout: 5000 }, () => {
|
|
const r = and(
|
|
condition("a", "matches", "^x$"),
|
|
condition("b", "equals", 1)
|
|
);
|
|
const opt = optimizeRule(r, {
|
|
flatten: false,
|
|
dedupe: false,
|
|
simplify: false,
|
|
});
|
|
const rules = (opt.rule as { rules: readonly Rule[] }).rules;
|
|
expect((rules[0] as { operator: string }).operator).toBe("equals");
|
|
});
|
|
|
|
it("does not sort NOT or group implicitly", { timeout: 5000 }, () => {
|
|
const r: Rule = {
|
|
type: "logical",
|
|
operator: "not",
|
|
rules: [condition("a", "equals", 1)],
|
|
};
|
|
const opt = optimizeRule(r);
|
|
expect(opt.rule.type).toBe("logical");
|
|
});
|
|
|
|
it("sort can be disabled", { timeout: 5000 }, () => {
|
|
const r = and(
|
|
condition("a", "matches", "^x$"),
|
|
condition("b", "equals", 1)
|
|
);
|
|
const opt = optimizeRule(r, {
|
|
sort: false,
|
|
dedupe: false,
|
|
simplify: false,
|
|
flatten: false,
|
|
});
|
|
expect((opt.rule as { rules: readonly Rule[] }).rules[0]?.type).toBe(
|
|
"condition"
|
|
);
|
|
});
|
|
|
|
it("group children pass through sort", { timeout: 5000 }, () => {
|
|
const r: Rule = {
|
|
type: "group",
|
|
name: "g",
|
|
rules: [
|
|
condition("a", "matches", "^x$"),
|
|
condition("b", "equals", 1),
|
|
],
|
|
};
|
|
const opt = optimizeRule(r);
|
|
expect(opt.rule.type).toBe("group");
|
|
});
|
|
});
|
|
|
|
describe("analyzeRule", () => {
|
|
it("counts total + depth + fields", { timeout: 5000 }, () => {
|
|
const r = and(
|
|
condition("a", "equals", 1),
|
|
or(condition("b", "equals", 2), condition("c", "equals", 3))
|
|
);
|
|
const stats = analyzeRule(r);
|
|
expect(stats.totalRules).toBe(5);
|
|
expect(stats.maxDepth).toBe(2);
|
|
expect(stats.fields).toEqual(expect.arrayContaining(["a", "b", "c"]));
|
|
});
|
|
|
|
it("detects async via custom/computed", { timeout: 5000 }, () => {
|
|
const custom: Rule = {
|
|
type: "custom",
|
|
name: "c",
|
|
validate: async () => true,
|
|
};
|
|
expect(analyzeRule(custom).hasAsync).toBe(true);
|
|
const computed: Rule = {
|
|
type: "computed",
|
|
field: "x",
|
|
operator: "equals",
|
|
compute: () => 1,
|
|
};
|
|
expect(analyzeRule(computed).hasAsync).toBe(true);
|
|
});
|
|
|
|
it("hasAsyncComponents alias", { timeout: 5000 }, () => {
|
|
expect(hasAsyncComponents(condition("a", "equals", 1))).toBe(false);
|
|
expect(
|
|
hasAsyncComponents({
|
|
type: "custom",
|
|
name: "c",
|
|
validate: () => true,
|
|
})
|
|
).toBe(true);
|
|
});
|
|
|
|
it("countRules / findFields / estimateCost helpers", {
|
|
timeout: 5000,
|
|
}, () => {
|
|
const r = and(
|
|
condition("a", "equals", 1),
|
|
condition("b", "matches", "^x$")
|
|
);
|
|
expect(countRules(r)).toBe(3);
|
|
expect(findFields(r)).toEqual(expect.arrayContaining(["a", "b"]));
|
|
expect(estimateCost(r)).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("isOptimisationStable returns true when no changes", {
|
|
timeout: 5000,
|
|
}, () => {
|
|
const r = condition("a", "equals", 1);
|
|
expect(isOptimisationStable(r)).toBe(true);
|
|
});
|
|
|
|
it("isOptimisationStable returns false when optimisations apply", {
|
|
timeout: 5000,
|
|
}, () => {
|
|
const r = and(
|
|
and(condition("a", "equals", 1), condition("b", "equals", 2))
|
|
);
|
|
expect(isOptimisationStable(r)).toBe(false);
|
|
});
|
|
|
|
it("walks group children", { timeout: 5000 }, () => {
|
|
const r: Rule = {
|
|
type: "group",
|
|
name: "g",
|
|
rules: [condition("a", "equals", 1), condition("b", "equals", 2)],
|
|
};
|
|
expect(analyzeRule(r).totalRules).toBe(3);
|
|
});
|
|
});
|
|
|
|
describe("optimizeRule — result", () => {
|
|
it("reports before + after stats", { timeout: 5000 }, () => {
|
|
const r = and(
|
|
and(condition("a", "equals", 1), condition("b", "equals", 2))
|
|
);
|
|
const opt = optimizeRule(r);
|
|
expect(opt.before.totalRules).toBeGreaterThan(opt.after.totalRules);
|
|
});
|
|
});
|