@fogpipe/forma-core 0.10.4 → 0.11.2

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,530 @@
1
+ /**
2
+ * Tests for visibility engine - option visibility
3
+ */
4
+
5
+ import { describe, it, expect } from "vitest";
6
+ import { getOptionsVisibility, getVisibleOptions } from "../engine/visibility.js";
7
+ import type { Forma, SelectOption } from "../types.js";
8
+
9
+ /**
10
+ * Helper to create a minimal Forma spec for testing
11
+ */
12
+ function createTestSpec(options: {
13
+ fields?: Record<string, unknown>;
14
+ computed?: Record<string, { expression: string }>;
15
+ referenceData?: Record<string, unknown>;
16
+ } = {}): Forma {
17
+ const { fields = {}, computed, referenceData } = options;
18
+
19
+ // Build fieldOrder from fields keys
20
+ const fieldOrder = Object.keys(fields);
21
+
22
+ return {
23
+ version: "1.0",
24
+ meta: { id: "test", title: "Test" },
25
+ schema: {
26
+ type: "object",
27
+ properties: {},
28
+ },
29
+ fields: fields as Forma["fields"],
30
+ fieldOrder,
31
+ computed,
32
+ referenceData,
33
+ };
34
+ }
35
+
36
+ // ============================================================================
37
+ // getOptionsVisibility (Batch Computation)
38
+ // ============================================================================
39
+
40
+ describe("getOptionsVisibility", () => {
41
+ describe("top-level select fields", () => {
42
+ it("should compute visible options for all select fields", () => {
43
+ const spec = createTestSpec({
44
+ fields: {
45
+ department: {
46
+ type: "select",
47
+ options: [
48
+ { value: "eng", label: "Engineering" },
49
+ { value: "hr", label: "HR" },
50
+ ],
51
+ },
52
+ level: {
53
+ type: "select",
54
+ options: [
55
+ { value: "junior", label: "Junior" },
56
+ { value: "senior", label: "Senior", visibleWhen: "experience >= 5" },
57
+ ],
58
+ },
59
+ },
60
+ });
61
+
62
+ const result = getOptionsVisibility({ experience: 3 }, spec);
63
+
64
+ // Department has all options (no visibleWhen)
65
+ expect(result["department"]).toHaveLength(2);
66
+
67
+ // Level only shows junior (senior requires experience >= 5)
68
+ expect(result["level"]).toHaveLength(1);
69
+ expect(result["level"][0].value).toBe("junior");
70
+ });
71
+
72
+ it("should not include fields without options", () => {
73
+ const spec = createTestSpec({
74
+ fields: {
75
+ name: { type: "text", label: "Name" },
76
+ department: {
77
+ type: "select",
78
+ options: [{ value: "eng", label: "Engineering" }],
79
+ },
80
+ },
81
+ });
82
+
83
+ const result = getOptionsVisibility({}, spec);
84
+
85
+ expect(result["name"]).toBeUndefined();
86
+ expect(result["department"]).toBeDefined();
87
+ });
88
+
89
+ it("should filter options based on form data", () => {
90
+ const spec = createTestSpec({
91
+ fields: {
92
+ position: {
93
+ type: "select",
94
+ options: [
95
+ { value: "dev_fe", label: "Frontend Dev", visibleWhen: 'department = "eng"' },
96
+ { value: "dev_be", label: "Backend Dev", visibleWhen: 'department = "eng"' },
97
+ { value: "recruiter", label: "Recruiter", visibleWhen: 'department = "hr"' },
98
+ ],
99
+ },
100
+ },
101
+ });
102
+
103
+ // Engineering department
104
+ const engResult = getOptionsVisibility({ department: "eng" }, spec);
105
+ expect(engResult["position"].map(o => o.value)).toEqual(["dev_fe", "dev_be"]);
106
+
107
+ // HR department
108
+ const hrResult = getOptionsVisibility({ department: "hr" }, spec);
109
+ expect(hrResult["position"].map(o => o.value)).toEqual(["recruiter"]);
110
+ });
111
+
112
+ it("should use computed values in expressions", () => {
113
+ const spec = createTestSpec({
114
+ fields: {
115
+ shipping: {
116
+ type: "select",
117
+ options: [
118
+ { value: "standard", label: "Standard" },
119
+ { value: "express", label: "Express", visibleWhen: "computed.total >= 50" },
120
+ ],
121
+ },
122
+ },
123
+ computed: {
124
+ total: { expression: "quantity * price" },
125
+ },
126
+ });
127
+
128
+ // Total = 40, only standard
129
+ const lowResult = getOptionsVisibility({ quantity: 2, price: 20 }, spec);
130
+ expect(lowResult["shipping"].map(o => o.value)).toEqual(["standard"]);
131
+
132
+ // Total = 100, both options
133
+ const highResult = getOptionsVisibility({ quantity: 5, price: 20 }, spec);
134
+ expect(highResult["shipping"].map(o => o.value)).toEqual(["standard", "express"]);
135
+ });
136
+
137
+ it("should accept pre-computed values", () => {
138
+ const spec = createTestSpec({
139
+ fields: {
140
+ tier: {
141
+ type: "select",
142
+ options: [
143
+ { value: "basic", label: "Basic" },
144
+ { value: "premium", label: "Premium", visibleWhen: "computed.score >= 100" },
145
+ ],
146
+ },
147
+ },
148
+ });
149
+
150
+ const result = getOptionsVisibility({}, spec, { computed: { score: 150 } });
151
+ expect(result["tier"].map(o => o.value)).toEqual(["basic", "premium"]);
152
+ });
153
+ });
154
+
155
+ describe("array item select fields", () => {
156
+ it("should compute options for each array item", () => {
157
+ const spec = createTestSpec({
158
+ fields: {
159
+ items: {
160
+ type: "array",
161
+ itemFields: {
162
+ category: {
163
+ type: "select",
164
+ options: [
165
+ { value: "electronics", label: "Electronics" },
166
+ { value: "clothing", label: "Clothing" },
167
+ ],
168
+ },
169
+ addon: {
170
+ type: "select",
171
+ options: [
172
+ { value: "warranty", label: "Warranty", visibleWhen: 'item.category = "electronics"' },
173
+ { value: "gift_wrap", label: "Gift Wrap", visibleWhen: 'item.category = "clothing"' },
174
+ { value: "insurance", label: "Insurance" },
175
+ ],
176
+ },
177
+ },
178
+ },
179
+ },
180
+ });
181
+
182
+ const data = {
183
+ items: [
184
+ { category: "electronics" },
185
+ { category: "clothing" },
186
+ ],
187
+ };
188
+
189
+ const result = getOptionsVisibility(data, spec);
190
+
191
+ // First item (electronics): warranty + insurance
192
+ expect(result["items[0].addon"].map(o => o.value)).toEqual(["warranty", "insurance"]);
193
+
194
+ // Second item (clothing): gift_wrap + insurance
195
+ expect(result["items[1].addon"].map(o => o.value)).toEqual(["gift_wrap", "insurance"]);
196
+
197
+ // Category field has all options (no visibleWhen)
198
+ expect(result["items[0].category"]).toHaveLength(2);
199
+ expect(result["items[1].category"]).toHaveLength(2);
200
+ });
201
+
202
+ it("should use itemIndex in expressions", () => {
203
+ const spec = createTestSpec({
204
+ fields: {
205
+ members: {
206
+ type: "array",
207
+ itemFields: {
208
+ role: {
209
+ type: "select",
210
+ options: [
211
+ { value: "member", label: "Member" },
212
+ { value: "lead", label: "Team Lead", visibleWhen: "itemIndex = 0" },
213
+ ],
214
+ },
215
+ },
216
+ },
217
+ },
218
+ });
219
+
220
+ const data = {
221
+ members: [{}, {}, {}],
222
+ };
223
+
224
+ const result = getOptionsVisibility(data, spec);
225
+
226
+ // First item can be lead
227
+ expect(result["members[0].role"].map(o => o.value)).toEqual(["member", "lead"]);
228
+
229
+ // Other items can only be member
230
+ expect(result["members[1].role"].map(o => o.value)).toEqual(["member"]);
231
+ expect(result["members[2].role"].map(o => o.value)).toEqual(["member"]);
232
+ });
233
+
234
+ it("should combine form data with item context", () => {
235
+ const spec = createTestSpec({
236
+ fields: {
237
+ isPremium: { type: "boolean" },
238
+ orders: {
239
+ type: "array",
240
+ itemFields: {
241
+ shipping: {
242
+ type: "select",
243
+ options: [
244
+ { value: "standard", label: "Standard" },
245
+ { value: "express", label: "Express", visibleWhen: "isPremium = true" },
246
+ { value: "priority", label: "Priority", visibleWhen: 'isPremium = true and item.value > 100' },
247
+ ],
248
+ },
249
+ },
250
+ },
251
+ },
252
+ });
253
+
254
+ // Not premium
255
+ const basicResult = getOptionsVisibility({
256
+ isPremium: false,
257
+ orders: [{ value: 50 }, { value: 200 }],
258
+ }, spec);
259
+
260
+ expect(basicResult["orders[0].shipping"].map(o => o.value)).toEqual(["standard"]);
261
+ expect(basicResult["orders[1].shipping"].map(o => o.value)).toEqual(["standard"]);
262
+
263
+ // Premium with different order values
264
+ const premiumResult = getOptionsVisibility({
265
+ isPremium: true,
266
+ orders: [{ value: 50 }, { value: 200 }],
267
+ }, spec);
268
+
269
+ // Low value order: standard + express
270
+ expect(premiumResult["orders[0].shipping"].map(o => o.value)).toEqual(["standard", "express"]);
271
+
272
+ // High value order: standard + express + priority
273
+ expect(premiumResult["orders[1].shipping"].map(o => o.value)).toEqual(["standard", "express", "priority"]);
274
+ });
275
+
276
+ it("should handle empty arrays", () => {
277
+ const spec = createTestSpec({
278
+ fields: {
279
+ items: {
280
+ type: "array",
281
+ itemFields: {
282
+ type: {
283
+ type: "select",
284
+ options: [{ value: "a", label: "A" }],
285
+ },
286
+ },
287
+ },
288
+ },
289
+ });
290
+
291
+ const result = getOptionsVisibility({ items: [] }, spec);
292
+
293
+ // No item paths should exist
294
+ expect(Object.keys(result).filter(k => k.startsWith("items["))).toHaveLength(0);
295
+ });
296
+
297
+ it("should handle missing array data", () => {
298
+ const spec = createTestSpec({
299
+ fields: {
300
+ items: {
301
+ type: "array",
302
+ itemFields: {
303
+ type: {
304
+ type: "select",
305
+ options: [{ value: "a", label: "A" }],
306
+ },
307
+ },
308
+ },
309
+ },
310
+ });
311
+
312
+ const result = getOptionsVisibility({}, spec);
313
+
314
+ // No item paths should exist
315
+ expect(Object.keys(result).filter(k => k.startsWith("items["))).toHaveLength(0);
316
+ });
317
+ });
318
+
319
+ describe("error handling", () => {
320
+ it("should hide options with invalid FEEL expressions", () => {
321
+ const spec = createTestSpec({
322
+ fields: {
323
+ status: {
324
+ type: "select",
325
+ options: [
326
+ { value: "valid", label: "Valid" },
327
+ { value: "invalid", label: "Invalid", visibleWhen: "not valid FEEL !!!" },
328
+ ],
329
+ },
330
+ },
331
+ });
332
+
333
+ const result = getOptionsVisibility({}, spec);
334
+
335
+ expect(result["status"].map(o => o.value)).toEqual(["valid"]);
336
+ });
337
+ });
338
+
339
+ describe("reference data", () => {
340
+ it("should access reference data in expressions", () => {
341
+ const spec = createTestSpec({
342
+ fields: {
343
+ plan: {
344
+ type: "select",
345
+ options: [
346
+ { value: "basic", label: "Basic" },
347
+ { value: "enterprise", label: "Enterprise", visibleWhen: "ref.features.enterpriseEnabled = true" },
348
+ ],
349
+ },
350
+ },
351
+ referenceData: {
352
+ features: { enterpriseEnabled: true },
353
+ },
354
+ });
355
+
356
+ const result = getOptionsVisibility({}, spec);
357
+
358
+ expect(result["plan"].map(o => o.value)).toEqual(["basic", "enterprise"]);
359
+ });
360
+ });
361
+ });
362
+
363
+ // ============================================================================
364
+ // getVisibleOptions (Individual Computation - Utility)
365
+ // ============================================================================
366
+
367
+ describe("getVisibleOptions", () => {
368
+ describe("basic filtering", () => {
369
+ it("should return all options when none have visibleWhen", () => {
370
+ const options: SelectOption[] = [
371
+ { value: "a", label: "A" },
372
+ { value: "b", label: "B" },
373
+ ];
374
+ const spec = createTestSpec();
375
+
376
+ const result = getVisibleOptions(options, {}, spec);
377
+
378
+ expect(result).toEqual(options);
379
+ });
380
+
381
+ it("should filter options based on visibleWhen", () => {
382
+ const options: SelectOption[] = [
383
+ { value: "always", label: "Always" },
384
+ { value: "conditional", label: "Conditional", visibleWhen: "show = true" },
385
+ ];
386
+ const spec = createTestSpec();
387
+
388
+ expect(getVisibleOptions(options, { show: false }, spec).map(o => o.value))
389
+ .toEqual(["always"]);
390
+
391
+ expect(getVisibleOptions(options, { show: true }, spec).map(o => o.value))
392
+ .toEqual(["always", "conditional"]);
393
+ });
394
+
395
+ it("should return empty array for undefined/empty options", () => {
396
+ const spec = createTestSpec();
397
+
398
+ expect(getVisibleOptions(undefined, {}, spec)).toEqual([]);
399
+ expect(getVisibleOptions([], {}, spec)).toEqual([]);
400
+ });
401
+
402
+ it("should return empty array when all options hidden", () => {
403
+ const options: SelectOption[] = [
404
+ { value: "a", label: "A", visibleWhen: "show = true" },
405
+ { value: "b", label: "B", visibleWhen: "show = true" },
406
+ ];
407
+ const spec = createTestSpec();
408
+
409
+ expect(getVisibleOptions(options, { show: false }, spec)).toEqual([]);
410
+ });
411
+ });
412
+
413
+ describe("with item context", () => {
414
+ it("should use item data in expressions", () => {
415
+ const options: SelectOption[] = [
416
+ { value: "warranty", label: "Warranty", visibleWhen: 'item.type = "electronics"' },
417
+ { value: "shipping", label: "Shipping" },
418
+ ];
419
+ const spec = createTestSpec();
420
+
421
+ const electronicsResult = getVisibleOptions(options, {}, spec, {
422
+ item: { type: "electronics" },
423
+ });
424
+ expect(electronicsResult.map(o => o.value)).toEqual(["warranty", "shipping"]);
425
+
426
+ const otherResult = getVisibleOptions(options, {}, spec, {
427
+ item: { type: "clothing" },
428
+ });
429
+ expect(otherResult.map(o => o.value)).toEqual(["shipping"]);
430
+ });
431
+
432
+ it("should use itemIndex in expressions", () => {
433
+ const options: SelectOption[] = [
434
+ { value: "lead", label: "Lead", visibleWhen: "itemIndex = 0" },
435
+ { value: "member", label: "Member" },
436
+ ];
437
+ const spec = createTestSpec();
438
+
439
+ expect(getVisibleOptions(options, {}, spec, { itemIndex: 0, item: {} }).map(o => o.value))
440
+ .toEqual(["lead", "member"]);
441
+
442
+ expect(getVisibleOptions(options, {}, spec, { itemIndex: 1, item: {} }).map(o => o.value))
443
+ .toEqual(["member"]);
444
+ });
445
+ });
446
+
447
+ describe("expression types", () => {
448
+ it("should evaluate numeric comparisons", () => {
449
+ const options: SelectOption[] = [
450
+ { value: "junior", label: "Junior", visibleWhen: "years < 3" },
451
+ { value: "mid", label: "Mid", visibleWhen: "years >= 3 and years < 7" },
452
+ { value: "senior", label: "Senior", visibleWhen: "years >= 7" },
453
+ ];
454
+ const spec = createTestSpec();
455
+
456
+ expect(getVisibleOptions(options, { years: 1 }, spec).map(o => o.value))
457
+ .toEqual(["junior"]);
458
+
459
+ expect(getVisibleOptions(options, { years: 5 }, spec).map(o => o.value))
460
+ .toEqual(["mid"]);
461
+
462
+ expect(getVisibleOptions(options, { years: 10 }, spec).map(o => o.value))
463
+ .toEqual(["senior"]);
464
+ });
465
+
466
+ it("should evaluate string equality", () => {
467
+ const options: SelectOption[] = [
468
+ { value: "a", label: "A", visibleWhen: 'type = "alpha"' },
469
+ { value: "b", label: "B", visibleWhen: 'type = "beta"' },
470
+ ];
471
+ const spec = createTestSpec();
472
+
473
+ expect(getVisibleOptions(options, { type: "alpha" }, spec).map(o => o.value))
474
+ .toEqual(["a"]);
475
+ });
476
+
477
+ it("should evaluate boolean expressions", () => {
478
+ const options: SelectOption[] = [
479
+ { value: "premium", label: "Premium", visibleWhen: "isPremium = true" },
480
+ { value: "basic", label: "Basic" },
481
+ ];
482
+ const spec = createTestSpec();
483
+
484
+ expect(getVisibleOptions(options, { isPremium: false }, spec).map(o => o.value))
485
+ .toEqual(["basic"]);
486
+
487
+ expect(getVisibleOptions(options, { isPremium: true }, spec).map(o => o.value))
488
+ .toEqual(["premium", "basic"]);
489
+ });
490
+ });
491
+
492
+ describe("edge cases", () => {
493
+ it("should handle numeric option values", () => {
494
+ const options: SelectOption[] = [
495
+ { value: 1, label: "One" },
496
+ { value: 2, label: "Two", visibleWhen: "level >= 2" },
497
+ ];
498
+ const spec = createTestSpec();
499
+
500
+ expect(getVisibleOptions(options, { level: 1 }, spec).map(o => o.value))
501
+ .toEqual([1]);
502
+
503
+ expect(getVisibleOptions(options, { level: 2 }, spec).map(o => o.value))
504
+ .toEqual([1, 2]);
505
+ });
506
+
507
+ it("should preserve option order", () => {
508
+ const options: SelectOption[] = [
509
+ { value: "z", label: "Z", visibleWhen: "show = true" },
510
+ { value: "a", label: "A", visibleWhen: "show = true" },
511
+ { value: "m", label: "M", visibleWhen: "show = true" },
512
+ ];
513
+ const spec = createTestSpec();
514
+
515
+ expect(getVisibleOptions(options, { show: true }, spec).map(o => o.value))
516
+ .toEqual(["z", "a", "m"]);
517
+ });
518
+
519
+ it("should hide options with invalid FEEL", () => {
520
+ const options: SelectOption[] = [
521
+ { value: "valid", label: "Valid" },
522
+ { value: "invalid", label: "Invalid", visibleWhen: "this is broken !!!" },
523
+ ];
524
+ const spec = createTestSpec();
525
+
526
+ expect(getVisibleOptions(options, {}, spec).map(o => o.value))
527
+ .toEqual(["valid"]);
528
+ });
529
+ });
530
+ });
@@ -28,10 +28,13 @@ export {
28
28
  getVisibility,
29
29
  isFieldVisible,
30
30
  getPageVisibility,
31
+ getOptionsVisibility,
32
+ getVisibleOptions,
31
33
  } from "./visibility.js";
32
34
 
33
35
  export type {
34
36
  VisibilityOptions,
37
+ OptionsVisibilityResult,
35
38
  } from "./visibility.js";
36
39
 
37
40
  // Required