diff --git a/src/error-handlers/multipleOf.js b/src/error-handlers/multipleOf.js index 9e18f5b..6a0673d 100644 --- a/src/error-handlers/multipleOf.js +++ b/src/error-handlers/multipleOf.js @@ -11,32 +11,41 @@ const multipleOfErrorHandler = { /** @type ErrorObject[] */ const errors = []; - /** @type (number | null) */ - let combinedMultipleOf = null; + /** @type number[] */ + const multiples = []; /** @type string[] */ const schemaLocations = []; - let hasError = false; for (const schemaLocation in normalizedErrors["https://json-schema.org/keyword/multipleOf"]) { + const multipleOf = /** @type number */ (normalizedErrors["https://json-schema.org/keyword/multipleOf"][schemaLocation].value); if (normalizedErrors["https://json-schema.org/keyword/multipleOf"][schemaLocation].valid === false) { - hasError = true; + errors.push({ + message: context.localization.getMultipleOfErrorMessage(multipleOf), + instanceLocation: Instance.uri(instance), + schemaLocations: [schemaLocation] + }); } - const multipleOf = /** @type number */ (normalizedErrors["https://json-schema.org/keyword/multipleOf"][schemaLocation].value); - - combinedMultipleOf = combinedMultipleOf === null ? multipleOf : lcm(combinedMultipleOf, multipleOf); + multiples.push(multipleOf); schemaLocations.push(schemaLocation); } - if (combinedMultipleOf !== null && hasError) { - errors.push({ - message: context.localization.getMultipleOfErrorMessage(combinedMultipleOf), - instanceLocation: Instance.uri(instance), - schemaLocations - }); + if (errors.length === 0 || multiples.length === 1) { + return errors; + } + + const combinedMultipleOf = lcm(multiples); + // If the LCM can't be computed exactly, describe the original failing + // constraints instead. + if (combinedMultipleOf === null) { + return errors; } - return errors; + return [{ + message: context.localization.getMultipleOfErrorMessage(combinedMultipleOf), + instanceLocation: Instance.uri(instance), + schemaLocations + }]; }, success: (normalizedOutput, instance, context) => { @@ -46,42 +55,32 @@ const multipleOfErrorHandler = { } }; -/** - * @param {number} value - * @returns {number} - */ -const countDecimals = (value) => value.toString().split(".")[1]?.length || 0; +/** @type (value: number) => number */ +const countDecimals = (value) => { + const [mantissa, exponent = "0"] = value.toString().split("e"); + return Math.max((mantissa.split(".")[1]?.length ?? 0) - Number(exponent), 0); +}; -/** - * @param {number} a - * @param {number} b - * @returns {number} - */ -const gcd = (a, b) => { - const m = 10 ** Math.max(countDecimals(a), countDecimals(b)); - let x = Math.round(a * m); - let y = Math.round(b * m); +/** @type (a: number, b: number) => number */ +const gcd = (a, b) => b === 0 ? Math.abs(a) : gcd(b, a % b); + +/** @type (multiples: number[]) => number | null */ +const lcm = (multiples) => { + const m = 10 ** Math.max(...multiples.map(countDecimals)); + let combined = 1; + for (const multiple of multiples) { + const integer = Math.round(multiple * m); + if (!Number.isSafeInteger(integer)) { + return null; + } - while (y !== 0) { - const temp = y; - y = x % y; - x = temp; + combined = Math.abs(combined / gcd(combined, integer) * integer); + if (!Number.isSafeInteger(combined)) { + return null; + } } - return Math.abs(x) / m; -}; - -/** - * @param {number} a - * @param {number} b - * @returns {number} - */ -const lcm = (a, b) => { - const m = 10 ** Math.max(countDecimals(a), countDecimals(b)); - const x = Math.round(a * m); - const y = Math.round(b * m); - - return Math.abs(x * y) / (gcd(x, y) * m); + return combined / m; }; export default multipleOfErrorHandler; diff --git a/src/localization.js b/src/localization.js index 213fb64..9dc17ca 100644 --- a/src/localization.js +++ b/src/localization.js @@ -1,5 +1,5 @@ import { translations } from "./translations/index.js"; -import { FluentBundle, FluentResource } from "@fluent/bundle"; +import { FluentBundle, FluentNumber, FluentResource } from "@fluent/bundle"; /** * @import { FluentVariable} from "@fluent/bundle" @@ -106,10 +106,15 @@ export class Localization { * @type (messageId: string, args?: Record) => string */ format(messageId, args = {}) { + // Fluent rounds to three fractional digits by default, which would display + // a value like 0.0000001 as 0. + const localizedArgs = Object.fromEntries(Object.entries(args).map(([name, value]) => [ + name, typeof value === "number" ? new FluentNumber(value, { maximumSignificantDigits: 21 }) : value + ])); for (const bundle of [this.bundle, getBundle(DEFAULT_LOCALE)]) { const message = bundle?.getMessage(messageId); if (bundle && message?.value) { - return bundle.formatPattern(message.value, args); + return bundle.formatPattern(message.value, localizedArgs); } } diff --git a/src/test-suite/tests/maximum.json b/src/test-suite/tests/maximum.json index be20462..4983aa5 100644 --- a/src/test-suite/tests/maximum.json +++ b/src/test-suite/tests/maximum.json @@ -28,6 +28,23 @@ "instance": 3, "errors": [] }, + { + "description": "maximum with more than three decimal places", + "schema": { + "maximum": 1.2345 + }, + "instance": 2, + "errors": [ + { + "messageId": "maximum-message", + "messageParams": { + "maximum": "1.2345" + }, + "instanceLocation": "#", + "schemaLocations": ["#/maximum"] + } + ] + }, { "description": "exclusiveMaximum", "compatibility": "6", diff --git a/src/test-suite/tests/minimum.json b/src/test-suite/tests/minimum.json index 8b7252a..0726c00 100644 --- a/src/test-suite/tests/minimum.json +++ b/src/test-suite/tests/minimum.json @@ -28,6 +28,23 @@ "instance": 5, "errors": [] }, + { + "description": "minimum with more than three decimal places", + "schema": { + "minimum": 1e-7 + }, + "instance": 0, + "errors": [ + { + "messageId": "minimum-message", + "messageParams": { + "minimum": "0.0000001" + }, + "instanceLocation": "#", + "schemaLocations": ["#/minimum"] + } + ] + }, { "description": "exclusiveMinimum", "compatibility": "6", diff --git a/src/test-suite/tests/multipleOf.json b/src/test-suite/tests/multipleOf.json index ae0372f..a850789 100644 --- a/src/test-suite/tests/multipleOf.json +++ b/src/test-suite/tests/multipleOf.json @@ -82,6 +82,231 @@ "schemaLocations": ["#/allOf/0/multipleOf", "#/allOf/1/multipleOf"] } ] + }, + { + "description": "integer multipleOf constraints both fail", + "schema": { + "allOf": [ + { "multipleOf": 2 }, + { "multipleOf": 3 } + ] + }, + "instance": 5, + "errors": [ + { + "messageId": "multipleOf-message", + "messageParams": { "multipleOf": "6" }, + "instanceLocation": "#", + "schemaLocations": ["#/allOf/0/multipleOf", "#/allOf/1/multipleOf"] + } + ] + }, + { + "description": "multipleOf constraints in reverse order are partially satisfied", + "schema": { + "allOf": [ + { "multipleOf": 3 }, + { "multipleOf": 2 } + ] + }, + "instance": 9, + "errors": [ + { + "messageId": "multipleOf-message", + "messageParams": { "multipleOf": "6" }, + "instanceLocation": "#", + "schemaLocations": ["#/allOf/0/multipleOf", "#/allOf/1/multipleOf"] + } + ] + }, + { + "description": "equal decimal multipleOf constraints", + "schema": { + "allOf": [ + { "multipleOf": 0.2 }, + { "multipleOf": 0.2 } + ] + }, + "instance": 0.3, + "errors": [ + { + "messageId": "multipleOf-message", + "messageParams": { "multipleOf": "0.2" }, + "instanceLocation": "#", + "schemaLocations": ["#/allOf/0/multipleOf", "#/allOf/1/multipleOf"] + } + ] + }, + { + "description": "decimal multipleOf constraints have different precisions", + "schema": { + "allOf": [ + { "multipleOf": 0.06 }, + { "multipleOf": 0.15 } + ] + }, + "instance": 0.1, + "errors": [ + { + "messageId": "multipleOf-message", + "messageParams": { "multipleOf": "0.3" }, + "instanceLocation": "#", + "schemaLocations": ["#/allOf/0/multipleOf", "#/allOf/1/multipleOf"] + } + ] + }, + { + "description": "decimal multipleOf constraints avoid scaling roundoff", + "schema": { + "allOf": [ + { "multipleOf": 0.07 }, + { "multipleOf": 0.1 } + ] + }, + "instance": 0.11, + "errors": [ + { + "messageId": "multipleOf-message", + "messageParams": { "multipleOf": "0.7" }, + "instanceLocation": "#", + "schemaLocations": ["#/allOf/0/multipleOf", "#/allOf/1/multipleOf"] + } + ] + }, + { + "description": "three decimal multipleOf constraints", + "schema": { + "allOf": [ + { "multipleOf": 0.2 }, + { "multipleOf": 0.3 }, + { "multipleOf": 0.7 } + ] + }, + "instance": 0.1, + "errors": [ + { + "messageId": "multipleOf-message", + "messageParams": { "multipleOf": "4.2" }, + "instanceLocation": "#", + "schemaLocations": ["#/allOf/0/multipleOf", "#/allOf/1/multipleOf", "#/allOf/2/multipleOf"] + } + ] + }, + { + "description": "multipleOf constraints pass together", + "schema": { + "allOf": [ + { "multipleOf": 2 }, + { "multipleOf": 3 } + ] + }, + "instance": 6, + "errors": [] + }, + { + "description": "decimal multipleOf constraints pass together", + "schema": { + "allOf": [ + { "multipleOf": 0.2 }, + { "multipleOf": 0.3 } + ] + }, + "instance": 0.6, + "errors": [] + }, + { + "description": "scientific notation multipleOf constraints", + "schema": { + "allOf": [ + { "multipleOf": 4e-7 }, + { "multipleOf": 6e-7 } + ] + }, + "instance": 1e-6, + "errors": [ + { + "messageId": "multipleOf-message", + "messageParams": { "multipleOf": "0.0000012" }, + "instanceLocation": "#", + "schemaLocations": ["#/allOf/0/multipleOf", "#/allOf/1/multipleOf"] + } + ] + }, + { + "description": "scientific notation multipleOf constraints pass together", + "schema": { + "allOf": [ + { "multipleOf": 1e-07 }, + { "multipleOf": 2e-07 } + ] + }, + "instance": 2e-07, + "errors": [] + }, + { + "description": "multipleOf constraints at nested locations are partially satisfied", + "schema": { + "properties": { + "amount": { + "multipleOf": 0.2, + "allOf": [ + { "multipleOf": 0.3 }, + { "allOf": [{ "multipleOf": 0.04 }] } + ] + } + } + }, + "instance": { "amount": 0.3 }, + "errors": [ + { + "messageId": "multipleOf-message", + "messageParams": { "multipleOf": "0.6" }, + "instanceLocation": "#/amount", + "schemaLocations": ["#/properties/amount/multipleOf", "#/properties/amount/allOf/0/multipleOf", "#/properties/amount/allOf/1/allOf/0/multipleOf"] + } + ] + }, + { + "description": "multipleOf constraints with an unrepresentable LCM report both failures", + "schema": { + "allOf": [ + { "multipleOf": 9007199254740991 }, + { "multipleOf": 3 } + ] + }, + "instance": 1, + "errors": [ + { + "messageId": "multipleOf-message", + "messageParams": { "multipleOf": "9,007,199,254,740,991" }, + "instanceLocation": "#", + "schemaLocations": ["#/allOf/0/multipleOf"] + }, + { + "messageId": "multipleOf-message", + "messageParams": { "multipleOf": "3" }, + "instanceLocation": "#", + "schemaLocations": ["#/allOf/1/multipleOf"] + } + ] + }, + { + "description": "multipleOf constraints with an unrepresentable LCM report only failures", + "schema": { + "allOf": [ + { "multipleOf": 9007199254740991 }, + { "multipleOf": 3 } + ] + }, + "instance": 3, + "errors": [ + { + "messageId": "multipleOf-message", + "messageParams": { "multipleOf": "9,007,199,254,740,991" }, + "instanceLocation": "#", + "schemaLocations": ["#/allOf/0/multipleOf"] + } + ] } ] }