@open-mercato/shared 0.6.1-develop.3276.1.1fcd145078 → 0.6.1-develop.3287.1.450f4ffb56

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,367 @@
1
+ /**
2
+ * End-to-end Phase 1-5 verification of the user's reported AND/OR scenarios.
3
+ *
4
+ * Pipeline exercised:
5
+ * AdvancedFilterTree
6
+ * -> compileTreeToWhere (advanced-filter-tree.ts)
7
+ * -> mergeAdvancedFilterTree (advanced-filter-integration.ts)
8
+ * -> normalizeFilters with DNF expansion + common-clause lift (join-utils.ts)
9
+ *
10
+ * The user's bug: with seed people Daniel Cho & Naomi Harris,
11
+ * - "Name contains Daniel OR Name contains Naomi" should return both;
12
+ * - "Name contains Daniel AND Name contains Harris" should return Daniel Harris (or none if absent),
13
+ * NOT Naomi Harris alone.
14
+ *
15
+ * We verify the math by inspecting the normalized filter shape that the engine
16
+ * would issue. Each test asserts on which row(s) match.
17
+ */
18
+ /** @jest-environment node */
19
+ import { compileTreeToWhere, type AdvancedFilterTree, type FilterRule, type FilterGroup } from '../advanced-filter-tree'
20
+ import { mergeAdvancedFilterTree } from '../../crud/advanced-filter-integration'
21
+ import { normalizeFilters } from '../join-utils'
22
+ import type { FilterOp } from '../types'
23
+
24
+ type Row = { id: string; display_name: string; status?: string; kind: 'person' }
25
+ const people: Row[] = [
26
+ { id: '1', display_name: 'Daniel Cho', kind: 'person' },
27
+ { id: '2', display_name: 'Naomi Harris', kind: 'person', status: 'Lead' },
28
+ { id: '3', display_name: 'Daniel Harris', kind: 'person' },
29
+ { id: '4', display_name: 'Naomi Cho', kind: 'person' },
30
+ ]
31
+
32
+ type Predicate = (row: Row) => boolean
33
+
34
+ function leafToPredicate(field: string, op: FilterOp, value: unknown): Predicate {
35
+ return (row: Row) => {
36
+ const cell = (row as Record<string, unknown>)[field]
37
+ if (op === 'eq') return cell === value
38
+ if (op === 'ne') return cell !== value
39
+ if (op === 'ilike' && typeof cell === 'string' && typeof value === 'string') {
40
+ // Convert SQL ilike pattern (% wildcards) to regex
41
+ const pattern = value.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/%/g, '.*')
42
+ return new RegExp(`^${pattern}$`, 'i').test(cell)
43
+ }
44
+ if (op === 'in' && Array.isArray(value)) return value.includes(cell)
45
+ if (op === 'exists') return value === true ? cell != null : cell == null
46
+ return false
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Take normalized filter output (regular base filters + orGroup-tagged disjuncts)
52
+ * and produce a single predicate. Mirrors the engine's eb.and([...]) and eb.or([...]).
53
+ */
54
+ function buildPredicate(filters: ReturnType<typeof normalizeFilters>): Predicate {
55
+ const regular: Predicate[] = []
56
+ const groups = new Map<string, Predicate[]>()
57
+ for (const f of filters) {
58
+ const pred = leafToPredicate(String(f.field), f.op, f.value)
59
+ if (f.orGroup) {
60
+ const list = groups.get(f.orGroup) ?? []
61
+ list.push(pred)
62
+ groups.set(f.orGroup, list)
63
+ } else {
64
+ regular.push(pred)
65
+ }
66
+ }
67
+ return (row: Row) => {
68
+ for (const p of regular) if (!p(row)) return false
69
+ if (groups.size === 0) return true
70
+ for (const [, preds] of groups) {
71
+ if (preds.every((p) => p(row))) return true
72
+ }
73
+ return false
74
+ }
75
+ }
76
+
77
+ function evaluate(routeFilters: Record<string, unknown>, tree: AdvancedFilterTree): Row[] {
78
+ const merged = mergeAdvancedFilterTree(routeFilters, tree)
79
+ const normalized = normalizeFilters(merged)
80
+ const pred = buildPredicate(normalized)
81
+ return people.filter(pred)
82
+ }
83
+
84
+ const rule = (field: string, op: FilterRule['operator'], value: string): FilterRule => ({
85
+ id: `${field}:${value}`, type: 'rule', field, operator: op, value,
86
+ })
87
+ const group = (combinator: 'and' | 'or', children: Array<FilterRule | FilterGroup>): FilterGroup => ({
88
+ id: combinator, type: 'group', combinator, children,
89
+ })
90
+
91
+ describe('Advanced filter — end-to-end user bug scenarios', () => {
92
+ const baseRouteFilters: Record<string, unknown> = { kind: { $eq: 'person' } }
93
+
94
+ test('"Name contains Daniel" -> 2 rows', () => {
95
+ const tree: AdvancedFilterTree = { root: group('and', [rule('display_name', 'contains', 'Daniel')]) }
96
+ expect(evaluate(baseRouteFilters, tree).map((r) => r.display_name).sort()).toEqual(['Daniel Cho', 'Daniel Harris'])
97
+ })
98
+
99
+ test('"Name contains Daniel AND Name contains Harris" -> 1 row (Daniel Harris) — Bug A fixed', () => {
100
+ const tree: AdvancedFilterTree = {
101
+ root: group('and', [
102
+ rule('display_name', 'contains', 'Daniel'),
103
+ rule('display_name', 'contains', 'Harris'),
104
+ ]),
105
+ }
106
+ expect(evaluate(baseRouteFilters, tree).map((r) => r.display_name)).toEqual(['Daniel Harris'])
107
+ })
108
+
109
+ test('"Name contains Daniel OR Name contains Naomi" -> 4 rows', () => {
110
+ const tree: AdvancedFilterTree = {
111
+ root: group('or', [
112
+ rule('display_name', 'contains', 'Daniel'),
113
+ rule('display_name', 'contains', 'Naomi'),
114
+ ]),
115
+ }
116
+ expect(evaluate(baseRouteFilters, tree).map((r) => r.display_name).sort()).toEqual([
117
+ 'Daniel Cho', 'Daniel Harris', 'Naomi Cho', 'Naomi Harris',
118
+ ])
119
+ })
120
+
121
+ test('"(Daniel OR Naomi) AND Harris" -> 2 rows', () => {
122
+ const tree: AdvancedFilterTree = {
123
+ root: group('and', [
124
+ group('or', [
125
+ rule('display_name', 'contains', 'Daniel'),
126
+ rule('display_name', 'contains', 'Naomi'),
127
+ ]),
128
+ rule('display_name', 'contains', 'Harris'),
129
+ ]),
130
+ }
131
+ expect(evaluate(baseRouteFilters, tree).map((r) => r.display_name).sort()).toEqual([
132
+ 'Daniel Harris', 'Naomi Harris',
133
+ ])
134
+ })
135
+
136
+ test('"Daniel OR (Naomi AND Harris)" -> 3 rows (precedence-distinct from above)', () => {
137
+ const tree: AdvancedFilterTree = {
138
+ root: group('or', [
139
+ rule('display_name', 'contains', 'Daniel'),
140
+ group('and', [
141
+ rule('display_name', 'contains', 'Naomi'),
142
+ rule('display_name', 'contains', 'Harris'),
143
+ ]),
144
+ ]),
145
+ }
146
+ expect(evaluate(baseRouteFilters, tree).map((r) => r.display_name).sort()).toEqual([
147
+ 'Daniel Cho', 'Daniel Harris', 'Naomi Harris',
148
+ ])
149
+ })
150
+
151
+ test('mixed-field: "(status is Lead) OR (Name contains Cho)" -> 3 rows (Naomi Harris by status, Daniel/Naomi Cho by name)', () => {
152
+ const tree: AdvancedFilterTree = {
153
+ root: group('or', [
154
+ rule('status', 'is', 'Lead'),
155
+ rule('display_name', 'contains', 'Cho'),
156
+ ]),
157
+ }
158
+ expect(evaluate(baseRouteFilters, tree).map((r) => r.display_name).sort()).toEqual([
159
+ 'Daniel Cho', 'Naomi Cho', 'Naomi Harris',
160
+ ])
161
+ })
162
+
163
+ test('empty tree returns route filters only (all persons)', () => {
164
+ const tree: AdvancedFilterTree = { root: group('and', []) }
165
+ expect(evaluate(baseRouteFilters, tree)).toHaveLength(4)
166
+ })
167
+
168
+ test('depth-3 tree: "(Daniel AND (Cho OR Harris)) OR (Naomi AND Cho)" -> 3 rows', () => {
169
+ const tree: AdvancedFilterTree = {
170
+ root: group('or', [
171
+ group('and', [
172
+ rule('display_name', 'contains', 'Daniel'),
173
+ group('or', [
174
+ rule('display_name', 'contains', 'Cho'),
175
+ rule('display_name', 'contains', 'Harris'),
176
+ ]),
177
+ ]),
178
+ group('and', [
179
+ rule('display_name', 'contains', 'Naomi'),
180
+ rule('display_name', 'contains', 'Cho'),
181
+ ]),
182
+ ]),
183
+ }
184
+ expect(evaluate(baseRouteFilters, tree).map((r) => r.display_name).sort()).toEqual([
185
+ 'Daniel Cho', 'Daniel Harris', 'Naomi Cho',
186
+ ])
187
+ })
188
+ })
189
+
190
+ describe('Advanced filter — long chains and high-cardinality combinations', () => {
191
+ // Add 6 more rows to exercise long ORs.
192
+ const more: Row[] = [
193
+ { id: '5', display_name: 'Lena Ortiz', kind: 'person' },
194
+ { id: '6', display_name: 'Mia Johnson', kind: 'person' },
195
+ { id: '7', display_name: 'Taylor Brooks', kind: 'person' },
196
+ { id: '8', display_name: 'Arjun Patel', kind: 'person' },
197
+ ]
198
+ const everyone = [...people, ...more]
199
+ function evalAll(routeFilters: Record<string, unknown>, tree: AdvancedFilterTree): Row[] {
200
+ const merged = mergeAdvancedFilterTree(routeFilters, tree)
201
+ const normalized = normalizeFilters(merged)
202
+ const pred = buildPredicate(normalized)
203
+ return everyone.filter(pred)
204
+ }
205
+ const c = (v: string): FilterRule => rule('display_name', 'contains', v)
206
+
207
+ test('6-way OR matches all six people', () => {
208
+ const t: AdvancedFilterTree = { root: group('or', ['Daniel', 'Naomi', 'Lena', 'Mia', 'Taylor', 'Arjun'].map(c)) }
209
+ expect(evalAll({}, t).map((r) => r.display_name).sort()).toEqual([
210
+ 'Arjun Patel', 'Daniel Cho', 'Daniel Harris', 'Lena Ortiz', 'Mia Johnson', 'Naomi Cho', 'Naomi Harris', 'Taylor Brooks',
211
+ ])
212
+ })
213
+
214
+ test('5-way AND impossible for distinct names returns empty', () => {
215
+ const t: AdvancedFilterTree = { root: group('and', ['Daniel', 'Naomi', 'Lena', 'Mia', 'Taylor'].map(c)) }
216
+ expect(evalAll({}, t)).toHaveLength(0)
217
+ })
218
+
219
+ test('4-way AND that one row satisfies', () => {
220
+ const t: AdvancedFilterTree = { root: group('and', ['Daniel', 'Cho', 'Dan', 'Daniel'].map(c)) }
221
+ expect(evalAll({}, t).map((r) => r.display_name)).toEqual(['Daniel Cho'])
222
+ })
223
+
224
+ test('Two OR groups ANDed: (A OR B) AND (C OR D)', () => {
225
+ const t: AdvancedFilterTree = {
226
+ root: group('and', [
227
+ group('or', [c('Daniel'), c('Naomi')]),
228
+ group('or', [c('Cho'), c('Harris')]),
229
+ ]),
230
+ }
231
+ expect(evalAll({}, t).map((r) => r.display_name).sort()).toEqual(['Daniel Cho', 'Daniel Harris', 'Naomi Cho', 'Naomi Harris'])
232
+ })
233
+
234
+ test('Three OR groups ANDed: (A OR B) AND (C OR D) AND (E OR F)', () => {
235
+ const t: AdvancedFilterTree = {
236
+ root: group('and', [
237
+ group('or', [c('Daniel'), c('Naomi')]),
238
+ group('or', [c('Cho'), c('Harris')]),
239
+ group('or', [c('Lena'), c('Mia')]),
240
+ ]),
241
+ }
242
+ expect(evalAll({}, t)).toHaveLength(0)
243
+ })
244
+
245
+ test('Depth-3 mixed (Daniel OR (Naomi AND Harris)) OR Lena', () => {
246
+ const t: AdvancedFilterTree = {
247
+ root: group('or', [
248
+ c('Daniel'),
249
+ group('and', [c('Naomi'), c('Harris')]),
250
+ c('Lena'),
251
+ ]),
252
+ }
253
+ expect(evalAll({}, t).map((r) => r.display_name).sort()).toEqual(['Daniel Cho', 'Daniel Harris', 'Lena Ortiz', 'Naomi Harris'])
254
+ })
255
+
256
+ test('Depth-3 deeper: ((Daniel AND Cho) OR Naomi) AND (Cho OR Harris)', () => {
257
+ const t: AdvancedFilterTree = {
258
+ root: group('and', [
259
+ group('or', [
260
+ group('and', [c('Daniel'), c('Cho')]),
261
+ c('Naomi'),
262
+ ]),
263
+ group('or', [c('Cho'), c('Harris')]),
264
+ ]),
265
+ }
266
+ expect(evalAll({}, t).map((r) => r.display_name).sort()).toEqual(['Daniel Cho', 'Naomi Cho', 'Naomi Harris'])
267
+ })
268
+
269
+ test('10-way OR with mixed hits and misses (de-dup verified)', () => {
270
+ const t: AdvancedFilterTree = {
271
+ root: group('or', ['Daniel', 'Naomi', 'Lena', 'XXX', 'YYY', 'Mia', 'Taylor', 'Arjun', 'ZZZ', 'Patel'].map(c)),
272
+ }
273
+ expect(evalAll({}, t).map((r) => r.display_name).sort()).toEqual([
274
+ 'Arjun Patel', 'Daniel Cho', 'Daniel Harris', 'Lena Ortiz', 'Mia Johnson', 'Naomi Cho', 'Naomi Harris', 'Taylor Brooks',
275
+ ])
276
+ })
277
+
278
+ test('Wide AND clause: (10-way OR over names) AND a single rule', () => {
279
+ const t: AdvancedFilterTree = {
280
+ root: group('and', [
281
+ group('or', ['Daniel', 'Naomi', 'Lena', 'Mia', 'Taylor', 'Arjun', 'Sara', 'John', 'Bob', 'Tom'].map(c)),
282
+ c('Cho'),
283
+ ]),
284
+ }
285
+ expect(evalAll({}, t).map((r) => r.display_name).sort()).toEqual(['Daniel Cho', 'Naomi Cho'])
286
+ })
287
+
288
+ test('4-level nested: ((Daniel OR Naomi) AND Cho) OR ((Lena OR Mia) AND Ortiz)', () => {
289
+ const t: AdvancedFilterTree = {
290
+ root: group('or', [
291
+ group('and', [group('or', [c('Daniel'), c('Naomi')]), c('Cho')]),
292
+ group('and', [group('or', [c('Lena'), c('Mia')]), c('Ortiz')]),
293
+ ]),
294
+ }
295
+ expect(evalAll({}, t).map((r) => r.display_name).sort()).toEqual(['Daniel Cho', 'Lena Ortiz', 'Naomi Cho'])
296
+ })
297
+
298
+ test('AND of 5 OR-pairs produces a large DNF (32 disjuncts) and still resolves correctly', () => {
299
+ const t: AdvancedFilterTree = {
300
+ root: group('and', [
301
+ group('or', [c('Daniel'), c('Naomi')]),
302
+ group('or', [c('Cho'), c('Harris')]),
303
+ group('or', [c('Cho'), c('Harris')]),
304
+ group('or', [c('Daniel'), c('Naomi')]),
305
+ group('or', [c('Cho'), c('Harris')]),
306
+ ]),
307
+ }
308
+ // logically reduces to (Daniel OR Naomi) AND (Cho OR Harris) - 4 rows
309
+ expect(evalAll({}, t).map((r) => r.display_name).sort()).toEqual(['Daniel Cho', 'Daniel Harris', 'Naomi Cho', 'Naomi Harris'])
310
+ })
311
+
312
+ test('Same value repeated in OR — collapses to a single match', () => {
313
+ const t: AdvancedFilterTree = { root: group('or', [c('Daniel'), c('Daniel')]) }
314
+ expect(evalAll({}, t).map((r) => r.display_name).sort()).toEqual(['Daniel Cho', 'Daniel Harris'])
315
+ })
316
+
317
+ test('Width cap: 15 OR alternatives — accepted', () => {
318
+ const t: AdvancedFilterTree = {
319
+ root: group('or', ['Daniel','Naomi','Lena','Mia','Taylor','Arjun','Cho','Harris','Ortiz','Johnson','Brooks','Patel','Dan','Nao','Len'].map(c)),
320
+ }
321
+ expect(evalAll({}, t).map((r) => r.display_name).length).toBe(8) // all 8 distinct rows
322
+ })
323
+ })
324
+
325
+ describe('Advanced filter — math vs old buggy algorithm', () => {
326
+ // Compare current algorithm output to what the buggy old algorithm would produce
327
+ // for the user's specific bug cases. This documents the regression contract.
328
+ test('Old AND collision would return Naomi Harris; new tree returns Daniel Harris', () => {
329
+ const tree: AdvancedFilterTree = {
330
+ root: group('and', [
331
+ rule('display_name', 'contains', 'Daniel'),
332
+ rule('display_name', 'contains', 'Harris'),
333
+ ]),
334
+ }
335
+ const got = evaluate({}, tree).map((r) => r.display_name)
336
+ expect(got).toEqual(['Daniel Harris'])
337
+ expect(got).not.toContain('Naomi Harris')
338
+ })
339
+
340
+ test('Standard SQL precedence: A OR (B AND C) is NOT (A OR B) AND C', () => {
341
+ // Demonstrate the design's claim about precedence: these two trees give
342
+ // different result sets, so the model preserves the user's intent.
343
+ const A_or_BC: AdvancedFilterTree = {
344
+ root: group('or', [
345
+ rule('display_name', 'contains', 'Daniel'),
346
+ group('and', [
347
+ rule('display_name', 'contains', 'Naomi'),
348
+ rule('display_name', 'contains', 'Harris'),
349
+ ]),
350
+ ]),
351
+ }
352
+ const AB_or_then_C: AdvancedFilterTree = {
353
+ root: group('and', [
354
+ group('or', [
355
+ rule('display_name', 'contains', 'Daniel'),
356
+ rule('display_name', 'contains', 'Naomi'),
357
+ ]),
358
+ rule('display_name', 'contains', 'Harris'),
359
+ ]),
360
+ }
361
+ const a = evaluate({}, A_or_BC).map((r) => r.display_name).sort()
362
+ const b = evaluate({}, AB_or_then_C).map((r) => r.display_name).sort()
363
+ expect(a).not.toEqual(b)
364
+ expect(a).toEqual(['Daniel Cho', 'Daniel Harris', 'Naomi Harris'])
365
+ expect(b).toEqual(['Daniel Harris', 'Naomi Harris'])
366
+ })
367
+ })
@@ -0,0 +1,218 @@
1
+ /** @jest-environment node */
2
+ import {
3
+ serializeTree,
4
+ deserializeTree,
5
+ flatToTree,
6
+ type AdvancedFilterState,
7
+ type FilterCondition,
8
+ } from '../advanced-filter'
9
+ import type { AdvancedFilterTree, FilterCombinator } from '../advanced-filter-tree'
10
+
11
+ describe('serializeTree', () => {
12
+ it('omits params for an empty tree', () => {
13
+ expect(serializeTree({
14
+ root: { id: 'r', type: 'group', combinator: 'and', children: [] },
15
+ })).toEqual({})
16
+ })
17
+
18
+ it('emits v2 bracketed params for a single rule', () => {
19
+ const tree: AdvancedFilterTree = {
20
+ root: {
21
+ id: 'r', type: 'group', combinator: 'and',
22
+ children: [{ id: 'c', type: 'rule', field: 'display_name', operator: 'contains', value: 'Daniel' }],
23
+ },
24
+ }
25
+ expect(serializeTree(tree)).toEqual({
26
+ 'filter[v]': '2',
27
+ 'filter[root][combinator]': 'and',
28
+ 'filter[root][children][0][type]': 'rule',
29
+ 'filter[root][children][0][field]': 'display_name',
30
+ 'filter[root][children][0][op]': 'contains',
31
+ 'filter[root][children][0][value]': 'Daniel',
32
+ })
33
+ })
34
+
35
+ it('emits nested groups recursively', () => {
36
+ const tree: AdvancedFilterTree = {
37
+ root: {
38
+ id: 'r', type: 'group', combinator: 'and',
39
+ children: [
40
+ {
41
+ id: 'g', type: 'group', combinator: 'or',
42
+ children: [
43
+ { id: 'a', type: 'rule', field: 'display_name', operator: 'contains', value: 'Daniel' },
44
+ { id: 'b', type: 'rule', field: 'display_name', operator: 'contains', value: 'Naomi' },
45
+ ],
46
+ },
47
+ { id: 'c', type: 'rule', field: 'status', operator: 'is', value: 'Lead' },
48
+ ],
49
+ },
50
+ }
51
+ expect(serializeTree(tree)).toMatchObject({
52
+ 'filter[v]': '2',
53
+ 'filter[root][combinator]': 'and',
54
+ 'filter[root][children][0][type]': 'group',
55
+ 'filter[root][children][0][combinator]': 'or',
56
+ 'filter[root][children][0][children][0][type]': 'rule',
57
+ 'filter[root][children][0][children][0][field]': 'display_name',
58
+ 'filter[root][children][0][children][0][op]': 'contains',
59
+ 'filter[root][children][0][children][0][value]': 'Daniel',
60
+ 'filter[root][children][0][children][1][value]': 'Naomi',
61
+ 'filter[root][children][1][type]': 'rule',
62
+ 'filter[root][children][1][field]': 'status',
63
+ 'filter[root][children][1][op]': 'is',
64
+ 'filter[root][children][1][value]': 'Lead',
65
+ })
66
+ })
67
+
68
+ it('omits value param for valueless operators', () => {
69
+ const tree: AdvancedFilterTree = {
70
+ root: {
71
+ id: 'r', type: 'group', combinator: 'and',
72
+ children: [{ id: 'a', type: 'rule', field: 'primary_email', operator: 'is_empty', value: '' }],
73
+ },
74
+ }
75
+ const out = serializeTree(tree)
76
+ expect(out['filter[root][children][0][op]']).toBe('is_empty')
77
+ expect(out['filter[root][children][0][value]']).toBeUndefined()
78
+ })
79
+ })
80
+
81
+ describe('deserializeTree (v2)', () => {
82
+ it('round-trips a nested tree', () => {
83
+ const tree: AdvancedFilterTree = {
84
+ root: { id: 'r', type: 'group', combinator: 'and', children: [
85
+ { id: 'g', type: 'group', combinator: 'or', children: [
86
+ { id: 'a', type: 'rule', field: 'display_name', operator: 'contains', value: 'Daniel' },
87
+ { id: 'b', type: 'rule', field: 'display_name', operator: 'contains', value: 'Naomi' },
88
+ ]},
89
+ { id: 'c', type: 'rule', field: 'status', operator: 'is', value: 'Lead' },
90
+ ]},
91
+ }
92
+ const params = serializeTree(tree)
93
+ const round = deserializeTree(params)
94
+ // ids are regenerated; compare structure only.
95
+ type StripNode =
96
+ | { type: 'rule'; field: string; operator: string; value: unknown }
97
+ | { type: 'group'; combinator: FilterCombinator; children: StripNode[] }
98
+ const stripIds = (node: any): StripNode =>
99
+ node.type === 'rule'
100
+ ? { type: 'rule', field: node.field, operator: node.operator, value: node.value }
101
+ : { type: 'group', combinator: node.combinator, children: node.children.map(stripIds) }
102
+ expect(round).not.toBeNull()
103
+ expect(stripIds(round!.root)).toEqual(stripIds(tree.root))
104
+ })
105
+
106
+ it('returns null when filter[v] is not 2', () => {
107
+ expect(deserializeTree({ 'filter[v]': '1' })).toBeNull()
108
+ expect(deserializeTree({})).toBeNull()
109
+ })
110
+
111
+ it('returns null when root combinator is missing', () => {
112
+ expect(deserializeTree({ 'filter[v]': '2' })).toBeNull()
113
+ })
114
+
115
+ it('parses a valueless operator with no value param', () => {
116
+ const out = deserializeTree({
117
+ 'filter[v]': '2',
118
+ 'filter[root][combinator]': 'and',
119
+ 'filter[root][children][0][type]': 'rule',
120
+ 'filter[root][children][0][field]': 'primary_email',
121
+ 'filter[root][children][0][op]': 'is_empty',
122
+ })
123
+ expect(out).not.toBeNull()
124
+ expect(out!.root.children[0]).toMatchObject({ type: 'rule', operator: 'is_empty' })
125
+ })
126
+ })
127
+
128
+ describe('flatToTree — legacy v1 -> tree (SQL precedence)', () => {
129
+ const cond = (field: string, value: string, join: 'and' | 'or' = 'and'): FilterCondition => ({
130
+ id: field, field, operator: 'contains', value, join,
131
+ })
132
+
133
+ it('A AND B AND C -> single AND group', () => {
134
+ const flat: AdvancedFilterState = {
135
+ logic: 'and',
136
+ conditions: [cond('a', 'x', 'and'), cond('b', 'y', 'and'), cond('c', 'z', 'and')],
137
+ }
138
+ const tree = flatToTree(flat)
139
+ expect(tree.root.combinator).toBe('and')
140
+ expect(tree.root.children).toHaveLength(3)
141
+ expect(tree.root.children.every((c) => c.type === 'rule')).toBe(true)
142
+ })
143
+
144
+ it('A OR B OR C -> single OR group', () => {
145
+ const flat: AdvancedFilterState = {
146
+ logic: 'and',
147
+ conditions: [cond('a', 'x', 'and'), cond('b', 'y', 'or'), cond('c', 'z', 'or')],
148
+ }
149
+ const tree = flatToTree(flat)
150
+ expect(tree.root.combinator).toBe('or')
151
+ expect(tree.root.children).toHaveLength(3)
152
+ expect(tree.root.children.every((c) => c.type === 'rule')).toBe(true)
153
+ })
154
+
155
+ it('A AND B OR C AND D -> OR(AND(A,B), AND(C,D))', () => {
156
+ const flat: AdvancedFilterState = {
157
+ logic: 'and',
158
+ conditions: [cond('a', 'x', 'and'), cond('b', 'y', 'and'), cond('c', 'z', 'or'), cond('d', 'w', 'and')],
159
+ }
160
+ const tree = flatToTree(flat)
161
+ expect(tree.root.combinator).toBe('or')
162
+ expect(tree.root.children).toHaveLength(2)
163
+ const [first, second] = tree.root.children
164
+ expect(first.type).toBe('group')
165
+ expect(second.type).toBe('group')
166
+ if (first.type === 'group') {
167
+ expect(first.combinator).toBe('and')
168
+ expect(first.children).toHaveLength(2)
169
+ }
170
+ if (second.type === 'group') {
171
+ expect(second.combinator).toBe('and')
172
+ expect(second.children).toHaveLength(2)
173
+ }
174
+ })
175
+
176
+ it('A OR B AND C -> OR(A, AND(B,C))', () => {
177
+ const flat: AdvancedFilterState = {
178
+ logic: 'and',
179
+ conditions: [cond('a', 'x', 'and'), cond('b', 'y', 'or'), cond('c', 'z', 'and')],
180
+ }
181
+ const tree = flatToTree(flat)
182
+ expect(tree.root.combinator).toBe('or')
183
+ expect(tree.root.children).toHaveLength(2)
184
+ expect(tree.root.children[0].type).toBe('rule')
185
+ expect(tree.root.children[1].type).toBe('group')
186
+ if (tree.root.children[1].type === 'group') {
187
+ expect(tree.root.children[1].combinator).toBe('and')
188
+ expect(tree.root.children[1].children).toHaveLength(2)
189
+ }
190
+ })
191
+
192
+ it('first row join is forced to AND even if user set "or"', () => {
193
+ const flat: AdvancedFilterState = {
194
+ logic: 'and',
195
+ conditions: [cond('a', 'x', 'or'), cond('b', 'y', 'and')],
196
+ }
197
+ const tree = flatToTree(flat)
198
+ expect(tree.root.combinator).toBe('and')
199
+ expect(tree.root.children).toHaveLength(2)
200
+ })
201
+
202
+ it('single rule -> root group with one rule', () => {
203
+ const flat: AdvancedFilterState = {
204
+ logic: 'and',
205
+ conditions: [cond('a', 'x', 'and')],
206
+ }
207
+ const tree = flatToTree(flat)
208
+ expect(tree.root.combinator).toBe('and')
209
+ expect(tree.root.children).toHaveLength(1)
210
+ expect(tree.root.children[0].type).toBe('rule')
211
+ })
212
+
213
+ it('empty conditions -> empty root', () => {
214
+ const flat: AdvancedFilterState = { logic: 'and', conditions: [] }
215
+ const tree = flatToTree(flat)
216
+ expect(tree.root.children).toHaveLength(0)
217
+ })
218
+ })