@featurevisor/core 2.13.0 → 2.14.0

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.
Files changed (80) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/coverage/clover.xml +906 -557
  3. package/coverage/coverage-final.json +11 -8
  4. package/coverage/lcov-report/builder/allocator.ts.html +1 -1
  5. package/coverage/lcov-report/builder/buildScopedConditions.ts.html +1 -1
  6. package/coverage/lcov-report/builder/buildScopedDatafile.ts.html +1 -1
  7. package/coverage/lcov-report/builder/buildScopedSegments.ts.html +1 -1
  8. package/coverage/lcov-report/builder/index.html +38 -8
  9. package/coverage/lcov-report/builder/mutateVariables.ts.html +400 -0
  10. package/coverage/lcov-report/builder/mutator.ts.html +796 -0
  11. package/coverage/lcov-report/builder/revision.ts.html +1 -1
  12. package/coverage/lcov-report/builder/traffic.ts.html +1 -1
  13. package/coverage/lcov-report/config/index.html +1 -1
  14. package/coverage/lcov-report/config/projectConfig.ts.html +1 -1
  15. package/coverage/lcov-report/datasource/adapter.ts.html +1 -1
  16. package/coverage/lcov-report/datasource/datasource.ts.html +4 -4
  17. package/coverage/lcov-report/datasource/filesystemAdapter.ts.html +23 -23
  18. package/coverage/lcov-report/datasource/index.html +1 -1
  19. package/coverage/lcov-report/datasource/index.ts.html +1 -1
  20. package/coverage/lcov-report/index.html +28 -28
  21. package/coverage/lcov-report/linter/attributeSchema.ts.html +1 -1
  22. package/coverage/lcov-report/linter/checkCircularDependency.ts.html +1 -1
  23. package/coverage/lcov-report/linter/checkPercentageExceedingSlot.ts.html +1 -1
  24. package/coverage/lcov-report/linter/conditionSchema.ts.html +7 -7
  25. package/coverage/lcov-report/linter/featureSchema.ts.html +789 -318
  26. package/coverage/lcov-report/linter/groupSchema.ts.html +1 -1
  27. package/coverage/lcov-report/linter/index.html +34 -19
  28. package/coverage/lcov-report/linter/lintProject.ts.html +25 -25
  29. package/coverage/lcov-report/linter/mutationNotation.ts.html +1309 -0
  30. package/coverage/lcov-report/linter/printError.ts.html +1 -1
  31. package/coverage/lcov-report/linter/schema.ts.html +69 -69
  32. package/coverage/lcov-report/linter/segmentSchema.ts.html +1 -1
  33. package/coverage/lcov-report/linter/testSchema.ts.html +5 -5
  34. package/coverage/lcov-report/list/index.html +1 -1
  35. package/coverage/lcov-report/list/matrix.ts.html +1 -1
  36. package/coverage/lcov-report/parsers/index.html +1 -1
  37. package/coverage/lcov-report/parsers/index.ts.html +1 -1
  38. package/coverage/lcov-report/parsers/json.ts.html +1 -1
  39. package/coverage/lcov-report/parsers/yml.ts.html +2 -2
  40. package/coverage/lcov-report/tester/cliFormat.ts.html +1 -1
  41. package/coverage/lcov-report/tester/helpers.ts.html +1 -1
  42. package/coverage/lcov-report/tester/index.html +1 -1
  43. package/coverage/lcov-report/utils/git.ts.html +1 -1
  44. package/coverage/lcov-report/utils/index.html +1 -1
  45. package/coverage/lcov.info +1839 -1120
  46. package/lib/builder/buildDatafile.js +8 -3
  47. package/lib/builder/buildDatafile.js.map +1 -1
  48. package/lib/builder/mutateVariables.d.ts +14 -0
  49. package/lib/builder/mutateVariables.js +90 -0
  50. package/lib/builder/mutateVariables.js.map +1 -0
  51. package/lib/builder/mutateVariables.spec.d.ts +1 -0
  52. package/lib/builder/mutateVariables.spec.js +1045 -0
  53. package/lib/builder/mutateVariables.spec.js.map +1 -0
  54. package/lib/builder/mutator.d.ts +20 -0
  55. package/lib/builder/mutator.js +223 -0
  56. package/lib/builder/mutator.js.map +1 -0
  57. package/lib/builder/mutator.spec.d.ts +1 -0
  58. package/lib/builder/mutator.spec.js +368 -0
  59. package/lib/builder/mutator.spec.js.map +1 -0
  60. package/lib/linter/featureSchema.d.ts +7 -7
  61. package/lib/linter/featureSchema.js +138 -62
  62. package/lib/linter/featureSchema.js.map +1 -1
  63. package/lib/linter/featureSchema.spec.js +150 -0
  64. package/lib/linter/featureSchema.spec.js.map +1 -1
  65. package/lib/linter/mutationNotation.d.ts +47 -0
  66. package/lib/linter/mutationNotation.js +381 -0
  67. package/lib/linter/mutationNotation.js.map +1 -0
  68. package/lib/linter/mutationNotation.spec.d.ts +1 -0
  69. package/lib/linter/mutationNotation.spec.js +549 -0
  70. package/lib/linter/mutationNotation.spec.js.map +1 -0
  71. package/package.json +2 -2
  72. package/src/builder/buildDatafile.ts +29 -3
  73. package/src/builder/mutateVariables.spec.ts +1134 -0
  74. package/src/builder/mutateVariables.ts +105 -0
  75. package/src/builder/mutator.spec.ts +413 -0
  76. package/src/builder/mutator.ts +237 -0
  77. package/src/linter/featureSchema.spec.ts +182 -0
  78. package/src/linter/featureSchema.ts +254 -97
  79. package/src/linter/mutationNotation.spec.ts +642 -0
  80. package/src/linter/mutationNotation.ts +408 -0
