@fogpipe/forma-core 0.10.2 → 0.10.4
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.
- package/dist/{chunk-KUZ3NPM4.js → chunk-7ANLNW3X.js} +112 -38
- package/dist/chunk-7ANLNW3X.js.map +1 -0
- package/dist/engine/calculate.d.ts +4 -2
- package/dist/engine/calculate.d.ts.map +1 -1
- package/dist/engine/index.cjs +116 -37
- package/dist/engine/index.cjs.map +1 -1
- package/dist/engine/index.d.ts +2 -0
- package/dist/engine/index.d.ts.map +1 -1
- package/dist/engine/index.js +11 -1
- package/dist/engine/validate.d.ts.map +1 -1
- package/dist/format/index.d.ts +74 -0
- package/dist/format/index.d.ts.map +1 -0
- package/dist/index.cjs +116 -37
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +11 -1
- package/package.json +1 -1
- package/src/__tests__/format.test.ts +241 -0
- package/src/__tests__/validate.test.ts +345 -0
- package/src/engine/calculate.ts +10 -47
- package/src/engine/index.ts +11 -0
- package/src/engine/validate.ts +54 -15
- package/src/format/index.ts +180 -0
- package/dist/chunk-KUZ3NPM4.js.map +0 -1
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
formatValue,
|
|
4
|
+
isValidFormat,
|
|
5
|
+
parseDecimalFormat,
|
|
6
|
+
SUPPORTED_FORMATS,
|
|
7
|
+
DECIMAL_FORMAT_PATTERN,
|
|
8
|
+
} from "../format/index.js";
|
|
9
|
+
|
|
10
|
+
describe("format module", () => {
|
|
11
|
+
describe("constants", () => {
|
|
12
|
+
it("SUPPORTED_FORMATS includes expected formats", () => {
|
|
13
|
+
expect(SUPPORTED_FORMATS).toContain("currency");
|
|
14
|
+
expect(SUPPORTED_FORMATS).toContain("percent");
|
|
15
|
+
expect(SUPPORTED_FORMATS).toContain("date");
|
|
16
|
+
expect(SUPPORTED_FORMATS).toContain("datetime");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("DECIMAL_FORMAT_PATTERN matches valid decimal formats", () => {
|
|
20
|
+
expect(DECIMAL_FORMAT_PATTERN.test("decimal(0)")).toBe(true);
|
|
21
|
+
expect(DECIMAL_FORMAT_PATTERN.test("decimal(1)")).toBe(true);
|
|
22
|
+
expect(DECIMAL_FORMAT_PATTERN.test("decimal(2)")).toBe(true);
|
|
23
|
+
expect(DECIMAL_FORMAT_PATTERN.test("decimal(10)")).toBe(true);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("DECIMAL_FORMAT_PATTERN rejects invalid formats", () => {
|
|
27
|
+
expect(DECIMAL_FORMAT_PATTERN.test("decimal")).toBe(false);
|
|
28
|
+
expect(DECIMAL_FORMAT_PATTERN.test("decimal()")).toBe(false);
|
|
29
|
+
expect(DECIMAL_FORMAT_PATTERN.test("decimal(-1)")).toBe(false);
|
|
30
|
+
expect(DECIMAL_FORMAT_PATTERN.test("decimal(a)")).toBe(false);
|
|
31
|
+
expect(DECIMAL_FORMAT_PATTERN.test("DECIMAL(2)")).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("isValidFormat", () => {
|
|
36
|
+
it("returns true for supported formats", () => {
|
|
37
|
+
expect(isValidFormat("currency")).toBe(true);
|
|
38
|
+
expect(isValidFormat("percent")).toBe(true);
|
|
39
|
+
expect(isValidFormat("date")).toBe(true);
|
|
40
|
+
expect(isValidFormat("datetime")).toBe(true);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("returns true for valid decimal formats", () => {
|
|
44
|
+
expect(isValidFormat("decimal(0)")).toBe(true);
|
|
45
|
+
expect(isValidFormat("decimal(2)")).toBe(true);
|
|
46
|
+
expect(isValidFormat("decimal(5)")).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("returns false for invalid formats", () => {
|
|
50
|
+
expect(isValidFormat("invalid")).toBe(false);
|
|
51
|
+
expect(isValidFormat("decimal")).toBe(false);
|
|
52
|
+
expect(isValidFormat("decimal()")).toBe(false);
|
|
53
|
+
expect(isValidFormat("CURRENCY")).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("parseDecimalFormat", () => {
|
|
58
|
+
it("extracts decimal places from valid format", () => {
|
|
59
|
+
expect(parseDecimalFormat("decimal(0)")).toBe(0);
|
|
60
|
+
expect(parseDecimalFormat("decimal(1)")).toBe(1);
|
|
61
|
+
expect(parseDecimalFormat("decimal(2)")).toBe(2);
|
|
62
|
+
expect(parseDecimalFormat("decimal(10)")).toBe(10);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("returns null for non-decimal formats", () => {
|
|
66
|
+
expect(parseDecimalFormat("currency")).toBeNull();
|
|
67
|
+
expect(parseDecimalFormat("percent")).toBeNull();
|
|
68
|
+
expect(parseDecimalFormat("invalid")).toBeNull();
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("formatValue", () => {
|
|
73
|
+
describe("no format specified", () => {
|
|
74
|
+
it("converts values to strings", () => {
|
|
75
|
+
expect(formatValue(123)).toBe("123");
|
|
76
|
+
expect(formatValue("hello")).toBe("hello");
|
|
77
|
+
expect(formatValue(true)).toBe("true");
|
|
78
|
+
expect(formatValue(false)).toBe("false");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("handles null and undefined", () => {
|
|
82
|
+
expect(formatValue(null)).toBe("null");
|
|
83
|
+
expect(formatValue(undefined)).toBe("undefined");
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("decimal format", () => {
|
|
88
|
+
it("formats numbers with specified decimal places", () => {
|
|
89
|
+
expect(formatValue(123.456, "decimal(0)")).toBe("123");
|
|
90
|
+
expect(formatValue(123.456, "decimal(1)")).toBe("123.5");
|
|
91
|
+
expect(formatValue(123.456, "decimal(2)")).toBe("123.46");
|
|
92
|
+
expect(formatValue(123.456, "decimal(5)")).toBe("123.45600");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("pads with zeros when needed", () => {
|
|
96
|
+
expect(formatValue(123, "decimal(2)")).toBe("123.00");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("handles negative numbers", () => {
|
|
100
|
+
expect(formatValue(-123.456, "decimal(2)")).toBe("-123.46");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("falls back to string for non-numbers", () => {
|
|
104
|
+
expect(formatValue("not a number", "decimal(2)")).toBe("not a number");
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe("currency format", () => {
|
|
109
|
+
it("formats numbers as USD currency by default", () => {
|
|
110
|
+
const result = formatValue(1234.56, "currency");
|
|
111
|
+
expect(result).toBe("$1,234.56");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("formats zero", () => {
|
|
115
|
+
expect(formatValue(0, "currency")).toBe("$0.00");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("formats negative numbers", () => {
|
|
119
|
+
expect(formatValue(-100, "currency")).toBe("-$100.00");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("respects currency option", () => {
|
|
123
|
+
const result = formatValue(1234.56, "currency", { currency: "EUR" });
|
|
124
|
+
// EUR formatting varies by locale
|
|
125
|
+
expect(result).toContain("1,234.56");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("falls back to string for non-numbers", () => {
|
|
129
|
+
expect(formatValue("not a number", "currency")).toBe("not a number");
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe("percent format", () => {
|
|
134
|
+
it("formats numbers as percentages", () => {
|
|
135
|
+
expect(formatValue(0.5, "percent")).toBe("50%");
|
|
136
|
+
expect(formatValue(1, "percent")).toBe("100%");
|
|
137
|
+
expect(formatValue(0.156, "percent")).toBe("15.6%");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("handles edge cases", () => {
|
|
141
|
+
expect(formatValue(0, "percent")).toBe("0%");
|
|
142
|
+
expect(formatValue(-0.5, "percent")).toBe("-50%");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("falls back to string for non-numbers", () => {
|
|
146
|
+
expect(formatValue("not a number", "percent")).toBe("not a number");
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe("date format", () => {
|
|
151
|
+
it("formats Date objects", () => {
|
|
152
|
+
const date = new Date("2024-03-15T10:30:00Z");
|
|
153
|
+
const result = formatValue(date, "date");
|
|
154
|
+
// Contains month, day, year in some format
|
|
155
|
+
expect(result).toMatch(/3\/15\/2024|15\/3\/2024/);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("formats date strings", () => {
|
|
159
|
+
const result = formatValue("2024-03-15", "date");
|
|
160
|
+
expect(result).toMatch(/3\/15\/2024|15\/3\/2024|3\/14\/2024|14\/3\/2024/);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("falls back to string for invalid dates", () => {
|
|
164
|
+
expect(formatValue("not a date", "date")).toBe("not a date");
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe("datetime format", () => {
|
|
169
|
+
it("formats Date objects with time", () => {
|
|
170
|
+
const date = new Date("2024-03-15T10:30:00Z");
|
|
171
|
+
const result = formatValue(date, "datetime");
|
|
172
|
+
// Contains both date and time components
|
|
173
|
+
expect(result).toMatch(/\d{1,2}\/\d{1,2}\/\d{2,4}/);
|
|
174
|
+
expect(result).toMatch(/\d{1,2}:\d{2}/);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("falls back to string for invalid dates", () => {
|
|
178
|
+
expect(formatValue("not a date", "datetime")).toBe("not a date");
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe("unknown format", () => {
|
|
183
|
+
it("falls back to string conversion", () => {
|
|
184
|
+
expect(formatValue(123, "unknown")).toBe("123");
|
|
185
|
+
expect(formatValue("hello", "invalid")).toBe("hello");
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
describe("nullDisplay option", () => {
|
|
190
|
+
it("uses nullDisplay for null values", () => {
|
|
191
|
+
expect(formatValue(null, undefined, { nullDisplay: "—" })).toBe("—");
|
|
192
|
+
expect(formatValue(null, "decimal(2)", { nullDisplay: "N/A" })).toBe("N/A");
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("uses nullDisplay for undefined values", () => {
|
|
196
|
+
expect(formatValue(undefined, undefined, { nullDisplay: "—" })).toBe("—");
|
|
197
|
+
expect(formatValue(undefined, "currency", { nullDisplay: "-" })).toBe("-");
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("does not affect non-null values", () => {
|
|
201
|
+
expect(formatValue(123, "decimal(2)", { nullDisplay: "—" })).toBe("123.00");
|
|
202
|
+
expect(formatValue(0, "decimal(2)", { nullDisplay: "—" })).toBe("0.00");
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
describe("locale option", () => {
|
|
207
|
+
it("respects locale for currency formatting", () => {
|
|
208
|
+
const result = formatValue(1234.56, "currency", { locale: "de-DE", currency: "EUR" });
|
|
209
|
+
// German locale uses comma for decimals
|
|
210
|
+
expect(result).toContain("1.234,56");
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("respects locale for percent formatting", () => {
|
|
214
|
+
const result = formatValue(0.5, "percent", { locale: "de-DE" });
|
|
215
|
+
// German locale uses comma for decimals
|
|
216
|
+
expect(result).toContain("50");
|
|
217
|
+
expect(result).toContain("%");
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
describe("edge cases", () => {
|
|
222
|
+
it("handles NaN", () => {
|
|
223
|
+
expect(formatValue(NaN, "decimal(2)")).toBe("NaN");
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("handles Infinity", () => {
|
|
227
|
+
expect(formatValue(Infinity, "decimal(2)")).toBe("Infinity");
|
|
228
|
+
expect(formatValue(-Infinity, "decimal(2)")).toBe("-Infinity");
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("handles very large numbers", () => {
|
|
232
|
+
const result = formatValue(1e15, "currency");
|
|
233
|
+
expect(result).toContain("$");
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("handles very small numbers", () => {
|
|
237
|
+
expect(formatValue(0.0001, "decimal(4)")).toBe("0.0001");
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
});
|
|
@@ -275,4 +275,349 @@ describe("validate", () => {
|
|
|
275
275
|
expect(result.errors[0].message).toBe("Name must be at least 2 characters");
|
|
276
276
|
});
|
|
277
277
|
});
|
|
278
|
+
|
|
279
|
+
// ============================================================================
|
|
280
|
+
// Array validation
|
|
281
|
+
// ============================================================================
|
|
282
|
+
|
|
283
|
+
describe("array validation", () => {
|
|
284
|
+
describe("minItems/maxItems from schema", () => {
|
|
285
|
+
it("should validate minItems from schema when fieldDef does not specify it", () => {
|
|
286
|
+
const spec: Forma = {
|
|
287
|
+
version: "1.0",
|
|
288
|
+
meta: { id: "test", title: "Test" },
|
|
289
|
+
schema: {
|
|
290
|
+
type: "object",
|
|
291
|
+
properties: {
|
|
292
|
+
items: {
|
|
293
|
+
type: "array",
|
|
294
|
+
items: { type: "string" },
|
|
295
|
+
minItems: 2,
|
|
296
|
+
},
|
|
297
|
+
},
|
|
298
|
+
},
|
|
299
|
+
fields: {
|
|
300
|
+
items: { label: "Items", type: "array" },
|
|
301
|
+
},
|
|
302
|
+
fieldOrder: ["items"],
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
// Valid - has minimum 2 items
|
|
306
|
+
expect(validate({ items: ["a", "b"] }, spec).valid).toBe(true);
|
|
307
|
+
expect(validate({ items: ["a", "b", "c"] }, spec).valid).toBe(true);
|
|
308
|
+
|
|
309
|
+
// Invalid - less than minItems
|
|
310
|
+
const result = validate({ items: ["a"] }, spec);
|
|
311
|
+
expect(result.valid).toBe(false);
|
|
312
|
+
expect(result.errors[0].field).toBe("items");
|
|
313
|
+
expect(result.errors[0].message).toBe("Items must have at least 2 items");
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it("should validate maxItems from schema when fieldDef does not specify it", () => {
|
|
317
|
+
const spec: Forma = {
|
|
318
|
+
version: "1.0",
|
|
319
|
+
meta: { id: "test", title: "Test" },
|
|
320
|
+
schema: {
|
|
321
|
+
type: "object",
|
|
322
|
+
properties: {
|
|
323
|
+
tags: {
|
|
324
|
+
type: "array",
|
|
325
|
+
items: { type: "string" },
|
|
326
|
+
maxItems: 3,
|
|
327
|
+
},
|
|
328
|
+
},
|
|
329
|
+
},
|
|
330
|
+
fields: {
|
|
331
|
+
tags: { label: "Tags", type: "array" },
|
|
332
|
+
},
|
|
333
|
+
fieldOrder: ["tags"],
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
// Valid - has maximum 3 items
|
|
337
|
+
expect(validate({ tags: ["a", "b", "c"] }, spec).valid).toBe(true);
|
|
338
|
+
expect(validate({ tags: ["a"] }, spec).valid).toBe(true);
|
|
339
|
+
|
|
340
|
+
// Invalid - more than maxItems
|
|
341
|
+
const result = validate({ tags: ["a", "b", "c", "d"] }, spec);
|
|
342
|
+
expect(result.valid).toBe(false);
|
|
343
|
+
expect(result.errors[0].field).toBe("tags");
|
|
344
|
+
expect(result.errors[0].message).toBe("Tags must have no more than 3 items");
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it("should allow fieldDef.minItems to override schema.minItems", () => {
|
|
348
|
+
const spec: Forma = {
|
|
349
|
+
version: "1.0",
|
|
350
|
+
meta: { id: "test", title: "Test" },
|
|
351
|
+
schema: {
|
|
352
|
+
type: "object",
|
|
353
|
+
properties: {
|
|
354
|
+
items: {
|
|
355
|
+
type: "array",
|
|
356
|
+
items: { type: "string" },
|
|
357
|
+
minItems: 5, // schema says 5
|
|
358
|
+
},
|
|
359
|
+
},
|
|
360
|
+
},
|
|
361
|
+
fields: {
|
|
362
|
+
items: {
|
|
363
|
+
label: "Items",
|
|
364
|
+
type: "array",
|
|
365
|
+
minItems: 2, // fieldDef overrides to 2
|
|
366
|
+
},
|
|
367
|
+
},
|
|
368
|
+
fieldOrder: ["items"],
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
// fieldDef.minItems (2) should take precedence over schema.minItems (5)
|
|
372
|
+
expect(validate({ items: ["a", "b"] }, spec).valid).toBe(true);
|
|
373
|
+
expect(validate({ items: ["a"] }, spec).valid).toBe(false);
|
|
374
|
+
});
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
describe("array item validation from schema", () => {
|
|
378
|
+
it("should validate array item type constraints from schema", () => {
|
|
379
|
+
const spec: Forma = {
|
|
380
|
+
version: "1.0",
|
|
381
|
+
meta: { id: "test", title: "Test" },
|
|
382
|
+
schema: {
|
|
383
|
+
type: "object",
|
|
384
|
+
properties: {
|
|
385
|
+
scores: {
|
|
386
|
+
type: "array",
|
|
387
|
+
items: {
|
|
388
|
+
type: "object",
|
|
389
|
+
properties: {
|
|
390
|
+
name: { type: "string" },
|
|
391
|
+
score: { type: "number", minimum: 0, maximum: 100 },
|
|
392
|
+
},
|
|
393
|
+
required: ["name", "score"],
|
|
394
|
+
},
|
|
395
|
+
},
|
|
396
|
+
},
|
|
397
|
+
},
|
|
398
|
+
fields: {
|
|
399
|
+
scores: { label: "Scores", type: "array" },
|
|
400
|
+
},
|
|
401
|
+
fieldOrder: ["scores"],
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
// Valid data
|
|
405
|
+
const validResult = validate(
|
|
406
|
+
{
|
|
407
|
+
scores: [
|
|
408
|
+
{ name: "Alice", score: 95 },
|
|
409
|
+
{ name: "Bob", score: 87 },
|
|
410
|
+
],
|
|
411
|
+
},
|
|
412
|
+
spec
|
|
413
|
+
);
|
|
414
|
+
expect(validResult.valid).toBe(true);
|
|
415
|
+
|
|
416
|
+
// Invalid - score above maximum
|
|
417
|
+
const maxResult = validate(
|
|
418
|
+
{
|
|
419
|
+
scores: [
|
|
420
|
+
{ name: "Alice", score: 105 },
|
|
421
|
+
{ name: "Bob", score: 87 },
|
|
422
|
+
],
|
|
423
|
+
},
|
|
424
|
+
spec
|
|
425
|
+
);
|
|
426
|
+
expect(maxResult.valid).toBe(false);
|
|
427
|
+
expect(maxResult.errors[0].field).toBe("scores[0].score");
|
|
428
|
+
expect(maxResult.errors[0].message).toContain("no more than 100");
|
|
429
|
+
|
|
430
|
+
// Invalid - score below minimum
|
|
431
|
+
const minResult = validate(
|
|
432
|
+
{
|
|
433
|
+
scores: [
|
|
434
|
+
{ name: "Alice", score: -5 },
|
|
435
|
+
{ name: "Bob", score: 87 },
|
|
436
|
+
],
|
|
437
|
+
},
|
|
438
|
+
spec
|
|
439
|
+
);
|
|
440
|
+
expect(minResult.valid).toBe(false);
|
|
441
|
+
expect(minResult.errors[0].field).toBe("scores[0].score");
|
|
442
|
+
expect(minResult.errors[0].message).toContain("at least 0");
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
it("should validate required fields in array items from schema", () => {
|
|
446
|
+
const spec: Forma = {
|
|
447
|
+
version: "1.0",
|
|
448
|
+
meta: { id: "test", title: "Test" },
|
|
449
|
+
schema: {
|
|
450
|
+
type: "object",
|
|
451
|
+
properties: {
|
|
452
|
+
people: {
|
|
453
|
+
type: "array",
|
|
454
|
+
items: {
|
|
455
|
+
type: "object",
|
|
456
|
+
properties: {
|
|
457
|
+
name: { type: "string" },
|
|
458
|
+
age: { type: "integer" },
|
|
459
|
+
},
|
|
460
|
+
required: ["name"],
|
|
461
|
+
},
|
|
462
|
+
},
|
|
463
|
+
},
|
|
464
|
+
},
|
|
465
|
+
fields: {
|
|
466
|
+
people: { label: "People", type: "array" },
|
|
467
|
+
},
|
|
468
|
+
fieldOrder: ["people"],
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
// Valid - name is present (age is optional)
|
|
472
|
+
expect(
|
|
473
|
+
validate({ people: [{ name: "Alice" }, { name: "Bob", age: 30 }] }, spec).valid
|
|
474
|
+
).toBe(true);
|
|
475
|
+
|
|
476
|
+
// Invalid - missing required name
|
|
477
|
+
const result = validate({ people: [{ age: 25 }] }, spec);
|
|
478
|
+
expect(result.valid).toBe(false);
|
|
479
|
+
expect(result.errors[0].field).toBe("people[0].name");
|
|
480
|
+
expect(result.errors[0].message).toContain("required");
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
it("should validate nested string constraints in array items", () => {
|
|
484
|
+
const spec: Forma = {
|
|
485
|
+
version: "1.0",
|
|
486
|
+
meta: { id: "test", title: "Test" },
|
|
487
|
+
schema: {
|
|
488
|
+
type: "object",
|
|
489
|
+
properties: {
|
|
490
|
+
emails: {
|
|
491
|
+
type: "array",
|
|
492
|
+
items: {
|
|
493
|
+
type: "object",
|
|
494
|
+
properties: {
|
|
495
|
+
address: { type: "string", format: "email" },
|
|
496
|
+
label: { type: "string", minLength: 1, maxLength: 20 },
|
|
497
|
+
},
|
|
498
|
+
required: ["address"],
|
|
499
|
+
},
|
|
500
|
+
},
|
|
501
|
+
},
|
|
502
|
+
},
|
|
503
|
+
fields: {
|
|
504
|
+
emails: { label: "Emails", type: "array" },
|
|
505
|
+
},
|
|
506
|
+
fieldOrder: ["emails"],
|
|
507
|
+
};
|
|
508
|
+
|
|
509
|
+
// Valid
|
|
510
|
+
expect(
|
|
511
|
+
validate({ emails: [{ address: "test@example.com", label: "Work" }] }, spec).valid
|
|
512
|
+
).toBe(true);
|
|
513
|
+
|
|
514
|
+
// Invalid email format
|
|
515
|
+
const emailResult = validate({ emails: [{ address: "not-an-email" }] }, spec);
|
|
516
|
+
expect(emailResult.valid).toBe(false);
|
|
517
|
+
expect(emailResult.errors[0].field).toBe("emails[0].address");
|
|
518
|
+
expect(emailResult.errors[0].message).toContain("valid email");
|
|
519
|
+
|
|
520
|
+
// Invalid label (too long)
|
|
521
|
+
const labelResult = validate(
|
|
522
|
+
{ emails: [{ address: "test@example.com", label: "This label is way too long for the constraint" }] },
|
|
523
|
+
spec
|
|
524
|
+
);
|
|
525
|
+
expect(labelResult.valid).toBe(false);
|
|
526
|
+
expect(labelResult.errors[0].field).toBe("emails[0].label");
|
|
527
|
+
expect(labelResult.errors[0].message).toContain("no more than 20");
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
it("should validate multipleOf in array item fields", () => {
|
|
531
|
+
const spec: Forma = {
|
|
532
|
+
version: "1.0",
|
|
533
|
+
meta: { id: "test", title: "Test" },
|
|
534
|
+
schema: {
|
|
535
|
+
type: "object",
|
|
536
|
+
properties: {
|
|
537
|
+
prices: {
|
|
538
|
+
type: "array",
|
|
539
|
+
items: {
|
|
540
|
+
type: "object",
|
|
541
|
+
properties: {
|
|
542
|
+
amount: { type: "number", multipleOf: 0.01 },
|
|
543
|
+
},
|
|
544
|
+
},
|
|
545
|
+
},
|
|
546
|
+
},
|
|
547
|
+
},
|
|
548
|
+
fields: {
|
|
549
|
+
prices: { label: "Prices", type: "array" },
|
|
550
|
+
},
|
|
551
|
+
fieldOrder: ["prices"],
|
|
552
|
+
};
|
|
553
|
+
|
|
554
|
+
// Valid - correct precision
|
|
555
|
+
expect(validate({ prices: [{ amount: 10.99 }, { amount: 5.0 }] }, spec).valid).toBe(true);
|
|
556
|
+
|
|
557
|
+
// Invalid - wrong precision
|
|
558
|
+
const result = validate({ prices: [{ amount: 10.999 }] }, spec);
|
|
559
|
+
expect(result.valid).toBe(false);
|
|
560
|
+
expect(result.errors[0].field).toBe("prices[0].amount");
|
|
561
|
+
expect(result.errors[0].message).toContain("multiple of 0.01");
|
|
562
|
+
});
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
describe("combined fieldDef and schema validation", () => {
|
|
566
|
+
it("should use itemFields for custom validations while using schema for type constraints", () => {
|
|
567
|
+
const spec: Forma = {
|
|
568
|
+
version: "1.0",
|
|
569
|
+
meta: { id: "test", title: "Test" },
|
|
570
|
+
schema: {
|
|
571
|
+
type: "object",
|
|
572
|
+
properties: {
|
|
573
|
+
orders: {
|
|
574
|
+
type: "array",
|
|
575
|
+
items: {
|
|
576
|
+
type: "object",
|
|
577
|
+
properties: {
|
|
578
|
+
quantity: { type: "integer", minimum: 1 },
|
|
579
|
+
price: { type: "number", minimum: 0 },
|
|
580
|
+
},
|
|
581
|
+
},
|
|
582
|
+
minItems: 1,
|
|
583
|
+
},
|
|
584
|
+
},
|
|
585
|
+
},
|
|
586
|
+
fields: {
|
|
587
|
+
orders: {
|
|
588
|
+
label: "Orders",
|
|
589
|
+
type: "array",
|
|
590
|
+
itemFields: {
|
|
591
|
+
quantity: {
|
|
592
|
+
label: "Quantity",
|
|
593
|
+
validations: [
|
|
594
|
+
{ rule: "value <= 100", message: "Cannot order more than 100 items" },
|
|
595
|
+
],
|
|
596
|
+
},
|
|
597
|
+
},
|
|
598
|
+
},
|
|
599
|
+
},
|
|
600
|
+
fieldOrder: ["orders"],
|
|
601
|
+
};
|
|
602
|
+
|
|
603
|
+
// Valid
|
|
604
|
+
expect(validate({ orders: [{ quantity: 5, price: 10.0 }] }, spec).valid).toBe(true);
|
|
605
|
+
|
|
606
|
+
// Invalid - schema minimum violated
|
|
607
|
+
const minResult = validate({ orders: [{ quantity: 0, price: 10.0 }] }, spec);
|
|
608
|
+
expect(minResult.valid).toBe(false);
|
|
609
|
+
expect(minResult.errors[0].message).toContain("at least 1");
|
|
610
|
+
|
|
611
|
+
// Invalid - custom FEEL validation violated
|
|
612
|
+
const customResult = validate({ orders: [{ quantity: 150, price: 10.0 }] }, spec);
|
|
613
|
+
expect(customResult.valid).toBe(false);
|
|
614
|
+
expect(customResult.errors.some((e) => e.message.includes("more than 100"))).toBe(true);
|
|
615
|
+
|
|
616
|
+
// Invalid - empty array (minItems from schema)
|
|
617
|
+
const emptyResult = validate({ orders: [] }, spec);
|
|
618
|
+
expect(emptyResult.valid).toBe(false);
|
|
619
|
+
expect(emptyResult.errors[0].message).toContain("at least 1 items");
|
|
620
|
+
});
|
|
621
|
+
});
|
|
622
|
+
});
|
|
278
623
|
});
|
package/src/engine/calculate.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { evaluate } from "../feel/index.js";
|
|
9
|
+
import { formatValue, type FormatOptions } from "../format/index.js";
|
|
9
10
|
import type {
|
|
10
11
|
Forma,
|
|
11
12
|
ComputedField,
|
|
@@ -275,12 +276,14 @@ function findComputedDependencies(
|
|
|
275
276
|
* @param fieldName - Name of the computed field
|
|
276
277
|
* @param data - Current form data
|
|
277
278
|
* @param spec - Form specification
|
|
278
|
-
* @
|
|
279
|
+
* @param options - Formatting options (locale, currency, nullDisplay)
|
|
280
|
+
* @returns Formatted string or null if field not found or value is null/undefined
|
|
279
281
|
*/
|
|
280
282
|
export function getFormattedValue(
|
|
281
283
|
fieldName: string,
|
|
282
284
|
data: Record<string, unknown>,
|
|
283
|
-
spec: Forma
|
|
285
|
+
spec: Forma,
|
|
286
|
+
options?: FormatOptions
|
|
284
287
|
): string | null {
|
|
285
288
|
if (!spec.computed?.[fieldName]) {
|
|
286
289
|
return null;
|
|
@@ -291,54 +294,14 @@ export function getFormattedValue(
|
|
|
291
294
|
const value = computed[fieldName];
|
|
292
295
|
|
|
293
296
|
if (value === null || value === undefined) {
|
|
297
|
+
// If nullDisplay is specified, use formatValue to get it
|
|
298
|
+
if (options?.nullDisplay !== undefined) {
|
|
299
|
+
return formatValue(value, fieldDef.format, options);
|
|
300
|
+
}
|
|
294
301
|
return null;
|
|
295
302
|
}
|
|
296
303
|
|
|
297
|
-
return formatValue(value, fieldDef.format);
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
/**
|
|
301
|
-
* Format a value according to a format specification
|
|
302
|
-
*
|
|
303
|
-
* Supported formats:
|
|
304
|
-
* - decimal(n) - Number with n decimal places
|
|
305
|
-
* - currency - Number formatted as currency
|
|
306
|
-
* - percent - Number formatted as percentage
|
|
307
|
-
* - (none) - Default string conversion
|
|
308
|
-
*/
|
|
309
|
-
function formatValue(value: unknown, format?: string): string {
|
|
310
|
-
if (!format) {
|
|
311
|
-
return String(value);
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
// Handle decimal(n) format
|
|
315
|
-
const decimalMatch = format.match(/^decimal\((\d+)\)$/);
|
|
316
|
-
if (decimalMatch) {
|
|
317
|
-
const decimals = parseInt(decimalMatch[1], 10);
|
|
318
|
-
return typeof value === "number" ? value.toFixed(decimals) : String(value);
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
// Handle currency format
|
|
322
|
-
if (format === "currency") {
|
|
323
|
-
return typeof value === "number"
|
|
324
|
-
? new Intl.NumberFormat("en-US", {
|
|
325
|
-
style: "currency",
|
|
326
|
-
currency: "USD",
|
|
327
|
-
}).format(value)
|
|
328
|
-
: String(value);
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
// Handle percent format
|
|
332
|
-
if (format === "percent") {
|
|
333
|
-
return typeof value === "number"
|
|
334
|
-
? new Intl.NumberFormat("en-US", {
|
|
335
|
-
style: "percent",
|
|
336
|
-
minimumFractionDigits: 1,
|
|
337
|
-
}).format(value)
|
|
338
|
-
: String(value);
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
return String(value);
|
|
304
|
+
return formatValue(value, fieldDef.format, options);
|
|
342
305
|
}
|
|
343
306
|
|
|
344
307
|
// ============================================================================
|
package/src/engine/index.ts
CHANGED
|
@@ -12,6 +12,17 @@ export {
|
|
|
12
12
|
getFormattedValue,
|
|
13
13
|
} from "./calculate.js";
|
|
14
14
|
|
|
15
|
+
// Format
|
|
16
|
+
export {
|
|
17
|
+
formatValue,
|
|
18
|
+
isValidFormat,
|
|
19
|
+
parseDecimalFormat,
|
|
20
|
+
SUPPORTED_FORMATS,
|
|
21
|
+
DECIMAL_FORMAT_PATTERN,
|
|
22
|
+
} from "../format/index.js";
|
|
23
|
+
|
|
24
|
+
export type { FormatOptions, SupportedFormat } from "../format/index.js";
|
|
25
|
+
|
|
15
26
|
// Visibility
|
|
16
27
|
export {
|
|
17
28
|
getVisibility,
|