-
Notifications
You must be signed in to change notification settings - Fork 5
Expand file tree
/
Copy pathview-operator-builder-parity.test.ts
More file actions
152 lines (137 loc) · 6.89 KB
/
Copy pathview-operator-builder-parity.test.ts
File metadata and controls
152 lines (137 loc) · 6.89 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* View operator → FilterBuilder operator parity (#2901, #2945).
*
* The third of objectui's spec-operator translation tables. The other two —
* `ListView.mapOperator` and `data-objectstack`'s `normalizeFilterOperator` —
* were pinned to the spec in #2974, which found eight spellings they had missed
* by enumerating instead of deriving. This one maps the same vocabulary onto the
* FilterBuilder's operator ids, and it had missed nine:
*
* not_equals, greater_than, less_than, greater_than_or_equal,
* less_than_or_equal, starts_with, ends_with, is_null, is_not_null
*
* All nine are canonical `VIEW_FILTER_OPERATORS` members, so a stored view
* legitimately carries them, and all nine reached the builder as a raw spelling
* its dropdown cannot select. Five now map; four are recorded as deliberate
* gaps, asserted below so the list cannot grow silently.
*/
import { describe, it, expect } from 'vitest';
import { VIEW_FILTER_OPERATORS, VIEW_FILTER_OPERATOR_ALIASES } from '@objectstack/spec/ui';
import { FILTER_BUILDER_OPERATORS } from '@object-ui/components';
import { specToBuilderOperator, __CANONICAL_TO_BUILDER } from '../view-config-utils';
/**
* Canonical view operators the FilterBuilder cannot express.
*
* Empty — every one of the 19 now maps. It was `starts_with`, `ends_with`,
* `is_null`, `is_not_null` until #2942 gave the builder `startsWith`/`endsWith`/
* `isNull`/`isNotNull`.
*
* Shrink it by adding the operator to the FilterBuilder, never by mapping onto a
* near-equivalent: `is_null` → `isEmpty` would rewrite a NULL predicate into an
* empty-string one on the next save.
*/
const NO_BUILDER_EQUIVALENT: string[] = [];
/** Case- and separator-insensitive key, matching the module's own `fold`. */
const fold = (op: string) => op.toLowerCase().replace(/[\s_-]+/g, '');
describe('CANONICAL_TO_BUILDER', () => {
it('covers every canonical view operator, and nothing else', () => {
expect(Object.keys(__CANONICAL_TO_BUILDER).sort()).toEqual([...VIEW_FILTER_OPERATORS].sort());
});
it('maps onto operator ids the FilterBuilder actually renders', () => {
const builderIds = new Set(FILTER_BUILDER_OPERATORS);
const notRenderable = Object.entries(__CANONICAL_TO_BUILDER)
.filter(([, id]) => id !== null && !builderIds.has(id as never))
.map(([op, id]) => `${op} -> ${id}`);
expect(notRenderable).toEqual([]);
});
/**
* The guard that catches drift in the direction the hand-kept gap list could
* not: the builder GAINING an operator this table still calls unmappable.
* `starts_with` and `startsWith` fold to the same key, so an unmapped operator
* whose folded name matches a folded builder id is an omission by definition —
* which is exactly how #2942's four new operators went unnoticed here.
*/
it('leaves nothing unmapped that the builder can already draw', () => {
const byFolded = new Map(FILTER_BUILDER_OPERATORS.map(id => [fold(id), id]));
const missed = Object.entries(__CANONICAL_TO_BUILDER)
.filter(([, id]) => id === null)
.filter(([op]) => byFolded.has(fold(op)))
.map(([op]) => `${op} -> ${byFolded.get(fold(op))} exists but is unmapped`);
expect(missed).toEqual([]);
});
it('records exactly the documented gaps', () => {
const unmapped = Object.entries(__CANONICAL_TO_BUILDER)
.filter(([, id]) => id === null)
.map(([op]) => op);
expect(unmapped.sort()).toEqual([...NO_BUILDER_EQUIVALENT].sort());
});
it('keeps the NULL and empty-string predicates distinct', () => {
expect(__CANONICAL_TO_BUILDER.is_null).toBe('isNull');
expect(__CANONICAL_TO_BUILDER.is_not_null).toBe('isNotNull');
expect(__CANONICAL_TO_BUILDER.is_empty).toBe('isEmpty');
expect(__CANONICAL_TO_BUILDER.is_not_empty).toBe('isNotEmpty');
});
});
describe('specToBuilderOperator', () => {
it('resolves every canonical view operator that has an equivalent', () => {
const builderIds = new Set(FILTER_BUILDER_OPERATORS);
const unresolved = VIEW_FILTER_OPERATORS
.filter(op => !NO_BUILDER_EQUIVALENT.includes(op))
.filter(op => !builderIds.has(specToBuilderOperator(op) as never));
expect(unresolved).toEqual([]);
});
it('resolves every legacy alias the spec still folds', () => {
const builderIds = new Set(FILTER_BUILDER_OPERATORS);
const unresolved = Object.entries(VIEW_FILTER_OPERATOR_ALIASES)
.filter(([, canonical]) => !NO_BUILDER_EQUIVALENT.includes(canonical))
.filter(([alias]) => !builderIds.has(specToBuilderOperator(alias) as never))
.map(([alias]) => alias);
expect(unresolved).toEqual([]);
});
it('resolves the infix spellings a stored filter array carries', () => {
expect(specToBuilderOperator('=')).toBe('equals');
expect(specToBuilderOperator('==')).toBe('equals');
expect(specToBuilderOperator('!=')).toBe('notEquals');
expect(specToBuilderOperator('<>')).toBe('notEquals');
expect(specToBuilderOperator('>')).toBe('greaterThan');
expect(specToBuilderOperator('<')).toBe('lessThan');
expect(specToBuilderOperator('>=')).toBe('greaterOrEqual');
expect(specToBuilderOperator('<=')).toBe('lessOrEqual');
expect(specToBuilderOperator('nin')).toBe('notIn');
expect(specToBuilderOperator('like')).toBe('contains');
});
it('folds case and separators, so one spelling class is one entry', () => {
for (const spelling of ['not_in', 'notIn', 'not in', 'NOT_IN', 'not-in', 'notin']) {
expect(specToBuilderOperator(spelling)).toBe('notIn');
}
for (const spelling of ['greater_than_or_equal', 'greaterThanOrEqual', 'greaterorequal']) {
expect(specToBuilderOperator(spelling)).toBe('greaterOrEqual');
}
});
it('returns an unrecognised operator unchanged rather than coercing it', () => {
// Visible in the UI as an incomplete condition row beats a silent rewrite
// to `equals`, which would drop the author's predicate on the next save.
expect(specToBuilderOperator('totally_made_up')).toBe('totally_made_up');
expect(specToBuilderOperator('$regex')).toBe('$regex');
});
it('resolves the four operators #2942 made expressible', () => {
expect(specToBuilderOperator('starts_with')).toBe('startsWith');
expect(specToBuilderOperator('ends_with')).toBe('endsWith');
expect(specToBuilderOperator('is_null')).toBe('isNull');
expect(specToBuilderOperator('is_not_null')).toBe('isNotNull');
// …without collapsing them onto the empty-string pair.
expect(specToBuilderOperator('is_empty')).toBe('isEmpty');
expect(specToBuilderOperator('is_not_empty')).toBe('isNotEmpty');
});
it('defaults an absent operator to equals', () => {
expect(specToBuilderOperator('')).toBe('equals');
expect(specToBuilderOperator(undefined as unknown as string)).toBe('equals');
});
});