@@ -0,0 +1,642 @@
1
+ /**
2
+ * Exhaustive unit tests for mutation notation parsing and validation:
3
+ * isMutationKey, parseMutationKey, resolveSchemaAtPath, parsePathMapKey, validateMutationKey.
4
+ */
5
+ import type { Schema } from "@featurevisor/types";
6
+
7
+ import {
8
+ isMutationKey,
9
+ parseMutationKey,
10
+ parsePathMapKey,
11
+ resolveSchemaAtPath,
12
+ validateMutationKey,
13
+ } from "./mutationNotation";
14
+
15
+ describe("mutationNotation.ts", () => {
16
+ describe("isMutationKey", () => {
17
+ it("returns false for empty string", () => {
18
+ expect(isMutationKey("")).toBe(false);
19
+ });
20
+
21
+ it("returns false for whitespace-only", () => {
22
+ expect(isMutationKey(" ")).toBe(false);
23
+ });
24
+
25
+ it("returns false for plain variable name (no path, no operation)", () => {
26
+ expect(isMutationKey("foo")).toBe(false);
27
+ expect(isMutationKey("title")).toBe(false);
28
+ expect(isMutationKey("config")).toBe(false);
29
+ });
30
+
31
+ it("returns true when key contains dot (path)", () => {
32
+ expect(isMutationKey("foo.bar")).toBe(true);
33
+ expect(isMutationKey("config.width")).toBe(true);
34
+ expect(isMutationKey("a.b.c")).toBe(true);
35
+ });
36
+
37
+ it("returns true when key contains bracket (array index or selector)", () => {
38
+ expect(isMutationKey("items[0]")).toBe(true);
39
+ expect(isMutationKey("tags[1]")).toBe(true);
40
+ expect(isMutationKey("items[id=1]")).toBe(true);
41
+ });
42
+
43
+ it("returns true when key ends with :append", () => {
44
+ expect(isMutationKey("tags:append")).toBe(true);
45
+ expect(isMutationKey("items:append")).toBe(true);
46
+ });
47
+
48
+ it("returns true when key ends with :prepend", () => {
49
+ expect(isMutationKey("tags:prepend")).toBe(true);
50
+ });
51
+
52
+ it("returns true when key ends with :after", () => {
53
+ expect(isMutationKey("items[id=1]:after")).toBe(true);
54
+ });
55
+
56
+ it("returns true when key ends with :before", () => {
57
+ expect(isMutationKey("items[id=1]:before")).toBe(true);
58
+ });
59
+
60
+ it("returns true when key ends with :remove", () => {
61
+ expect(isMutationKey("config.compact:remove")).toBe(true);
62
+ expect(isMutationKey("items[0]:remove")).toBe(true);
63
+ });
64
+
65
+ it("trims input before checking", () => {
66
+ expect(isMutationKey(" config.width ")).toBe(true);
67
+ expect(isMutationKey(" tags:append ")).toBe(true);
68
+ });
69
+ });
70
+
71
+ describe("parseMutationKey", () => {
72
+ it("returns null for empty string", () => {
73
+ expect(parseMutationKey("")).toBeNull();
74
+ });
75
+
76
+ it("returns null for whitespace-only", () => {
77
+ expect(parseMutationKey(" ")).toBeNull();
78
+ });
79
+
80
+ it("parses plain variable name as rootKey only, operation set", () => {
81
+ const r = parseMutationKey("foo");
82
+ expect(r).not.toBeNull();
83
+ expect(r!.rootKey).toBe("foo");
84
+ expect(r!.pathSegments).toEqual([]);
85
+ expect(r!.allSegments).toEqual([{ key: "foo" }]);
86
+ expect(r!.operation).toBe("set");
87
+ });
88
+
89
+ it("parses dot path: rootKey and single path segment", () => {
90
+ const r = parseMutationKey("config.width");
91
+ expect(r).not.toBeNull();
92
+ expect(r!.rootKey).toBe("config");
93
+ expect(r!.pathSegments).toEqual([{ key: "width" }]);
94
+ expect(r!.allSegments).toEqual([{ key: "config" }, { key: "width" }]);
95
+ expect(r!.operation).toBe("set");
96
+ });
97
+
98
+ it("parses deep dot path", () => {
99
+ const r = parseMutationKey("settings.display.fontSize");
100
+ expect(r).not.toBeNull();
101
+ expect(r!.rootKey).toBe("settings");
102
+ expect(r!.pathSegments).toEqual([{ key: "display" }, { key: "fontSize" }]);
103
+ expect(r!.allSegments).toEqual([
104
+ { key: "settings" },
105
+ { key: "display" },
106
+ { key: "fontSize" },
107
+ ]);
108
+ });
109
+
110
+ it("parses array index notation", () => {
111
+ const r = parseMutationKey("items[0]");
112
+ expect(r).not.toBeNull();
113
+ expect(r!.rootKey).toBe("items");
114
+ expect(r!.pathSegments).toEqual([{ key: "", index: 0 }]);
115
+ expect(r!.allSegments).toEqual([{ key: "items", index: 0 }]);
116
+ expect(r!.operation).toBe("set");
117
+ });
118
+
119
+ it("parses array index then property", () => {
120
+ const r = parseMutationKey("items[0].name");
121
+ expect(r).not.toBeNull();
122
+ expect(r!.rootKey).toBe("items");
123
+ expect(r!.pathSegments).toEqual([{ key: "", index: 0 }, { key: "name" }]);
124
+ expect(r!.allSegments).toEqual([{ key: "items", index: 0 }, { key: "name" }]);
125
+ });
126
+
127
+ it("parses selector notation", () => {
128
+ const r = parseMutationKey("items[id=1]");
129
+ expect(r).not.toBeNull();
130
+ expect(r!.rootKey).toBe("items");
131
+ expect(r!.pathSegments).toEqual([{ key: "", selector: { prop: "id", value: "1" } }]);
132
+ expect(r!.allSegments).toEqual([{ key: "items", selector: { prop: "id", value: "1" } }]);
133
+ });
134
+
135
+ it("parses key starting with bracket (root-level array segment)", () => {
136
+ const r = parseMutationKey("[0].name");
137
+ expect(r).not.toBeNull();
138
+ expect(r!.rootKey).toBe("");
139
+ expect(r!.pathSegments).toEqual([{ key: "", index: 0 }, { key: "name" }]);
140
+ expect(r!.allSegments).toEqual([{ key: "", index: 0 }, { key: "name" }]);
141
+ });
142
+
143
+ it("parses :append operation", () => {
144
+ const r = parseMutationKey("tags:append");
145
+ expect(r).not.toBeNull();
146
+ expect(r!.rootKey).toBe("tags");
147
+ expect(r!.pathSegments).toEqual([]);
148
+ expect(r!.operation).toBe("append");
149
+ });
150
+
151
+ it("parses :prepend operation", () => {
152
+ const r = parseMutationKey("tags:prepend");
153
+ expect(r).not.toBeNull();
154
+ expect(r!.rootKey).toBe("tags");
155
+ expect(r!.operation).toBe("prepend");
156
+ });
157
+
158
+ it("parses :remove on property", () => {
159
+ const r = parseMutationKey("config.compact:remove");
160
+ expect(r).not.toBeNull();
161
+ expect(r!.rootKey).toBe("config");
162
+ expect(r!.pathSegments).toEqual([{ key: "compact" }]);
163
+ expect(r!.operation).toBe("remove");
164
+ });
165
+
166
+ it("parses :remove on array index", () => {
167
+ const r = parseMutationKey("items[2]:remove");
168
+ expect(r).not.toBeNull();
169
+ expect(r!.rootKey).toBe("items");
170
+ expect(r!.pathSegments).toEqual([{ key: "", index: 2 }]);
171
+ expect(r!.operation).toBe("remove");
172
+ });
173
+
174
+ it("parses :after with selector", () => {
175
+ const r = parseMutationKey("items[id=2]:after");
176
+ expect(r).not.toBeNull();
177
+ expect(r!.rootKey).toBe("items");
178
+ expect(r!.pathSegments).toEqual([{ key: "", selector: { prop: "id", value: "2" } }]);
179
+ expect(r!.operation).toBe("after");
180
+ });
181
+
182
+ it("parses :before with selector", () => {
183
+ const r = parseMutationKey("items[id=2]:before");
184
+ expect(r).not.toBeNull();
185
+ expect(r!.operation).toBe("before");
186
+ });
187
+
188
+ it("trims input", () => {
189
+ const r = parseMutationKey(" config.width ");
190
+ expect(r).not.toBeNull();
191
+ expect(r!.rootKey).toBe("config");
192
+ expect(r!.pathSegments).toEqual([{ key: "width" }]);
193
+ });
194
+ });
195
+
196
+ describe("resolveSchemaAtPath", () => {
197
+ const objectSchema: Schema = {
198
+ type: "object",
199
+ properties: {
200
+ width: { type: "integer" },
201
+ theme: { type: "string" },
202
+ nested: {
203
+ type: "object",
204
+ properties: {
205
+ deep: { type: "string" },
206
+ },
207
+ },
208
+ },
209
+ };
210
+
211
+ const arraySchema: Schema = {
212
+ type: "array",
213
+ items: {
214
+ type: "object",
215
+ properties: {
216
+ id: { type: "string" },
217
+ name: { type: "string" },
218
+ },
219
+ },
220
+ };
221
+
222
+ it("returns null when variableSchema is null", () => {
223
+ expect(resolveSchemaAtPath(null, [])).toBeNull();
224
+ expect(resolveSchemaAtPath(null, [{ key: "width" }])).toBeNull();
225
+ });
226
+
227
+ it("returns resolved schema for empty path (root)", () => {
228
+ const result = resolveSchemaAtPath(objectSchema, []);
229
+ expect(result).toEqual(objectSchema);
230
+ });
231
+
232
+ it("resolves schema ref when variableSchema has schema key and schemasByKey provided", () => {
233
+ const refSchema: Schema = { type: "object", properties: { x: { type: "string" } } };
234
+ const variableSchema = { schema: "MyRef" as const };
235
+ const result = resolveSchemaAtPath(variableSchema, [], { MyRef: refSchema });
236
+ expect(result).toEqual(refSchema);
237
+ });
238
+
239
+ it("returns unresolved ref object when schema ref is missing in schemasByKey (no resolution)", () => {
240
+ const variableSchema = { schema: "Missing" as const };
241
+ const result = resolveSchemaAtPath(variableSchema, [], {});
242
+ expect(result).toEqual(variableSchema);
243
+ });
244
+
245
+ it("resolves one level of path (object property)", () => {
246
+ const result = resolveSchemaAtPath(objectSchema, [{ key: "width" }]);
247
+ expect(result).toEqual({ type: "integer" });
248
+ });
249
+
250
+ it("resolves nested path", () => {
251
+ const result = resolveSchemaAtPath(objectSchema, [{ key: "nested" }, { key: "deep" }]);
252
+ expect(result).toEqual({ type: "string" });
253
+ });
254
+
255
+ it("returns null when path property does not exist", () => {
256
+ const result = resolveSchemaAtPath(objectSchema, [{ key: "unknown" }]);
257
+ expect(result).toBeNull();
258
+ });
259
+
260
+ it("returns null when stepping into non-object", () => {
261
+ const result = resolveSchemaAtPath(objectSchema, [{ key: "width" }, { key: "foo" }]);
262
+ expect(result).toBeNull();
263
+ });
264
+
265
+ it("resolves array items schema for index segment", () => {
266
+ const result = resolveSchemaAtPath(arraySchema, [{ key: "", index: 0 }]);
267
+ expect(result).toEqual({
268
+ type: "object",
269
+ properties: { id: { type: "string" }, name: { type: "string" } },
270
+ });
271
+ });
272
+
273
+ it("resolves array item then property", () => {
274
+ const result = resolveSchemaAtPath(arraySchema, [{ key: "", index: 0 }, { key: "name" }]);
275
+ expect(result).toEqual({ type: "string" });
276
+ });
277
+
278
+ it("returns null when stepping into non-array with index segment", () => {
279
+ const result = resolveSchemaAtPath(objectSchema, [{ key: "", index: 0 }]);
280
+ expect(result).toBeNull();
281
+ });
282
+
283
+ it("returns null when schema has oneOf (path through oneOf invalid)", () => {
284
+ const oneOfSchema: Schema = {
285
+ oneOf: [{ type: "string" }, { type: "integer" }],
286
+ };
287
+ const result = resolveSchemaAtPath(oneOfSchema, [{ key: "any" }]);
288
+ expect(result).toBeNull();
289
+ });
290
+
291
+ it("resolves nested ref in properties when schemasByKey provided", () => {
292
+ const linkSchema: Schema = { type: "object", properties: { url: { type: "string" } } };
293
+ const root: Schema = {
294
+ type: "object",
295
+ properties: {
296
+ mainLink: { schema: "Link" },
297
+ },
298
+ };
299
+ const result = resolveSchemaAtPath(root, [{ key: "mainLink" }], { Link: linkSchema });
300
+ expect(result).toEqual(linkSchema);
301
+ });
302
+ });
303
+
304
+ describe("parsePathMapKey", () => {
305
+ it("returns null for empty string", () => {
306
+ expect(parsePathMapKey("")).toBeNull();
307
+ });
308
+
309
+ it("returns null when parseMutationKey returns null", () => {
310
+ expect(parsePathMapKey(" ")).toBeNull();
311
+ });
312
+
313
+ it("parses dot path into segments", () => {
314
+ expect(parsePathMapKey("display.fontSize")).toEqual([
315
+ { key: "display" },
316
+ { key: "fontSize" },
317
+ ]);
318
+ });
319
+
320
+ it("parses bracket-first path", () => {
321
+ expect(parsePathMapKey("[0].name")).toEqual([{ key: "", index: 0 }, { key: "name" }]);
322
+ });
323
+
324
+ it("parses single key", () => {
325
+ expect(parsePathMapKey("label")).toEqual([{ key: "label" }]);
326
+ });
327
+
328
+ it("parses deep path", () => {
329
+ expect(parsePathMapKey("config.nested.deep")).toEqual([
330
+ { key: "config" },
331
+ { key: "nested" },
332
+ { key: "deep" },
333
+ ]);
334
+ });
335
+
336
+ it("parses path with array index in middle", () => {
337
+ expect(parsePathMapKey("rows[1].label")).toEqual([
338
+ { key: "rows", index: 1 },
339
+ { key: "label" },
340
+ ]);
341
+ });
342
+ });
343
+
344
+ describe("validateMutationKey", () => {
345
+ const objectSchema: Schema = {
346
+ type: "object",
347
+ properties: {
348
+ theme: { type: "string" },
349
+ compact: { type: "boolean" },
350
+ width: { type: "integer" },
351
+ },
352
+ required: ["compact"],
353
+ };
354
+
355
+ const arraySchema: Schema = {
356
+ type: "array",
357
+ items: { type: "string" },
358
+ };
359
+
360
+ const arrayOfObjectsSchema: Schema = {
361
+ type: "array",
362
+ items: {
363
+ type: "object",
364
+ properties: { id: { type: "string" }, name: { type: "string" } },
365
+ },
366
+ };
367
+
368
+ const nestedObjectSchema: Schema = {
369
+ type: "object",
370
+ properties: {
371
+ config: {
372
+ type: "object",
373
+ properties: {
374
+ width: { type: "integer" },
375
+ optionalKey: { type: "string" },
376
+ },
377
+ required: ["width"],
378
+ },
379
+ },
380
+ };
381
+
382
+ const variableSchemaByKey: Record<string, Schema> = {
383
+ config: objectSchema,
384
+ tags: arraySchema,
385
+ items: arrayOfObjectsSchema,
386
+ settings: nestedObjectSchema,
387
+ };
388
+
389
+ it("returns invalid when key is empty (parseMutationKey null)", () => {
390
+ const r = validateMutationKey("", variableSchemaByKey);
391
+ expect(r.valid).toBe(false);
392
+ expect(r.error).toContain("Invalid mutation notation");
393
+ expect(r.operation).toBe("set");
394
+ });
395
+
396
+ it("returns invalid when key has no root (e.g. [0] only)", () => {
397
+ const r = validateMutationKey("[0]", variableSchemaByKey);
398
+ expect(r.valid).toBe(false);
399
+ expect(r.error).toContain("must start with a variable name");
400
+ });
401
+
402
+ it("returns invalid when root variable is not in variablesSchema", () => {
403
+ const r = validateMutationKey("unknownVar.foo", variableSchemaByKey);
404
+ expect(r.valid).toBe(false);
405
+ expect(r.error).toContain("not defined in");
406
+ expect(r.rootKey).toBe("unknownVar");
407
+ });
408
+
409
+ it("returns invalid when variable has schema ref but path fails (unresolved ref has no properties)", () => {
410
+ const schemas: Record<string, Schema> = {
411
+ myVar: { schema: "MissingRef" },
412
+ };
413
+ const r = validateMutationKey("myVar.foo", schemas, {});
414
+ expect(r.valid).toBe(false);
415
+ expect(r.error).toMatch(/path does not exist|could not be loaded/);
416
+ expect(r.error).toContain("myVar");
417
+ });
418
+
419
+ it("returns invalid when root schema is oneOf and path has segments", () => {
420
+ const schemas: Record<string, Schema> = {
421
+ oneOfVar: { oneOf: [{ type: "string" }, { type: "integer" }] },
422
+ };
423
+ const r = validateMutationKey("oneOfVar.foo", schemas);
424
+ expect(r.valid).toBe(false);
425
+ expect(r.error).toContain("oneOf");
426
+ expect(r.error).toContain("path resolution not defined");
427
+ });
428
+
429
+ describe("operation: set", () => {
430
+ it("valid when setting whole variable (no path)", () => {
431
+ const r = validateMutationKey("config", variableSchemaByKey);
432
+ expect(r.valid).toBe(true);
433
+ expect(r.operation).toBe("set");
434
+ expect(r.valueSchema).toEqual(objectSchema);
435
+ });
436
+
437
+ it("valid when path exists in schema", () => {
438
+ const r = validateMutationKey("config.width", variableSchemaByKey);
439
+ expect(r.valid).toBe(true);
440
+ expect(r.valueSchema).toEqual({ type: "integer" });
441
+ });
442
+
443
+ it("invalid when path does not exist", () => {
444
+ const r = validateMutationKey("config.nonexistent", variableSchemaByKey);
445
+ expect(r.valid).toBe(false);
446
+ expect(r.error).toContain("path does not exist");
447
+ });
448
+
449
+ it("invalid when target schema is oneOf", () => {
450
+ const schemas: Record<string, Schema> = {
451
+ x: {
452
+ type: "object",
453
+ properties: {
454
+ choice: { oneOf: [{ type: "string" }, { type: "integer" }] },
455
+ },
456
+ },
457
+ };
458
+ const r = validateMutationKey("x.choice", schemas);
459
+ expect(r.valid).toBe(false);
460
+ expect(r.error).toContain("oneOf");
461
+ expect(r.error).toContain("mutation target must be a single schema");
462
+ });
463
+ });
464
+
465
+ describe("operation: append / prepend", () => {
466
+ it("valid on root array variable", () => {
467
+ const rAppend = validateMutationKey("tags:append", variableSchemaByKey);
468
+ expect(rAppend.valid).toBe(true);
469
+ expect(rAppend.operation).toBe("append");
470
+ expect(rAppend.valueSchema).toEqual({ type: "string" });
471
+
472
+ const rPrepend = validateMutationKey("tags:prepend", variableSchemaByKey);
473
+ expect(rPrepend.valid).toBe(true);
474
+ expect(rPrepend.operation).toBe("prepend");
475
+ expect(rPrepend.valueSchema).toEqual({ type: "string" });
476
+ });
477
+
478
+ it("invalid when path does not exist", () => {
479
+ const r = validateMutationKey("config.nested:append", variableSchemaByKey);
480
+ expect(r.valid).toBe(false);
481
+ expect(r.error).toContain("path does not exist");
482
+ });
483
+
484
+ it("invalid when path points to non-array", () => {
485
+ const r = validateMutationKey("config.width:append", variableSchemaByKey);
486
+ expect(r.valid).toBe(false);
487
+ expect(r.error).toContain("only allowed on array");
488
+ expect(r.error).toContain("does not point to an array");
489
+ });
490
+
491
+ it("valid when array items is oneOf (valueSchema is the oneOf schema)", () => {
492
+ const oneOfItems: Schema = {
493
+ oneOf: [{ type: "string" }, { type: "integer" }],
494
+ };
495
+ const schemas: Record<string, Schema> = {
496
+ arr: {
497
+ type: "array",
498
+ items: oneOfItems,
499
+ },
500
+ };
501
+ const r = validateMutationKey("arr:append", schemas);
502
+ expect(r.valid).toBe(true);
503
+ expect(r.valueSchema).toEqual(oneOfItems);
504
+ });
505
+
506
+ it("valid on nested array path when schema has array at that path", () => {
507
+ const schemas: Record<string, Schema> = {
508
+ root: {
509
+ type: "object",
510
+ properties: {
511
+ list: { type: "array", items: { type: "string" } },
512
+ },
513
+ },
514
+ };
515
+ const r = validateMutationKey("root.list:append", schemas);
516
+ expect(r.valid).toBe(true);
517
+ expect(r.valueSchema).toEqual({ type: "string" });
518
+ });
519
+ });
520
+
521
+ describe("operation: after / before", () => {
522
+ it("valid on array with index segment", () => {
523
+ const r = validateMutationKey("items[1]:after", variableSchemaByKey);
524
+ expect(r.valid).toBe(true);
525
+ expect(r.operation).toBe("after");
526
+ expect(r.valueSchema).toEqual({
527
+ type: "object",
528
+ properties: { id: { type: "string" }, name: { type: "string" } },
529
+ });
530
+ });
531
+
532
+ it("valid on array with selector segment", () => {
533
+ const r = validateMutationKey("items[id=2]:before", variableSchemaByKey);
534
+ expect(r.valid).toBe(true);
535
+ expect(r.operation).toBe("before");
536
+ });
537
+
538
+ it("invalid when path does not exist or does not point to array element", () => {
539
+ const r = validateMutationKey("config.fake[0]:after", variableSchemaByKey);
540
+ expect(r.valid).toBe(false);
541
+ expect(r.error).toMatch(/path does not exist|does not point to an array/);
542
+ });
543
+
544
+ it("invalid when array items is oneOf", () => {
545
+ const schemas: Record<string, Schema> = {
546
+ arr: {
547
+ type: "array",
548
+ items: { oneOf: [{ type: "string" }] },
549
+ },
550
+ };
551
+ const r = validateMutationKey("arr[0]:after", schemas);
552
+ expect(r.valid).toBe(false);
553
+ expect(r.error).toContain("oneOf");
554
+ });
555
+ });
556
+
557
+ describe("operation: remove", () => {
558
+ it("valid when removing optional object property", () => {
559
+ const r = validateMutationKey("settings.config.optionalKey:remove", variableSchemaByKey);
560
+ expect(r.valid).toBe(true);
561
+ expect(r.operation).toBe("remove");
562
+ expect(r.valueSchema).toBeNull();
563
+ });
564
+
565
+ it("invalid when removing required object property", () => {
566
+ const r = validateMutationKey("config.compact:remove", variableSchemaByKey);
567
+ expect(r.valid).toBe(false);
568
+ expect(r.error).toContain("Cannot remove required property");
569
+ expect(r.error).toContain("compact");
570
+ expect(r.error).toContain("required");
571
+ });
572
+
573
+ it("invalid when removing required nested property", () => {
574
+ const r = validateMutationKey("settings.config.width:remove", variableSchemaByKey);
575
+ expect(r.valid).toBe(false);
576
+ expect(r.error).toContain("Cannot remove required property");
577
+ expect(r.error).toContain("width");
578
+ });
579
+
580
+ it("valid when removing array element by index", () => {
581
+ const r = validateMutationKey("items[0]:remove", variableSchemaByKey);
582
+ expect(r.valid).toBe(true);
583
+ expect(r.operation).toBe("remove");
584
+ expect(r.valueSchema).toBeNull();
585
+ });
586
+
587
+ it("valid when removing array element by selector", () => {
588
+ const r = validateMutationKey("items[id=1]:remove", variableSchemaByKey);
589
+ expect(r.valid).toBe(true);
590
+ });
591
+
592
+ it("invalid when path does not exist", () => {
593
+ const r = validateMutationKey("config.bad:remove", variableSchemaByKey);
594
+ expect(r.valid).toBe(false);
595
+ expect(r.error).toContain("path does not exist");
596
+ });
597
+ });
598
+
599
+ describe("with schema ref resolution", () => {
600
+ it("valid when variable schema is ref and schemasByKey resolves it", () => {
601
+ const resolvedSchema: Schema = {
602
+ type: "object",
603
+ properties: { title: { type: "string" }, url: { type: "string" } },
604
+ };
605
+ const schemas: Record<string, Schema> = {
606
+ link: { schema: "Link" },
607
+ };
608
+ const r = validateMutationKey("link.title", schemas, { Link: resolvedSchema });
609
+ expect(r.valid).toBe(true);
610
+ expect(r.valueSchema).toEqual({ type: "string" });
611
+ });
612
+
613
+ it("valid set on resolved ref root", () => {
614
+ const resolvedSchema: Schema = { type: "object", properties: { x: { type: "string" } } };
615
+ const schemas: Record<string, Schema> = { v: { schema: "R" } };
616
+ const r = validateMutationKey("v", schemas, { R: resolvedSchema });
617
+ expect(r.valid).toBe(true);
618
+ expect(r.valueSchema).toEqual(resolvedSchema);
619
+ });
620
+ });
621
+
622
+ describe("edge cases", () => {
623
+ it("valid for nested path set when all steps exist", () => {
624
+ const r = validateMutationKey("settings.config.width", variableSchemaByKey);
625
+ expect(r.valid).toBe(true);
626
+ expect(r.valueSchema).toEqual({ type: "integer" });
627
+ });
628
+
629
+ it("invalid for path into primitive (no properties)", () => {
630
+ const r = validateMutationKey("config.theme.foo", variableSchemaByKey);
631
+ expect(r.valid).toBe(false);
632
+ });
633
+
634
+ it("returns correct pathSegments and rootKey in result", () => {
635
+ const r = validateMutationKey("config.theme", variableSchemaByKey);
636
+ expect(r.valid).toBe(true);
637
+ expect(r.rootKey).toBe("config");
638
+ expect(r.pathSegments).toEqual([{ key: "theme" }]);
639
+ });
640
+ });
641
+ });
642
+ });