@fogpipe/forma-core 0.7.0 → 0.8.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.
- package/dist/{chunk-IRLYWN3R.js → chunk-VHKUCCAC.js} +13 -1
- package/dist/{chunk-IRLYWN3R.js.map → chunk-VHKUCCAC.js.map} +1 -1
- package/dist/engine/index.cjs +12 -0
- package/dist/engine/index.cjs.map +1 -1
- package/dist/engine/index.js +1 -1
- package/dist/engine/validate.d.ts.map +1 -1
- package/dist/index.cjs +12 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +1 -1
- package/dist/types.d.ts +2 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -1
- package/src/__tests__/validate.test.ts +278 -0
- package/src/engine/validate.ts +14 -0
- package/src/types.ts +2 -0
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for validation engine
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect } from "vitest";
|
|
6
|
+
import { validate } from "../engine/validate.js";
|
|
7
|
+
import type { Forma } from "../types.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Helper to create a minimal Forma spec for testing
|
|
11
|
+
*/
|
|
12
|
+
function createTestSpec(options: {
|
|
13
|
+
schemaProperties: Record<string, unknown>;
|
|
14
|
+
fields?: Record<string, unknown>;
|
|
15
|
+
required?: string[];
|
|
16
|
+
}): Forma {
|
|
17
|
+
const { schemaProperties, fields = {}, required = [] } = options;
|
|
18
|
+
|
|
19
|
+
// Build fields from schema if not provided
|
|
20
|
+
const fieldDefs = Object.keys(schemaProperties).reduce(
|
|
21
|
+
(acc, key) => {
|
|
22
|
+
acc[key] = fields[key] || { label: key };
|
|
23
|
+
return acc;
|
|
24
|
+
},
|
|
25
|
+
{} as Record<string, unknown>
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
version: "1.0",
|
|
30
|
+
meta: { id: "test", title: "Test" },
|
|
31
|
+
schema: {
|
|
32
|
+
type: "object",
|
|
33
|
+
properties: schemaProperties as Forma["schema"]["properties"],
|
|
34
|
+
required: required.length > 0 ? required : undefined,
|
|
35
|
+
},
|
|
36
|
+
fields: fieldDefs as Forma["fields"],
|
|
37
|
+
fieldOrder: Object.keys(schemaProperties),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe("validate", () => {
|
|
42
|
+
// ============================================================================
|
|
43
|
+
// multipleOf validation
|
|
44
|
+
// ============================================================================
|
|
45
|
+
|
|
46
|
+
describe("multipleOf", () => {
|
|
47
|
+
it("should pass when value is a multiple of the constraint", () => {
|
|
48
|
+
const spec = createTestSpec({
|
|
49
|
+
schemaProperties: {
|
|
50
|
+
amount: { type: "number", multipleOf: 0.01 },
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const result = validate({ amount: 10.25 }, spec);
|
|
55
|
+
|
|
56
|
+
expect(result.valid).toBe(true);
|
|
57
|
+
expect(result.errors).toHaveLength(0);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("should fail when value is not a multiple of the constraint", () => {
|
|
61
|
+
const spec = createTestSpec({
|
|
62
|
+
schemaProperties: {
|
|
63
|
+
amount: { type: "number", multipleOf: 0.01 },
|
|
64
|
+
},
|
|
65
|
+
fields: { amount: { label: "Amount" } },
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const result = validate({ amount: 10.255 }, spec);
|
|
69
|
+
|
|
70
|
+
expect(result.valid).toBe(false);
|
|
71
|
+
expect(result.errors).toHaveLength(1);
|
|
72
|
+
expect(result.errors[0].field).toBe("amount");
|
|
73
|
+
expect(result.errors[0].message).toBe("Amount must be a multiple of 0.01");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("should handle multipleOf for integers", () => {
|
|
77
|
+
const spec = createTestSpec({
|
|
78
|
+
schemaProperties: {
|
|
79
|
+
quantity: { type: "integer", multipleOf: 5 },
|
|
80
|
+
},
|
|
81
|
+
fields: { quantity: { label: "Quantity" } },
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// Valid - multiple of 5
|
|
85
|
+
expect(validate({ quantity: 15 }, spec).valid).toBe(true);
|
|
86
|
+
expect(validate({ quantity: 100 }, spec).valid).toBe(true);
|
|
87
|
+
|
|
88
|
+
// Invalid - not multiple of 5
|
|
89
|
+
const result = validate({ quantity: 17 }, spec);
|
|
90
|
+
expect(result.valid).toBe(false);
|
|
91
|
+
expect(result.errors[0].message).toBe("Quantity must be a multiple of 5");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("should handle multipleOf: 0.5 for half-units", () => {
|
|
95
|
+
const spec = createTestSpec({
|
|
96
|
+
schemaProperties: {
|
|
97
|
+
rating: { type: "number", multipleOf: 0.5 },
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// Valid half-units
|
|
102
|
+
expect(validate({ rating: 3.5 }, spec).valid).toBe(true);
|
|
103
|
+
expect(validate({ rating: 4.0 }, spec).valid).toBe(true);
|
|
104
|
+
expect(validate({ rating: 0.5 }, spec).valid).toBe(true);
|
|
105
|
+
|
|
106
|
+
// Invalid
|
|
107
|
+
expect(validate({ rating: 3.3 }, spec).valid).toBe(false);
|
|
108
|
+
expect(validate({ rating: 4.25 }, spec).valid).toBe(false);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("should handle floating point precision correctly", () => {
|
|
112
|
+
const spec = createTestSpec({
|
|
113
|
+
schemaProperties: {
|
|
114
|
+
value: { type: "number", multipleOf: 0.1 },
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// These should pass despite floating point precision issues
|
|
119
|
+
// 0.1 + 0.2 = 0.30000000000000004 in JS
|
|
120
|
+
expect(validate({ value: 0.3 }, spec).valid).toBe(true);
|
|
121
|
+
expect(validate({ value: 0.7 }, spec).valid).toBe(true);
|
|
122
|
+
expect(validate({ value: 1.1 }, spec).valid).toBe(true);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("should pass validation when value is null/undefined (empty)", () => {
|
|
126
|
+
const spec = createTestSpec({
|
|
127
|
+
schemaProperties: {
|
|
128
|
+
amount: { type: "number", multipleOf: 0.01 },
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// Empty values should not trigger multipleOf validation
|
|
133
|
+
expect(validate({ amount: null }, spec).valid).toBe(true);
|
|
134
|
+
expect(validate({ amount: undefined }, spec).valid).toBe(true);
|
|
135
|
+
expect(validate({}, spec).valid).toBe(true);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("should work with minimum and maximum constraints together", () => {
|
|
139
|
+
const spec = createTestSpec({
|
|
140
|
+
schemaProperties: {
|
|
141
|
+
friction: {
|
|
142
|
+
type: "number",
|
|
143
|
+
minimum: 0.05,
|
|
144
|
+
maximum: 0.3,
|
|
145
|
+
multipleOf: 0.01,
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
fields: { friction: { label: "Friction Coefficient" } },
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// Valid - within range and correct precision
|
|
152
|
+
expect(validate({ friction: 0.14 }, spec).valid).toBe(true);
|
|
153
|
+
expect(validate({ friction: 0.05 }, spec).valid).toBe(true);
|
|
154
|
+
expect(validate({ friction: 0.3 }, spec).valid).toBe(true);
|
|
155
|
+
|
|
156
|
+
// Invalid - wrong precision
|
|
157
|
+
const precisionResult = validate({ friction: 0.145 }, spec);
|
|
158
|
+
expect(precisionResult.valid).toBe(false);
|
|
159
|
+
expect(precisionResult.errors[0].message).toContain("multiple of 0.01");
|
|
160
|
+
|
|
161
|
+
// Invalid - below minimum
|
|
162
|
+
const minResult = validate({ friction: 0.01 }, spec);
|
|
163
|
+
expect(minResult.valid).toBe(false);
|
|
164
|
+
expect(minResult.errors[0].message).toContain("at least 0.05");
|
|
165
|
+
|
|
166
|
+
// Invalid - above maximum
|
|
167
|
+
const maxResult = validate({ friction: 0.5 }, spec);
|
|
168
|
+
expect(maxResult.valid).toBe(false);
|
|
169
|
+
expect(maxResult.errors[0].message).toContain("no more than 0.3");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("should handle multipleOf: 1 for whole numbers", () => {
|
|
173
|
+
const spec = createTestSpec({
|
|
174
|
+
schemaProperties: {
|
|
175
|
+
count: { type: "number", multipleOf: 1 },
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
expect(validate({ count: 5 }, spec).valid).toBe(true);
|
|
180
|
+
expect(validate({ count: 100 }, spec).valid).toBe(true);
|
|
181
|
+
expect(validate({ count: 5.5 }, spec).valid).toBe(false);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("should handle very small multipleOf values", () => {
|
|
185
|
+
const spec = createTestSpec({
|
|
186
|
+
schemaProperties: {
|
|
187
|
+
precision: { type: "number", multipleOf: 0.001 },
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
expect(validate({ precision: 1.234 }, spec).valid).toBe(true);
|
|
192
|
+
expect(validate({ precision: 1.2345 }, spec).valid).toBe(false);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("should handle large multipleOf values", () => {
|
|
196
|
+
const spec = createTestSpec({
|
|
197
|
+
schemaProperties: {
|
|
198
|
+
angle: { type: "integer", multipleOf: 15 },
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
expect(validate({ angle: 0 }, spec).valid).toBe(true);
|
|
203
|
+
expect(validate({ angle: 15 }, spec).valid).toBe(true);
|
|
204
|
+
expect(validate({ angle: 90 }, spec).valid).toBe(true);
|
|
205
|
+
expect(validate({ angle: 360 }, spec).valid).toBe(true);
|
|
206
|
+
expect(validate({ angle: 45 }, spec).valid).toBe(true);
|
|
207
|
+
expect(validate({ angle: 17 }, spec).valid).toBe(false);
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// ============================================================================
|
|
212
|
+
// Basic type validation (existing behavior)
|
|
213
|
+
// ============================================================================
|
|
214
|
+
|
|
215
|
+
describe("basic type validation", () => {
|
|
216
|
+
it("should validate required fields", () => {
|
|
217
|
+
const spec = createTestSpec({
|
|
218
|
+
schemaProperties: {
|
|
219
|
+
name: { type: "string" },
|
|
220
|
+
},
|
|
221
|
+
required: ["name"],
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
const result = validate({ name: "" }, spec);
|
|
225
|
+
|
|
226
|
+
expect(result.valid).toBe(false);
|
|
227
|
+
expect(result.errors[0].message).toContain("required");
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("should validate minimum for numbers", () => {
|
|
231
|
+
const spec = createTestSpec({
|
|
232
|
+
schemaProperties: {
|
|
233
|
+
age: { type: "integer", minimum: 0 },
|
|
234
|
+
},
|
|
235
|
+
fields: { age: { label: "Age" } },
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
expect(validate({ age: 25 }, spec).valid).toBe(true);
|
|
239
|
+
expect(validate({ age: 0 }, spec).valid).toBe(true);
|
|
240
|
+
|
|
241
|
+
const result = validate({ age: -1 }, spec);
|
|
242
|
+
expect(result.valid).toBe(false);
|
|
243
|
+
expect(result.errors[0].message).toBe("Age must be at least 0");
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("should validate maximum for numbers", () => {
|
|
247
|
+
const spec = createTestSpec({
|
|
248
|
+
schemaProperties: {
|
|
249
|
+
percentage: { type: "number", maximum: 100 },
|
|
250
|
+
},
|
|
251
|
+
fields: { percentage: { label: "Percentage" } },
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
expect(validate({ percentage: 50 }, spec).valid).toBe(true);
|
|
255
|
+
expect(validate({ percentage: 100 }, spec).valid).toBe(true);
|
|
256
|
+
|
|
257
|
+
const result = validate({ percentage: 101 }, spec);
|
|
258
|
+
expect(result.valid).toBe(false);
|
|
259
|
+
expect(result.errors[0].message).toBe("Percentage must be no more than 100");
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("should validate string minLength", () => {
|
|
263
|
+
const spec = createTestSpec({
|
|
264
|
+
schemaProperties: {
|
|
265
|
+
name: { type: "string", minLength: 2 },
|
|
266
|
+
},
|
|
267
|
+
fields: { name: { label: "Name" } },
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
expect(validate({ name: "Jo" }, spec).valid).toBe(true);
|
|
271
|
+
expect(validate({ name: "John" }, spec).valid).toBe(true);
|
|
272
|
+
|
|
273
|
+
const result = validate({ name: "J" }, spec);
|
|
274
|
+
expect(result.valid).toBe(false);
|
|
275
|
+
expect(result.errors[0].message).toBe("Name must be at least 2 characters");
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
});
|
package/src/engine/validate.ts
CHANGED
|
@@ -333,6 +333,20 @@ function validateType(
|
|
|
333
333
|
}
|
|
334
334
|
}
|
|
335
335
|
|
|
336
|
+
if ("multipleOf" in schema && schema.multipleOf !== undefined) {
|
|
337
|
+
const multipleOf = schema.multipleOf;
|
|
338
|
+
// Use epsilon comparison to handle floating point precision issues
|
|
339
|
+
const remainder = Math.abs(value % multipleOf);
|
|
340
|
+
const isValid = remainder < 1e-10 || Math.abs(remainder - multipleOf) < 1e-10;
|
|
341
|
+
if (!isValid) {
|
|
342
|
+
return {
|
|
343
|
+
field: path,
|
|
344
|
+
message: `${label} must be a multiple of ${multipleOf}`,
|
|
345
|
+
severity: "error",
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
336
350
|
return null;
|
|
337
351
|
}
|
|
338
352
|
|
package/src/types.ts
CHANGED
|
@@ -71,12 +71,14 @@ export interface JSONSchemaNumber extends JSONSchemaBase {
|
|
|
71
71
|
maximum?: number;
|
|
72
72
|
exclusiveMinimum?: number;
|
|
73
73
|
exclusiveMaximum?: number;
|
|
74
|
+
multipleOf?: number;
|
|
74
75
|
}
|
|
75
76
|
|
|
76
77
|
export interface JSONSchemaInteger extends JSONSchemaBase {
|
|
77
78
|
type: "integer";
|
|
78
79
|
minimum?: number;
|
|
79
80
|
maximum?: number;
|
|
81
|
+
multipleOf?: number;
|
|
80
82
|
}
|
|
81
83
|
|
|
82
84
|
export interface JSONSchemaBoolean extends JSONSchemaBase {
|