|
1 | 1 | import {describe, it, expect} from 'vitest'; |
2 | 2 | import {FieldMask} from '../../src/wkt'; |
3 | | -import type {FieldPaths} from '../../src/wkt'; |
| 3 | +import type {FieldMaskSchema} from '../../src/wkt'; |
4 | 4 |
|
5 | | -// Simulates a class instance with methods (e.g. Temporal.Instant). |
6 | | -interface ClassLike { |
7 | | - epochMilliseconds: number; |
8 | | - toString(): string; |
9 | | -} |
| 5 | +// Alert-like non-cyclic schema with nested Condition + Operand, used to drive FieldMask through its `@internal` build entry point. |
| 6 | +const operandSchema: FieldMaskSchema = { |
| 7 | + column: {wire: 'column'}, |
| 8 | + value: {wire: 'value'}, |
| 9 | +}; |
| 10 | +const conditionSchema: FieldMaskSchema = { |
| 11 | + op: {wire: 'op'}, |
| 12 | + operand: {wire: 'operand', children: () => operandSchema}, |
| 13 | +}; |
| 14 | +const alertSchema: FieldMaskSchema = { |
| 15 | + displayName: {wire: 'display_name'}, |
| 16 | + condition: {wire: 'condition', children: () => conditionSchema}, |
| 17 | +}; |
10 | 18 |
|
11 | | -// Test interface for FieldPaths derivation. |
12 | | -interface Cluster { |
13 | | - name: string; |
14 | | - displayName: string; |
15 | | - state: string; |
16 | | - config: { |
17 | | - numWorkers: number; |
18 | | - scaling: { |
19 | | - minReplicas: number; |
20 | | - maxReplicas: number; |
21 | | - }; |
22 | | - }; |
23 | | - tags: string[]; |
24 | | - labels: Record<string, string>; |
25 | | - createTime?: ClassLike; |
26 | | -} |
| 19 | +// Self-referential schema used to verify cycle-safe construction. |
| 20 | +const nodeSchema: FieldMaskSchema = { |
| 21 | + label: {wire: 'label'}, |
| 22 | + child: {wire: 'child', children: () => nodeSchema}, |
| 23 | +}; |
27 | 24 |
|
28 | | -// Verify FieldPaths derives correct paths at compile time. |
29 | | -type ClusterPaths = FieldPaths<Cluster>; |
30 | | -const _checkPaths: ClusterPaths[] = [ |
31 | | - 'name', |
32 | | - 'displayName', |
33 | | - 'state', |
34 | | - 'config', |
35 | | - 'config.numWorkers', |
36 | | - 'config.scaling', |
37 | | - 'config.scaling.minReplicas', |
38 | | - 'config.scaling.maxReplicas', |
39 | | - 'tags', |
40 | | - 'labels', |
41 | | - 'createTime', // Leaf — ClassLike has methods, so no recursion. |
42 | | -]; |
43 | | -// Suppress unused variable warning. |
44 | | -void _checkPaths; |
45 | | - |
46 | | -// Verify that ClassLike properties are NOT included as paths. |
47 | | -// If FieldPaths recursed into ClassLike, "createTime.epochMilliseconds" would |
48 | | -// be a valid path. This assignment must fail at compile time. |
49 | | -// @ts-expect-error - ClassLike is a leaf; its properties are not valid paths. |
50 | | -const _badPath: ClusterPaths = 'createTime.epochMilliseconds'; |
51 | | -void _badPath; |
52 | | - |
53 | | -describe('FieldMask', () => { |
54 | | - describe('of', () => { |
55 | | - const testCases: {name: string; input: string[]; want: string[]}[] = [ |
56 | | - {name: 'single path', input: ['name'], want: ['name']}, |
| 25 | +describe('FieldMask.build', () => { |
| 26 | + describe('valid paths translate and serialize', () => { |
| 27 | + const cases: { |
| 28 | + name: string; |
| 29 | + schema: FieldMaskSchema; |
| 30 | + input: string[]; |
| 31 | + want: string; |
| 32 | + }[] = [ |
| 33 | + {name: 'flat path', schema: alertSchema, input: ['displayName'], want: 'display_name'}, |
| 34 | + {name: 'nested path', schema: alertSchema, input: ['condition.op'], want: 'condition.op'}, |
57 | 35 | { |
58 | | - name: 'multiple paths', |
59 | | - input: ['displayName', 'name'], |
60 | | - want: ['displayName', 'name'], |
| 36 | + name: 'deeply nested path', |
| 37 | + schema: alertSchema, |
| 38 | + input: ['condition.operand.column'], |
| 39 | + want: 'condition.operand.column', |
61 | 40 | }, |
62 | 41 | { |
63 | | - name: 'deduplicates paths', |
64 | | - input: ['name', 'name', 'state'], |
65 | | - want: ['name', 'state'], |
| 42 | + name: 'multiple paths joined in sorted order', |
| 43 | + schema: alertSchema, |
| 44 | + input: ['displayName', 'condition.op'], |
| 45 | + want: 'condition.op,display_name', |
66 | 46 | }, |
67 | 47 | { |
68 | | - name: 'removes paths subsumed by a parent', |
69 | | - input: ['config.numWorkers', 'config', 'name'], |
70 | | - want: ['config', 'name'], |
| 48 | + name: 'duplicates collapse before translation', |
| 49 | + schema: alertSchema, |
| 50 | + input: ['displayName', 'displayName'], |
| 51 | + want: 'display_name', |
71 | 52 | }, |
72 | 53 | { |
73 | | - name: 'removes deeply subsumed paths', |
74 | | - input: ['config.scaling.minReplicas', 'config.scaling', 'config'], |
75 | | - want: ['config'], |
| 54 | + name: 'children subsumed by a parent are dropped', |
| 55 | + schema: alertSchema, |
| 56 | + input: ['condition.op', 'condition'], |
| 57 | + want: 'condition', |
76 | 58 | }, |
77 | 59 | { |
78 | | - name: 'does not subsume paths sharing a prefix without a dot boundary', |
79 | | - input: ['foo', 'foobar'], |
80 | | - want: ['foo', 'foobar'], |
| 60 | + name: 'empty input serializes to empty string', |
| 61 | + schema: alertSchema, |
| 62 | + input: [], |
| 63 | + want: '', |
| 64 | + }, |
| 65 | + { |
| 66 | + name: 'arbitrary depth through a self-cycle', |
| 67 | + schema: nodeSchema, |
| 68 | + input: ['child.child.child.label'], |
| 69 | + want: 'child.child.child.label', |
81 | 70 | }, |
82 | | - {name: 'empty mask', input: [], want: []}, |
83 | 71 | ]; |
84 | 72 |
|
85 | | - it.each(testCases)('$name', ({input, want}) => { |
86 | | - const mask = FieldMask.of(...input); |
87 | | - expect(mask.paths).toStrictEqual(want); |
| 73 | + it.each(cases)('$name', ({schema, input, want}) => { |
| 74 | + const mask = FieldMask.build<unknown>(input, schema); |
| 75 | + expect(mask.toString()).toBe(want); |
88 | 76 | }); |
89 | 77 | }); |
90 | 78 |
|
91 | | - describe('append', () => { |
92 | | - const testCases: { |
| 79 | + describe('invalid paths throw', () => { |
| 80 | + const cases: { |
93 | 81 | name: string; |
94 | | - initial: string[]; |
95 | | - append: string[]; |
96 | | - want: string[]; |
| 82 | + schema: FieldMaskSchema; |
| 83 | + input: string[]; |
| 84 | + msg: string; |
97 | 85 | }[] = [ |
98 | 86 | { |
99 | | - name: 'adds new paths', |
100 | | - initial: ['name'], |
101 | | - append: ['state'], |
102 | | - want: ['name', 'state'], |
| 87 | + name: 'unknown top-level field', |
| 88 | + schema: alertSchema, |
| 89 | + input: ['bogus'], |
| 90 | + msg: 'Unknown field path "bogus"', |
103 | 91 | }, |
104 | 92 | { |
105 | | - name: 'deduplicates when appending existing paths', |
106 | | - initial: ['name', 'state'], |
107 | | - append: ['name'], |
108 | | - want: ['name', 'state'], |
| 93 | + name: 'unknown nested field', |
| 94 | + schema: alertSchema, |
| 95 | + input: ['condition.bogus'], |
| 96 | + msg: 'Unknown field path "condition.bogus"', |
109 | 97 | }, |
110 | 98 | { |
111 | | - name: 'subsumes child when parent is appended', |
112 | | - initial: ['config.numWorkers'], |
113 | | - append: ['config'], |
114 | | - want: ['config'], |
| 99 | + name: 'descent past a scalar leaf', |
| 100 | + schema: alertSchema, |
| 101 | + input: ['displayName.nope'], |
| 102 | + msg: 'Unknown field path "displayName.nope"', |
115 | 103 | }, |
116 | 104 | ]; |
117 | 105 |
|
118 | | - it.each(testCases)('$name', ({initial, append, want}) => { |
119 | | - const mask = FieldMask.of(...initial).append(...append); |
120 | | - expect(mask.paths).toStrictEqual(want); |
121 | | - }); |
122 | | - }); |
123 | | - |
124 | | - describe('type safety with FieldPaths', () => { |
125 | | - it('works with derived FieldPaths type', () => { |
126 | | - const mask = FieldMask.of<FieldPaths<Cluster>>( |
127 | | - 'displayName', |
128 | | - 'config.numWorkers' |
129 | | - ); |
130 | | - expect(mask.paths).toStrictEqual(['config.numWorkers', 'displayName']); |
| 106 | + it.each(cases)('$name', ({schema, input, msg}) => { |
| 107 | + expect(() => FieldMask.build<unknown>(input, schema)).toThrowError(msg); |
131 | 108 | }); |
132 | 109 | }); |
133 | 110 | }); |
0 commit comments