diff --git a/src/validation/number.ts b/src/validation/number.ts index 2f94eb9d..a05a2a2c 100644 --- a/src/validation/number.ts +++ b/src/validation/number.ts @@ -2,6 +2,65 @@ import type { ValidationError, ValidationErrorPath } from '../errors' import type { NonBooleanJsfSchema, SchemaValue } from '../types' import { getSchemaType } from './schema' +interface DecimalParts { + digits: bigint + exponent: number +} + +/** + * Decompose a finite number into its exact decimal digits and exponent. + * @param value - The number to decompose + * @returns The digits (as a bigint) and the power of ten they must be multiplied by + * @description + * The decomposition is based on `toString()`, which returns the shortest decimal representation + * that round-trips to the same double - in other words, the number as it was written in the schema + * or typed by the user. So `0.0001` decomposes to `1 * 10 ** -4`, not to the slightly larger binary + * value the double actually holds. + */ +function toDecimalParts(value: number): DecimalParts { + const [mantissa, exponent] = value.toString().split('e') + const [integerDigits, fractionDigits = ''] = mantissa.split('.') + + return { + digits: BigInt(integerDigits + fractionDigits), + exponent: (exponent === undefined ? 0 : Number(exponent)) - fractionDigits.length, + } +} + +/** + * Check whether a value is an exact multiple of `multipleOf`. + * @param value - The number being validated + * @param multipleOf - The divisor the value must be a multiple of + * @returns `true` if the value is an exact multiple of `multipleOf` + * @description + * A plain `value % multipleOf` check is unreliable whenever `multipleOf` is fractional, because a + * value like `0.0001` has no exact binary representation: `3.025 % 0.0001` yields `0.00009999...` + * instead of `0`, so unambiguously valid values get rejected. + * + * To avoid this, both numbers are scaled by the same power of ten until they are integers, and the + * remainder is taken with bigint arithmetic, which is exact at any magnitude. + */ +function isMultipleOf(value: number, multipleOf: number): boolean { + if (Number.isInteger(value) && Number.isInteger(multipleOf)) { + return value % multipleOf === 0 + } + + // NaN, Infinity and a zero divisor can never produce a valid multiple. + if (!Number.isFinite(value) || !Number.isFinite(multipleOf) || multipleOf === 0) { + return false + } + + const dividend = toDecimalParts(value) + const divisor = toDecimalParts(multipleOf) + + // Line both numbers up on the smaller exponent so that they are both whole numbers + const exponent = Math.min(dividend.exponent, divisor.exponent) + const scaledValue = dividend.digits * 10n ** BigInt(dividend.exponent - exponent) + const scaledMultipleOf = divisor.digits * 10n ** BigInt(divisor.exponent - exponent) + + return scaledValue % scaledMultipleOf === 0n +} + /** * Validate a number against a schema * @param value - The value to validate @@ -33,7 +92,7 @@ export function validateNumber( } // MultipleOf validation - dividing value by multipleOf must have no remainder - if (schema.multipleOf !== undefined && value % schema.multipleOf !== 0) { + if (schema.multipleOf !== undefined && !isMultipleOf(value, schema.multipleOf)) { errors.push({ path, validation: 'multipleOf', schema, value }) } diff --git a/test/json-schema-test-suite/failed-json-schema-test-suite.json b/test/json-schema-test-suite/failed-json-schema-test-suite.json index e4155abd..1ad06eb8 100644 --- a/test/json-schema-test-suite/failed-json-schema-test-suite.json +++ b/test/json-schema-test-suite/failed-json-schema-test-suite.json @@ -177,12 +177,6 @@ "minProperties validation with a decimal": [ "too short is invalid" ], - "by small number": [ - "0.0075 is multiple of 0.0001" - ], - "small multiple of large integer": [ - "any integer is a multiple of 1e-8" - ], "collect annotations inside a 'not', even if collection is disabled": [ "unevaluated property" ], diff --git a/test/validation/number.test.ts b/test/validation/number.test.ts index 59474407..eedadaec 100644 --- a/test/validation/number.test.ts +++ b/test/validation/number.test.ts @@ -1,4 +1,6 @@ +import type { JsfObjectSchema } from '../../src/types' import { describe, expect, it } from '@jest/globals' +import { createHeadlessForm } from '../../src' import { validateSchema } from '../../src/validation/schema' import { errorLike } from '../test-utils' @@ -163,4 +165,69 @@ describe('number validation', () => { }), ]) }) + + describe('multipleOf with fractional values', () => { + it('accepts values that are true multiples of a fractional multipleOf', () => { + expect(validateSchema(3.025, { type: 'number', multipleOf: 0.0001 })).toEqual([]) + expect(validateSchema(3.4, { type: 'number', multipleOf: 0.0001 })).toEqual([]) + expect(validateSchema(0, { type: 'number', multipleOf: 0.0001 })).toEqual([]) + expect(validateSchema(0.0075, { type: 'number', multipleOf: 0.0001 })).toEqual([]) + expect(validateSchema(100, { type: 'number', multipleOf: 0.01 })).toEqual([]) + expect(validateSchema(12.34, { type: 'number', multipleOf: 0.01 })).toEqual([]) + expect(validateSchema(250.5, { type: 'number', multipleOf: 0.01 })).toEqual([]) + expect(validateSchema(4.5, { type: 'number', multipleOf: 1.5 })).toEqual([]) + expect(validateSchema(-3.4, { type: 'number', multipleOf: 0.0001 })).toEqual([]) + }) + + it('still rejects values that are not multiples of a fractional multipleOf', () => { + const multipleOfError = [errorLike({ path: [], validation: 'multipleOf' })] + + expect(validateSchema(3.12345, { type: 'number', multipleOf: 0.0001 })).toEqual(multipleOfError) + expect(validateSchema(0.00751, { type: 'number', multipleOf: 0.0001 })).toEqual(multipleOfError) + expect(validateSchema(12.345, { type: 'number', multipleOf: 0.01 })).toEqual(multipleOfError) + expect(validateSchema(35, { type: 'number', multipleOf: 1.5 })).toEqual(multipleOfError) + expect(validateSchema(-3.12345, { type: 'number', multipleOf: 0.0001 })).toEqual(multipleOfError) + }) + + it('validates a percentage field nested in a fieldset', () => { + const schema: JsfObjectSchema = { + 'type': 'object', + 'additionalProperties': false, + 'properties': { + foo: { + 'type': 'object', + 'additionalProperties': false, + 'title': 'Working title', + 'properties': { + problematic_field: { + 'type': 'number', + 'title': 'Should accept 3.025', + 'description': 'Enter a number between 0 and 99.9999', + 'minimum': 0, + 'maximum': 99.9999, + 'multipleOf': 0.0001, + 'x-jsf-errorMessage': { + maximum: 'Please enter a number as a percentage value with up to 4 decimal places', + }, + 'x-jsf-presentation': { inputType: 'number' }, + }, + }, + 'required': ['problematic_field'], + 'x-jsf-order': ['problematic_field'], + 'x-jsf-presentation': { inputType: 'fieldset' }, + }, + }, + 'required': ['foo'], + 'x-jsf-order': ['foo'], + } + + const form = createHeadlessForm(schema) + + expect(form.handleValidation({ foo: { problematic_field: 3.025 } }).formErrors).toBeUndefined() + + expect(form.handleValidation({ foo: { problematic_field: 3.02555 } }).formErrors).toEqual({ + foo: { problematic_field: 'Must be a multiple of 0.0001' }, + }) + }) + }) })