/** * TASK-0111 — condition evaluator decision matrix. * * Pure-function tests: every operator × (string, number, date, boolean, * multi-select, empty, null, type-mismatch) + tree cases (AND/OR short-circuit, * NOT, depth 5 ok / depth 6 rejected, 100 nodes ok / 101 rejected) + * fail-closed paths (missing record, unknown property, corrupt tree, timeout). * No infrastructure — `evaluateCondition` is a pure function. */ import { describe, expect, it } from "vitest"; import { CONDITION_EVAL_BUDGET_MS, ConditionError, evaluateCondition, unwrapEnvelope, type ConditionContext } from "../condition-evaluator.js"; const ctx = (event: Record = {}, record: Record | null = null): ConditionContext => ({ event, record, page: null }); const pred = (ref: "event" | "property", name: string, operator: string, value?: unknown) => ({ field: { ref, name }, operator, ...(value === undefined ? {} : { value }) }); // --------------------------------------------------------------------------- // eq / neq // --------------------------------------------------------------------------- describe("eq / neq", () => { it("string equality (case-sensitive)", () => { expect(evaluateCondition(pred("property", "Status", "eq", "Done"), ctx({}, { Status: "Done" })).result).toBe(true); expect(evaluateCondition(pred("property", "Status", "eq", "done"), ctx({}, { Status: "Done" })).result).toBe(false); }); it("numeric equality incl. string-number", () => { expect(evaluateCondition(pred("property", "N", "eq", 5), ctx({}, { N: 5 })).result).toBe(true); expect(evaluateCondition(pred("property", "N", "eq", "5"), ctx({}, { N: 5 })).result).toBe(true); expect(evaluateCondition(pred("property", "N", "eq", 4), ctx({}, { N: 5 })).result).toBe(false); }); it("boolean equality", () => { expect(evaluateCondition(pred("property", "Flag", "eq", true), ctx({}, { Flag: true })).result).toBe(true); expect(evaluateCondition(pred("property", "Flag", "eq", false), ctx({}, { Flag: true })).result).toBe(false); }); it("date (ISO string) equality", () => { expect(evaluateCondition(pred("property", "Due", "eq", "2026-09-30"), ctx({}, { Due: "2026-09-30" })).result).toBe(true); expect(evaluateCondition(pred("property", "Due", "eq", "2026-09-31"), ctx({}, { Due: "2026-09-30" })).result).toBe(false); }); it("multi-select: SET equality (order-insensitive), not bag", () => { expect(evaluateCondition(pred("property", "Tags", "eq", ["b", "a"]), ctx({}, { Tags: ["a", "b"] })).result).toBe(true); expect(evaluateCondition(pred("property", "Tags", "eq", ["a"]), ctx({}, { Tags: ["a", "b"] })).result).toBe(false); expect(evaluateCondition(pred("property", "Tags", "eq", ["a", "a"]), ctx({}, { Tags: ["a"] })).result).toBe(false); }); it("null/missing: present null eq null → true; absent → false (fail closed)", () => { expect(evaluateCondition(pred("property", "X", "eq", null), ctx({}, { X: null })).result).toBe(true); expect(evaluateCondition(pred("property", "X", "eq", null), ctx({}, {})).result).toBe(false); }); it("neq is the negation of eq (absent → false, fail closed)", () => { expect(evaluateCondition(pred("property", "X", "neq", "a"), ctx({}, { X: "b" })).result).toBe(true); expect(evaluateCondition(pred("property", "X", "neq", "a"), ctx({}, { X: "a" })).result).toBe(false); expect(evaluateCondition(pred("property", "X", "neq", "a"), ctx({}, {})).result).toBe(false); }); it("type mismatch (number vs non-numeric string) → false", () => { expect(evaluateCondition(pred("property", "N", "eq", "not-a-number"), ctx({}, { N: 5 })).result).toBe(false); }); }); // --------------------------------------------------------------------------- // contains / not_contains / starts_with / ends_with // --------------------------------------------------------------------------- describe("contains / not_contains / starts_with / ends_with", () => { it("contains: string substring, case-insensitive", () => { expect(evaluateCondition(pred("property", "T", "contains", "lo"), ctx({}, { T: "Hello World" })).result).toBe(true); expect(evaluateCondition(pred("property", "T", "contains", "xyz"), ctx({}, { T: "Hello" })).result).toBe(false); }); it("contains: array membership", () => { expect(evaluateCondition(pred("property", "Tags", "contains", "a"), ctx({}, { Tags: ["a", "b"] })).result).toBe(true); expect(evaluateCondition(pred("property", "Tags", "contains", "z"), ctx({}, { Tags: ["a", "b"] })).result).toBe(false); }); it("not_contains: negation", () => { expect(evaluateCondition(pred("property", "T", "not_contains", "xyz"), ctx({}, { T: "Hello" })).result).toBe(true); expect(evaluateCondition(pred("property", "T", "not_contains", "lo"), ctx({}, { T: "Hello" })).result).toBe(false); }); it("starts_with / ends_with: case-insensitive", () => { expect(evaluateCondition(pred("property", "T", "starts_with", "he"), ctx({}, { T: "Hello" })).result).toBe(true); expect(evaluateCondition(pred("property", "T", "starts_with", "world"), ctx({}, { T: "Hello" })).result).toBe(false); expect(evaluateCondition(pred("property", "T", "ends_with", "LLO"), ctx({}, { T: "Hello" })).result).toBe(true); }); it("string ops on non-strings fail closed (false + warning)", () => { const d = evaluateCondition(pred("property", "N", "starts_with", "5"), ctx({}, { N: 5 })); expect(d.result).toBe(false); expect(d.warnings.length).toBeGreaterThan(0); }); it("absent field → false for all four", () => { for (const op of ["contains", "not_contains", "starts_with", "ends_with"]) { const d = evaluateCondition(pred("property", "X", op, "a"), ctx({}, {})); expect(d.result).toBe(false); expect(d.warnings.length).toBeGreaterThan(0); } }); }); // --------------------------------------------------------------------------- // ordering (gt/gte/lt/lte) // --------------------------------------------------------------------------- describe("gt / gte / lt / lte", () => { it("numbers", () => { const c = ctx({}, { N: 10 }); expect(evaluateCondition(pred("property", "N", "gt", 5), c).result).toBe(true); expect(evaluateCondition(pred("property", "N", "gt", 10), c).result).toBe(false); expect(evaluateCondition(pred("property", "N", "gte", 10), c).result).toBe(true); expect(evaluateCondition(pred("property", "N", "lt", 10), c).result).toBe(false); expect(evaluateCondition(pred("property", "N", "lte", 10), c).result).toBe(true); }); it("dates (ISO strings) compare chronologically", () => { const c = ctx({}, { Due: "2026-09-30T00:00:00.000Z" }); expect(evaluateCondition(pred("property", "Due", "gt", "2026-09-01"), c).result).toBe(true); expect(evaluateCondition(pred("property", "Due", "lt", "2026-10-01"), c).result).toBe(true); expect(evaluateCondition(pred("property", "Due", "gt", "2026-10-01"), c).result).toBe(false); }); it("non-comparable (string vs string-date) → false + warn (no crash)", () => { const d = evaluateCondition(pred("property", "T", "gt", "2026-01-01"), ctx({}, { T: "hello" })); expect(d.result).toBe(false); expect(d.warnings.length).toBeGreaterThan(0); }); it("absent field → false + warn", () => { const d = evaluateCondition(pred("property", "X", "gt", 0), ctx({}, {})); expect(d.result).toBe(false); expect(d.warnings.length).toBeGreaterThan(0); }); }); // --------------------------------------------------------------------------- // in / not_in // --------------------------------------------------------------------------- describe("in / not_in", () => { it("scalar membership", () => { const c = ctx({}, { Status: "bug" }); expect(evaluateCondition(pred("property", "Status", "in", ["bug", "feature"]), c).result).toBe(true); expect(evaluateCondition(pred("property", "Status", "in", ["bug"]), c).result).toBe(true); expect(evaluateCondition(pred("property", "Status", "in", ["x", "y"]), c).result).toBe(false); expect(evaluateCondition(pred("property", "Status", "not_in", ["x", "y"]), c).result).toBe(true); expect(evaluateCondition(pred("property", "Status", "not_in", ["bug"]), c).result).toBe(false); }); it("multi-select: in = intersection non-empty", () => { const c = ctx({}, { Tags: ["a", "b"] }); expect(evaluateCondition(pred("property", "Tags", "in", ["z", "b"]), c).result).toBe(true); expect(evaluateCondition(pred("property", "Tags", "in", ["z"]), c).result).toBe(false); expect(evaluateCondition(pred("property", "Tags", "not_in", ["z"]), c).result).toBe(true); expect(evaluateCondition(pred("property", "Tags", "not_in", ["b"]), c).result).toBe(false); }); }); // --------------------------------------------------------------------------- // is_empty / is_not_empty // --------------------------------------------------------------------------- describe("is_empty / is_not_empty", () => { it("null / empty-string / empty-array / empty-object are empty", () => { expect(evaluateCondition(pred("property", "X", "is_empty", undefined), ctx({}, { X: null })).result).toBe(true); expect(evaluateCondition(pred("property", "X", "is_empty", undefined), ctx({}, { X: "" })).result).toBe(true); expect(evaluateCondition(pred("property", "X", "is_empty", undefined), ctx({}, { X: [] })).result).toBe(true); expect(evaluateCondition(pred("property", "X", "is_empty", undefined), ctx({}, { X: {} })).result).toBe(true); }); it("non-empty values", () => { expect(evaluateCondition(pred("property", "X", "is_empty", undefined), ctx({}, { X: "a" })).result).toBe(false); expect(evaluateCondition(pred("property", "X", "is_empty", undefined), ctx({}, { X: 0 })).result).toBe(false); expect(evaluateCondition(pred("property", "X", "is_empty", undefined), ctx({}, { X: false })).result).toBe(false); }); it("absent value: is_empty → true; is_not_empty → false", () => { expect(evaluateCondition(pred("property", "X", "is_empty", undefined), ctx({}, {})).result).toBe(true); expect(evaluateCondition(pred("property", "X", "is_not_empty", undefined), ctx({}, {})).result).toBe(false); }); it("is_not_empty on present values", () => { expect(evaluateCondition(pred("property", "X", "is_not_empty", undefined), ctx({}, { X: "a" })).result).toBe(true); expect(evaluateCondition(pred("property", "X", "is_not_empty", undefined), ctx({}, { X: [] })).result).toBe(false); }); }); // --------------------------------------------------------------------------- // event-ref fields // --------------------------------------------------------------------------- describe("event-ref fields", () => { it("event.type / event.databaseId / event.workspaceId", () => { const c = ctx({ type: "record_created", databaseId: "db1", workspaceId: "w1" }, { N: 1 }); expect(evaluateCondition(pred("event", "type", "eq", "record_created"), c).result).toBe(true); expect(evaluateCondition(pred("event", "databaseId", "eq", "db1"), c).result).toBe(true); expect(evaluateCondition(pred("event", "workspaceId", "neq", "other"), c).result).toBe(true); }); it("event.changedProperties (array) membership", () => { const c = ctx({ changedProperties: ["Priority"] }, {}); expect(evaluateCondition(pred("event", "changedProperties", "contains", "Priority"), c).result).toBe(true); expect(evaluateCondition(pred("event", "changedProperties", "contains", "Status"), c).result).toBe(false); }); it("dot-path into payload (WEBHOOK shape): event.payload.category in […]", () => { const c = ctx({ type: "webhook", payload: { category: "bug" } }, null); expect(evaluateCondition(pred("event", "payload.category", "in", ["bug", "feature"]), c).result).toBe(true); expect(evaluateCondition(pred("event", "payload.category", "in", ["ops"]), c).result).toBe(false); }); it("FORM_SUBMITTED: event.payload.anonymous eq true", () => { const c = ctx({ type: "form_submitted", payload: { anonymous: true } }, null); expect(evaluateCondition(pred("event", "payload.anonymous", "eq", true), c).result).toBe(true); expect(evaluateCondition(pred("event", "payload.anonymous", "eq", false), c).result).toBe(false); }); it("payload.* deeper than 3 segments → undefined → fail closed", () => { const c = ctx({ payload: { a: { b: { c: { d: 1 } } } } }, null); expect(evaluateCondition(pred("event", "payload.a.b.c.d", "eq", 1), c).result).toBe(false); }); it("missing event field → false (fail closed)", () => { const d = evaluateCondition(pred("event", "type", "eq", "x"), ctx({})); expect(d.result).toBe(false); expect(d.warnings.length).toBeGreaterThan(0); }); }); // --------------------------------------------------------------------------- // property fail-closed (no record / unknown property) // --------------------------------------------------------------------------- describe("property fail-closed", () => { it("no record → property.* predicate false (even eq null)", () => { const d = evaluateCondition(pred("property", "Priority", "eq", "High"), ctx({}, null)); expect(d.result).toBe(false); expect(d.warnings.length).toBeGreaterThan(0); }); it("no record: AND with an always-true event predicate → overall false", () => { const tree = { op: "AND", children: [pred("event", "type", "eq", "record_created"), pred("property", "Priority", "eq", "High")] }; expect(evaluateCondition(tree, ctx({ type: "record_created" }, null)).result).toBe(false); }); it("unknown property name → false (no crash)", () => { const d = evaluateCondition(pred("property", "Nope", "eq", "x"), ctx({}, { Status: "Done" })); expect(d.result).toBe(false); expect(d.warnings.length).toBeGreaterThan(0); }); it("no record: is_empty on a property → true (absent value semantics)", () => { expect(evaluateCondition(pred("property", "X", "is_empty", undefined), ctx({}, null)).result).toBe(true); }); }); // --------------------------------------------------------------------------- // tree structure // --------------------------------------------------------------------------- describe("tree structure", () => { it("AND short-circuits (first false → overall false)", () => { const t = { op: "AND", children: [pred("property", "A", "eq", "1"), pred("property", "A", "eq", "2")] }; expect(evaluateCondition(t, ctx({}, { A: "1" })).result).toBe(false); }); it("OR short-circuits (first true → overall true)", () => { const t = { op: "OR", children: [pred("property", "A", "eq", "1"), pred("property", "A", "eq", "2")] }; expect(evaluateCondition(t, ctx({}, { A: "1" })).result).toBe(true); }); it("NOT negates", () => { const t = { op: "NOT", child: pred("property", "Status", "eq", "Done") }; expect(evaluateCondition(t, ctx({}, { Status: "Done" })).result).toBe(false); expect(evaluateCondition(t, ctx({}, { Status: "Open" })).result).toBe(true); }); it("nested AND/OR/NOT at depth 5 is accepted", () => { const d5 = { op: "AND" as const, children: [ { op: "OR", children: [{ op: "NOT", child: pred("property", "A", "eq", "1") }, pred("property", "A", "eq", "1")] }, pred("property", "B", "is_empty", undefined) ] }; // Depth counts group levels: AND(1) > OR(2) > NOT(3) — well within 5. expect(() => evaluateCondition(d5, ctx({}, { A: "1", B: null }))).not.toThrow(); }); it("depth 6 group nesting → CONDITION_INVALID (over depth)", () => { let node: unknown = pred("property", "A", "eq", "1"); for (let i = 0; i < 6; i++) node = { op: "AND", children: [node, pred("property", "B", "is_empty", undefined)] }; expect(() => evaluateCondition(node, ctx({}, { A: "1" }))).toThrowError(ConditionError); try { evaluateCondition(node, ctx({}, { A: "1" })); } catch (e) { expect((e as ConditionError).code).toBe("CONDITION_INVALID"); } }); it("100 nodes accepted, 101 rejected", () => { // 100-leaf OR tree = 101 nodes total (1 group + 100 leaves) → over. const leaves100 = Array.from({ length: 100 }, (_, i) => pred("property", `P${i}`, "is_empty", undefined)); const exactly100 = { op: "AND", children: [leaves100.slice(0, 99), leaves100[99]!] }; // exactly100 = 1 group + 100 leaves = 101 nodes → CONDITION_TOO_COMPLEX. expect(() => evaluateCondition(exactly100, ctx({}))).toThrowError(ConditionError); // 99 leaves + 1 group = 100 nodes → ok. const ok100 = { op: "AND", children: leaves100.slice(0, 99) }; expect(() => evaluateCondition(ok100, ctx({}))).not.toThrow(); }); it("AND with 1 child / NOT without child → CONDITION_INVALID", () => { expect(() => evaluateCondition({ op: "AND", children: [pred("property", "A", "eq", "1")] }, ctx({}))).toThrowError(ConditionError); expect(() => evaluateCondition({ op: "NOT" }, ctx({}))).toThrowError(ConditionError); }); it("null/undefined tree → always-true (evaluatedNodes 0)", () => { expect(evaluateCondition(null, ctx({}))).toEqual({ result: true, evaluatedNodes: 0, ms: 0, warnings: [] }); expect(evaluateCondition(undefined, ctx({})).result).toBe(true); }); it("evaluatedNodes counts every visited node", () => { const t = { op: "AND", children: [pred("property", "A", "eq", "1"), pred("property", "B", "eq", "2")] }; expect(evaluateCondition(t, ctx({}, { A: "1", B: "2" })).evaluatedNodes).toBe(3); }); }); // --------------------------------------------------------------------------- // timeout budget // --------------------------------------------------------------------------- describe("timeout budget", () => { it("throws CONDITION_TIMEOUT when the clock advances past the budget", () => { // Simulate a slow evaluation by making the clock jump. const t = { op: "AND", children: [pred("property", "A", "eq", "1"), pred("property", "B", "eq", "2")] }; let clock = 0; expect(() => evaluateCondition(t, ctx({}, { A: "1", B: "2" }), () => { clock += CONDITION_EVAL_BUDGET_MS + 1; return clock; }) ).toThrowError(ConditionError); try { evaluateCondition(t, ctx({}, { A: "1", B: "2" }), () => { clock += CONDITION_EVAL_BUDGET_MS + 1; return clock; }); } catch (e) { expect((e as ConditionError).code).toBe("CONDITION_TIMEOUT"); } }); it("deterministic: same ctx → same result (run 100×)", () => { const t = { op: "OR", children: [ { op: "AND", children: [pred("property", "N", "gt", 10), pred("property", "Status", "in", ["a", "b"])] }, { op: "NOT", child: pred("property", "Flag", "is_empty", undefined) } ] }; const c = ctx({}, { N: 20, Status: "a", Flag: true }); const first = evaluateCondition(t, c); for (let i = 0; i < 100; i++) expect(evaluateCondition(t, c).result).toBe(first.result); }); }); // --------------------------------------------------------------------------- // envelope unwrapping (Phase-5 stored shapes) // --------------------------------------------------------------------------- describe("unwrapEnvelope", () => { it("unwraps each Phase-5 stored shape", () => { expect(unwrapEnvelope("TEXT", { text: "hi", marks: [] })).toBe("hi"); expect(unwrapEnvelope("NUMBER", { value: 3 })).toBe(3); expect(unwrapEnvelope("SELECT", { optionId: "opt1" })).toBe("opt1"); expect(unwrapEnvelope("MULTI_SELECT", { optionIds: ["a", "b"] })).toEqual(["a", "b"]); expect(unwrapEnvelope("PERSON", { userIds: ["u1"] })).toEqual(["u1"]); expect(unwrapEnvelope("DATE", { start: "2026-01-01", end: null })).toBe("2026-01-01"); expect(unwrapEnvelope("DATE_RANGE", { start: "2026-01-01", end: "2026-01-02" })).toBe("2026-01-01..2026-01-02"); expect(unwrapEnvelope("CHECKBOX", { value: true })).toBe(true); expect(unwrapEnvelope("URL", { value: "https://x", title: null })).toBe("https://x"); // Pass-throughs. expect(unwrapEnvelope("TITLE", "plain")).toBe("plain"); expect(unwrapEnvelope("UNKNOWN", { foo: 1 })).toEqual({ foo: 1 }); expect(unwrapEnvelope("TEXT", null)).toBeNull(); }); });