1import { describe, expect, it } from "vitest";
2
3import { applyFilter, evaluateCondition, propertiesMap } from "./index.js";
4import type { RecordRow, PropertyInfo, EvalContext } from "../values/types.js";
5
6const props: PropertyInfo[] = [
7 { id: "p-title", name: "Title", type: "title", config: {} },
8 { id: "p-num", name: "Estimate", type: "number", config: {} },
9 { id: "p-date", name: "Due", type: "date", config: {} },
10 {
11 id: "p-status",
12 name: "Status",
13 type: "select",
14 config: { options: [{ id: "done", name: "Done", color: "green" }] },
15 },
16 {
17 id: "p-multi",
18 name: "Tags",
19 type: "multi_select",
20 config: {
21 options: [
22 { id: "t1", name: "T1", color: "blue" },
23 { id: "t2", name: "T2", color: "red" },
24 ],
25 },
26 },
27 { id: "p-check", name: "Done?", type: "checkbox", config: {} },
28];
29const map = propertiesMap(props);
30const ctx: EvalContext = {
31 now: Date.parse("2025-06-15T12:00:00Z"),
32 actorUserId: "usr-me",
33 userTimezone: "UTC",
34};
35
36const row = (over: Partial<Record<string, unknown>> = {}): RecordRow => ({
37 id: "r1",
38 pageId: "pg1",
39 position: "a0",
40 createdTime: 1,
41 lastEditedTime: 1,
42 values: {
43 "p-title": "Write spec",
44 "p-num": 10,
45 "p-date": "2025-06-16",
46 "p-status": "done",
47 "p-multi": ["t1", "t2"],
48 "p-check": true,
49 ...over,
50 },
51});
52
53describe("filter engine", () => {
54 it("text operators (case-insensitive contains/starts/ends)", () => {
55 const c = { propertyId: "p-title", operator: "contains" as const, value: "SPEC" };
56 expect(evaluateCondition(row(), props[0]!, c, ctx)).toBe(true);
57 expect(
58 evaluateCondition(
59 row(),
60 props[0]!,
61 { propertyId: "p-title", operator: "starts_with", value: "write" },
62 ctx,
63 ),
64 ).toBe(true);
65 expect(
66 evaluateCondition(
67 row(),
68 props[0]!,
69 { propertyId: "p-title", operator: "ends_with", value: "spec" },
70 ctx,
71 ),
72 ).toBe(true);
73 });
74
75 it("number comparison + isBetween", () => {
76 const n = (op: string, value: unknown) => ({
77 propertyId: "p-num",
78 operator: op as never,
79 value,
80 });
81 expect(evaluateCondition(row(), props[1]!, n(">", 5), ctx)).toBe(true);
82 expect(evaluateCondition(row(), props[1]!, n("<", 5), ctx)).toBe(false);
83 expect(evaluateCondition(row(), props[1]!, n("isBetween", [5, 15]), ctx)).toBe(true);
84 });
85
86 it("select is/isNot + empty semantics", () => {
87 const s = (op: string, value?: unknown) => ({
88 propertyId: "p-status",
89 operator: op as never,
90 value,
91 });
92 expect(evaluateCondition(row(), props[3]!, s("=", "done"), ctx)).toBe(true);
93 expect(evaluateCondition(row(), props[3]!, s("!=", "done"), ctx)).toBe(false);
94 expect(evaluateCondition(row({ "p-status": null }), props[3]!, s("is_empty"), ctx)).toBe(true);
95 });
96
97 it("multi_select contains/doesNotContain", () => {
98 const m = (op: string, value: unknown) => ({
99 propertyId: "p-multi",
100 operator: op as never,
101 value,
102 });
103 expect(evaluateCondition(row(), props[4]!, m("contains", "t1"), ctx)).toBe(true);
104 expect(evaluateCondition(row(), props[4]!, m("doesNotContain", "t1"), ctx)).toBe(false);
105 expect(evaluateCondition(row(), props[4]!, m("=", ["t1", "t2"]), ctx)).toBe(true);
106 });
107
108 it("checkbox isChecked/isUnchecked", () => {
109 expect(
110 evaluateCondition(row(), props[5]!, { propertyId: "p-check", operator: "isChecked" }, ctx),
111 ).toBe(true);
112 expect(
113 evaluateCondition(
114 row({ "p-check": false }),
115 props[5]!,
116 { propertyId: "p-check", operator: "isUnchecked" },
117 ctx,
118 ),
119 ).toBe(true);
120 });
121
122 it("date: equality + relative windows (this week)", () => {
123 const d = (op: string, value: unknown) => ({
124 propertyId: "p-date",
125 operator: op as never,
126 value,
127 });
128 expect(evaluateCondition(row(), props[2]!, d("=", "2025-06-16"), ctx)).toBe(true);
129 expect(evaluateCondition(row(), props[2]!, d("isAfter", "2025-06-15"), ctx)).toBe(true);
130
131
132
133
134
135 const weekCtx: EvalContext = {
136 now: Date.parse("2025-06-17T12:00:00Z"),
137 actorUserId: "usr-me",
138 userTimezone: "UTC",
139 };
140 expect(evaluateCondition(row(), props[2]!, d("=", "this week"), weekCtx)).toBe(true);
141 expect(evaluateCondition(row(), props[2]!, d("=", "last week"), weekCtx)).toBe(false);
142 });
143
144 it("tokens: me / today resolve against ctx", () => {
145 const owner = { propertyId: "p-owner", operator: "=" as const, value: "me" };
146 const ownerProp: PropertyInfo = { id: "p-owner", name: "Owner", type: "person", config: {} };
147 expect(evaluateCondition(row({ "p-owner": ["usr-me"] }), ownerProp, owner, ctx)).toBe(true);
148 expect(
149 evaluateCondition(
150 row(),
151 props[2]!,
152 { propertyId: "p-date", operator: "=", value: "today" },
153 { now: Date.parse("2025-06-16T00:00:00Z"), userTimezone: "UTC" },
154 ),
155 ).toBe(true);
156 });
157
158 it("nested and/or/not trees", () => {
159 const tree = {
160 op: "and" as const,
161 conditions: [
162 { propertyId: "p-num", operator: ">" as const, value: 5 },
163 {
164 op: "or" as const,
165 conditions: [
166 { propertyId: "p-status", operator: "=" as const, value: "nope" },
167 { propertyId: "p-check", operator: "isChecked" as const },
168 ],
169 },
170 {
171 op: "not" as const,
172 conditions: [{ propertyId: "p-num", operator: ">" as const, value: 100 }],
173 },
174 ],
175 };
176 expect(applyFilter([row()], tree, map, ctx)).toHaveLength(1);
177 const failing = {
178 op: "or" as const,
179 conditions: [{ propertyId: "p-num", operator: ">" as const, value: 100 }],
180 };
181 expect(applyFilter([row()], failing, map, ctx)).toHaveLength(0);
182 });
183});
184