@fogpipe/forma-core 0.7.0 → 0.8.1

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,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
+ });
@@ -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 {