-
Notifications
You must be signed in to change notification settings - Fork 5
Expand file tree
/
Copy pathprotocol.graft-normalized-operators.test.ts
More file actions
152 lines (138 loc) · 6.98 KB
/
Copy pathprotocol.graft-normalized-operators.test.ts
File metadata and controls
152 lines (138 loc) · 6.98 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
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
/**
* `saveMeta` persists the operator spellings the spec normalized (objectui#2945).
*
* `ViewFilterRuleSchema.operator` is `z.preprocess(normalizeFilterOperator, …)`,
* so validation folds `notEquals`/`gt`/`isNull` to canonical — and the result
* used to be discarded, because the authored body is persisted verbatim. Every
* save therefore minted fresh `VIEW_FILTER_OPERATOR_ALIASES` rows, and an alias
* table that keeps growing can never be retired.
*
* `graftNormalizedOperators` copies the normalization back on and nothing else.
* The tests below pin both halves of that claim: operators DO change, and
* everything else — auxiliary fields, unknown keys, `$`-token conditions, array
* order, absent optionals — does NOT.
*
* Parsing goes through `ViewMetadataSchema`, which is what
* `getMetadataTypeSchema('view')` returns and therefore what `saveMetaItem`
* actually validates against.
*/
import { describe, it, expect } from 'vitest';
import { ViewMetadataSchema, VIEW_FILTER_OPERATORS, VIEW_FILTER_OPERATOR_ALIASES } from '@objectstack/spec/ui';
import { graftNormalizedOperators } from './protocol.js';
/** Graft through the real spec schema, the way `saveMetaItem` does. */
function graftThroughSchema(authored: unknown): unknown {
const parsed = (ViewMetadataSchema as unknown as {
safeParse: (v: unknown) => { success: boolean; data?: unknown; error?: unknown };
}).safeParse(authored);
expect(parsed.success, JSON.stringify(parsed.error)).toBe(true);
return graftNormalizedOperators(authored, parsed.data);
}
/** Flattened runtime view overlay — the shape a console personalization PUT sends. */
const view = (filter: unknown, extra: Record<string, unknown> = {}) => ({
name: 'showcase_task.open',
object: 'showcase_task',
viewKind: 'list' as const,
label: 'Open',
type: 'grid' as const,
columns: ['name'],
filter,
...extra,
});
describe('graftNormalizedOperators — through the real view metadata schema', () => {
it('canonicalizes a legacy operator spelling on the persisted body', () => {
const authored = view([{ field: 'status', operator: 'notEquals', value: 'done' }]);
const out = graftThroughSchema(authored) as { filter: Array<{ operator: string }> };
expect(out.filter[0].operator).toBe('not_equals');
});
it('canonicalizes every alias the spec still folds', () => {
const canonical = new Set<string>(VIEW_FILTER_OPERATORS);
const stillLegacy: string[] = [];
for (const alias of Object.keys(VIEW_FILTER_OPERATOR_ALIASES)) {
const out = graftThroughSchema(
view([{ field: 'status', operator: alias, value: 'x' }]),
) as { filter: Array<{ operator: string }> };
if (!canonical.has(out.filter[0].operator)) stillLegacy.push(alias);
}
expect(stillLegacy).toEqual([]);
});
it('leaves a canonical operator identical — same object, nothing allocated', () => {
const authored = view([{ field: 'status', operator: 'not_equals', value: 'done' }]);
expect(graftThroughSchema(authored)).toBe(authored);
});
it('normalizes a tab filter, not just the view filter', () => {
const authored = view(
[{ field: 'status', operator: 'eq', value: 'open' }],
{ tabs: [{ name: 'mine', label: 'Mine', filter: [{ field: 'owner', operator: 'isNotNull' }] }] },
);
const out = graftThroughSchema(authored) as {
filter: Array<{ operator: string }>;
tabs: Array<{ filter: Array<{ operator: string }> }>;
};
expect(out.filter[0].operator).toBe('equals');
expect(out.tabs[0].filter[0].operator).toBe('is_not_null');
});
});
describe('graftNormalizedOperators — what it must never touch', () => {
it('keeps Studio-only auxiliary fields a `parsed.data` swap would strip', () => {
// The exact reason saveMeta persists verbatim (ADR-0005 §Validation).
const authored = view(
[{ field: 'status', operator: 'gt', value: 1 }],
{ isPinned: true, isDefault: false, sortOrder: 3 },
);
const out = graftThroughSchema(authored) as Record<string, unknown>;
expect(out.isPinned).toBe(true);
expect(out.isDefault).toBe(false);
expect(out.sortOrder).toBe(3);
expect((out.filter as Array<{ operator: string }>)[0].operator).toBe('greater_than');
});
it('adds no defaults and no keys the author did not write', () => {
const authored = view([{ field: 'status', operator: 'ne', value: 'done' }]);
const out = graftThroughSchema(authored) as Record<string, unknown>;
expect(Object.keys(out).sort()).toEqual(Object.keys(authored).sort());
// The unary form carries no `value`; grafting must not materialize one,
// even though the schema's own output would.
const unary = view([{ field: 'owner', operator: 'isNull' }]);
const unaryOut = graftThroughSchema(unary) as { filter: Array<Record<string, unknown>> };
expect(Object.keys(unaryOut.filter[0]).sort()).toEqual(['field', 'operator']);
});
it('preserves filter order', () => {
const authored = view([
{ field: 'a', operator: 'eq', value: 1 },
{ field: 'b', operator: 'gt', value: 2 },
{ field: 'c', operator: 'notIn', value: ['x'] },
]);
const out = graftThroughSchema(authored) as { filter: Array<{ field: string; operator: string }> };
expect(out.filter.map(f => f.field)).toEqual(['a', 'b', 'c']);
expect(out.filter.map(f => f.operator)).toEqual(['equals', 'greater_than', 'not_in']);
});
});
describe('graftNormalizedOperators — structural safety, no schema involved', () => {
it('rewrites only an `operator` string, never a nested object', () => {
// A `$`-token FilterCondition is a DIFFERENT operator vocabulary. Even if a
// parsed tree somehow offered a string here, the object must survive.
const authored = { criteria: { operator: { $eq: 'active' } } };
const parsed = { criteria: { operator: 'equals' } };
expect(graftNormalizedOperators(authored, parsed)).toBe(authored);
});
it('ignores a parsed tree whose shape does not match', () => {
const authored = { filter: [{ field: 'a', operator: 'eq' }] };
expect(graftNormalizedOperators(authored, { filter: 'not-an-array' })).toBe(authored);
expect(graftNormalizedOperators(authored, undefined)).toBe(authored);
expect(graftNormalizedOperators(authored, null)).toBe(authored);
});
it('does not read past the authored array — a longer parsed array adds nothing', () => {
const authored = { filter: [{ field: 'a', operator: 'eq' }] };
const out = graftNormalizedOperators(authored, {
filter: [{ field: 'a', operator: 'equals' }, { field: 'b', operator: 'equals' }],
}) as { filter: unknown[] };
expect(out.filter).toHaveLength(1);
});
it('passes primitives and empty structures through unchanged', () => {
expect(graftNormalizedOperators('x', 'y')).toBe('x');
expect(graftNormalizedOperators(7, 8)).toBe(7);
expect(graftNormalizedOperators(null, { a: 1 })).toBe(null);
const empty = {};
expect(graftNormalizedOperators(empty, { a: 1 })).toBe(empty);
});
});