From d79feabd79b81e8687502e8b0eb3ce0eff9627c9 Mon Sep 17 00:00:00 2001 From: Kaileshwar16 Date: Mon, 12 Oct 2026 19:02:36 +0530 Subject: [PATCH 1/3] fix: handle scientific notation in multipleOf error aggregation --- src/error-handlers/multipleOf.js | 88 ++++++--- src/localization.js | 7 +- src/multipleOf.test.js | 142 +++++++++++++++ src/test-suite/tests/multipleOf.json | 262 +++++++++++++++++++++++++++ 4 files changed, 467 insertions(+), 32 deletions(-) create mode 100644 src/multipleOf.test.js diff --git a/src/error-handlers/multipleOf.js b/src/error-handlers/multipleOf.js index 9e18f5b..76488c2 100644 --- a/src/error-handlers/multipleOf.js +++ b/src/error-handlers/multipleOf.js @@ -11,29 +11,38 @@ 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({ + if (errors.length > 0) { + const combinedMultipleOf = lcm(multiples); + // If the exact LCM cannot be represented as a number, describe the + // original failing constraints rather than a rounded or infinite multiple. + if (combinedMultipleOf === null) { + return errors; + } + + return [{ message: context.localization.getMultipleOfErrorMessage(combinedMultipleOf), instanceLocation: Instance.uri(instance), schemaLocations - }); + }]; } return errors; @@ -48,40 +57,59 @@ const multipleOfErrorHandler = { /** * @param {number} value - * @returns {number} + * @returns {{ coefficient: bigint, exponent: number }} */ -const countDecimals = (value) => value.toString().split(".")[1]?.length || 0; +const toDecimal = (value) => { + const [mantissa, exponent = "0"] = value.toString().split("e"); + const decimals = mantissa.split(".")[1]?.length ?? 0; + return { + coefficient: BigInt(mantissa.replace(".", "")), + exponent: Number(exponent) - decimals + }; +}; /** - * @param {number} a - * @param {number} b - * @returns {number} + * @param {bigint} x + * @param {bigint} y + * @returns {bigint} */ -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); - - while (y !== 0) { +const gcd = (x, y) => { + while (y !== 0n) { const temp = y; y = x % y; x = temp; } - return Math.abs(x) / m; + return x; }; /** - * @param {number} a - * @param {number} b - * @returns {number} + * @param {number[]} multiples + * @returns {number | null} */ -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); +const lcm = (multiples) => { + // Use the decimal representations of the schema values. Keep a common scale + // and exact integers throughout, including when combining three or more values. + const decimals = multiples.map(toDecimal); + const exponent = decimals.reduce((min, decimal) => Math.min(min, decimal.exponent), Infinity); + let combined = 1n; + for (const decimal of decimals) { + const integer = decimal.coefficient * 10n ** BigInt(decimal.exponent - exponent); + combined = (combined / gcd(combined, integer)) * integer; + } + + const result = Number(`${combined}e${exponent}`); + if (!Number.isFinite(result)) { + return null; + } - return Math.abs(x * y) / (gcd(x, y) * m); + // A finite result can still have lost significant digits during conversion. + const rounded = toDecimal(result); + const commonExponent = Math.min(exponent, rounded.exponent); + return combined * 10n ** BigInt(exponent - commonExponent) + === rounded.coefficient * 10n ** BigInt(rounded.exponent - commonExponent) + ? result + : null; }; export default multipleOfErrorHandler; diff --git a/src/localization.js b/src/localization.js index 213fb64..296745f 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" @@ -213,7 +213,10 @@ export class Localization { /** @type (multipleOf: number) => string */ getMultipleOfErrorMessage(multipleOf) { - return this.format("multipleOf-message", { multipleOf }); + return this.format("multipleOf-message", { + // Fluent's default three fractional digits would display small multiples as zero. + multipleOf: new FluentNumber(multipleOf, { maximumSignificantDigits: 21 }) + }); } /** @type (maxLength: number) => string */ diff --git a/src/multipleOf.test.js b/src/multipleOf.test.js new file mode 100644 index 0000000..3a8ce84 --- /dev/null +++ b/src/multipleOf.test.js @@ -0,0 +1,142 @@ +import { afterEach, describe, expect, test } from "vitest"; +import { registerSchema, unregisterSchema } from "@hyperjump/json-schema/draft-2020-12"; +import { addTranslation, jsonSchemaErrors } from "./index.js"; + +describe("multipleOf error aggregation", () => { + const schemaUri = "https://example.com/multipleOf"; + const dialectUri = "https://json-schema.org/draft/2020-12/schema"; + + afterEach(() => { + unregisterSchema(schemaUri); + }); + + // Hyperjump accepts the instance in #289. Simulate standard validation output + // to exercise the formatter independently of the validator's arithmetic. + test("combines scientific notation constraints (#289)", async () => { + registerSchema({ + allOf: [{ multipleOf: 1e-7 }, { multipleOf: 2e-7 }] + }, schemaUri, dialectUri); + + const output = { + valid: false, + errors: [ + { instanceLocation: "#", keywordLocation: "/allOf/0/multipleOf" }, + { instanceLocation: "#", keywordLocation: "/allOf/1/multipleOf" } + ] + }; + + expect(await jsonSchemaErrors(output, schemaUri, 1.5e-7)).to.eql([{ + message: "Expected a number that is a multiple of 0.0000002", + instanceLocation: "#", + schemaLocations: [`${schemaUri}#/allOf/0/multipleOf`, `${schemaUri}#/allOf/1/multipleOf`] + }]); + }); + + test.each([ + { description: "decimal mantissas", multiples: [1.2e-7, 1.8e-7], instance: 1e-7, expected: "0.00000036" }, + { description: "reversed decimal mantissas", multiples: [1.8e-7, 1.2e-7], instance: 1e-7, expected: "0.00000036" }, + { description: "different exponents", multiples: [1.2e-7, 1.8e-8], instance: 1e-8, expected: "0.00000036" }, + { description: "mixed decimal and scientific notation", multiples: [0.0003, 2e-7], instance: 1e-7, expected: "0.0003" }, + { description: "equal constraints", multiples: [1.2e-7, 1.2e-7], instance: 1e-7, expected: "0.00000012" }, + { description: "single constraint", multiples: [1e-7], instance: 1.5e-7, expected: "0.0000001" }, + { description: "three constraints", multiples: [1.2e-7, 1.8e-7, 2.7e-7], instance: 1e-7, expected: "0.00000108" }, + { description: "reversed three constraints", multiples: [2.7e-7, 1.8e-7, 1.2e-7], instance: 1e-7, expected: "0.00000108" }, + { description: "subnormal scale", multiples: [2e-323, 3e-323], instance: 5e-324, expected: `0.${"0".repeat(322)}6` }, + { description: "overflowing intermediate product", multiples: [2e200, 3e200], instance: 1, expected: `600${",000".repeat(66)}` }, + { description: "widely separated exponents", multiples: [1e-308, 1e308], instance: 1e-309, expected: `100${",000".repeat(102)}` } + ])("combines $description", async ({ multiples, instance, expected }) => { + registerSchema({ allOf: multiples.map((multipleOf) => ({ multipleOf })) }, schemaUri, dialectUri); + const locations = multiples.map((_, index) => `/allOf/${index}/multipleOf`); + const output = { + valid: false, + errors: locations.map((keywordLocation) => ({ instanceLocation: "#", keywordLocation })) + }; + + expect(await jsonSchemaErrors(output, schemaUri, instance)).to.eql([{ + message: `Expected a number that is a multiple of ${expected}`, + instanceLocation: "#", + schemaLocations: locations.map((location) => `${schemaUri}#${location}`) + }]); + }); + + test.each([ + { multiples: [1e-7, 2e-7], failedIndex: 1 }, + { multiples: [2e-7, 1e-7], failedIndex: 0 } + ])("includes a satisfied constraint in $multiples", async ({ multiples, failedIndex }) => { + registerSchema({ + properties: { + amount: { allOf: [{ allOf: multiples.map((multipleOf) => ({ multipleOf })) }] } + } + }, schemaUri, dialectUri); + const output = { + valid: false, + errors: [{ + instanceLocation: "#/amount", + keywordLocation: `/properties/amount/allOf/0/allOf/${failedIndex}/multipleOf` + }] + }; + + expect(await jsonSchemaErrors(output, schemaUri, { amount: 1e-7 })).to.eql([{ + message: "Expected a number that is a multiple of 0.0000002", + instanceLocation: "#/amount", + schemaLocations: [ + `${schemaUri}#/properties/amount/allOf/0/allOf/0/multipleOf`, + `${schemaUri}#/properties/amount/allOf/0/allOf/1/multipleOf` + ] + }]); + }); + + test.each([ + { description: "overflow", multiples: [1e308, 3e307], instance: 1, failed: [0, 1] }, + { description: "overflow with a satisfied constraint", multiples: [1e308, 3e307], instance: 1e308, failed: [1] } + ])("reports original failing constraints for $description", async ({ multiples, instance, failed }) => { + registerSchema({ allOf: multiples.map((multipleOf) => ({ multipleOf })) }, schemaUri, dialectUri); + const output = { + valid: false, + errors: failed.map((index) => ({ instanceLocation: "#", keywordLocation: `/allOf/${index}/multipleOf` })) + }; + + expect(await jsonSchemaErrors(output, schemaUri, instance)).to.eql(failed.map((index) => ({ + message: `Expected a number that is a multiple of ${new Intl.NumberFormat("en-US").format(multiples[index])}`, + instanceLocation: "#", + schemaLocations: [`${schemaUri}#/allOf/${index}/multipleOf`] + }))); + }); + + test("successful scientific notation constraints produce no errors", async () => { + registerSchema({ allOf: [{ multipleOf: 1e-7 }, { multipleOf: 2e-7 }] }, schemaUri, dialectUri); + expect(await jsonSchemaErrors({ valid: true }, schemaUri, 2e-7)).to.eql([]); + }); + + test("preserves localized digits, separators, and grouping", async () => { + addTranslation("de-DE", "multipleOf-message = Erwartet ein Vielfaches von {$multipleOf}"); + registerSchema({ + properties: { + small: { allOf: [{ multipleOf: 1e-7 }, { multipleOf: 2e-7 }] }, + large: { allOf: [{ multipleOf: 1000 }, { multipleOf: 1500 }] } + } + }, schemaUri, dialectUri); + const output = { + valid: false, + errors: [ + { instanceLocation: "#/small", keywordLocation: "/properties/small/allOf/0/multipleOf" }, + { instanceLocation: "#/small", keywordLocation: "/properties/small/allOf/1/multipleOf" }, + { instanceLocation: "#/large", keywordLocation: "/properties/large/allOf/0/multipleOf" }, + { instanceLocation: "#/large", keywordLocation: "/properties/large/allOf/1/multipleOf" } + ] + }; + + expect(await jsonSchemaErrors(output, schemaUri, { small: 1.5e-7, large: 1 }, { locale: "de-DE" })).to.eql([ + { + message: "Erwartet ein Vielfaches von 0,0000002", + instanceLocation: "#/small", + schemaLocations: [`${schemaUri}#/properties/small/allOf/0/multipleOf`, `${schemaUri}#/properties/small/allOf/1/multipleOf`] + }, + { + message: "Erwartet ein Vielfaches von 3.000", + instanceLocation: "#/large", + schemaLocations: [`${schemaUri}#/properties/large/allOf/0/multipleOf`, `${schemaUri}#/properties/large/allOf/1/multipleOf`] + } + ]); + }); +}); diff --git a/src/test-suite/tests/multipleOf.json b/src/test-suite/tests/multipleOf.json index ae0372f..dbe5615 100644 --- a/src/test-suite/tests/multipleOf.json +++ b/src/test-suite/tests/multipleOf.json @@ -82,6 +82,268 @@ "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 with positive exponents and decimal mantissas", + "schema": { + "allOf": [ + { "multipleOf": 1.2e+21 }, + { "multipleOf": 1.8e+21 } + ] + }, + "instance": 1, + "errors": [ + { + "messageId": "multipleOf-message", + "messageParams": { "multipleOf": "3,600,000,000,000,000,000,000" }, + "instanceLocation": "#", + "schemaLocations": ["#/allOf/0/multipleOf", "#/allOf/1/multipleOf"] + } + ] + }, + { + "description": "multipleOf constraints avoid unsafe integer scaling", + "schema": { + "allOf": [ + { "multipleOf": 9007199254740991 }, + { "multipleOf": 0.1 } + ] + }, + "instance": 0.5, + "errors": [ + { + "messageId": "multipleOf-message", + "messageParams": { "multipleOf": "9,007,199,254,740,991" }, + "instanceLocation": "#", + "schemaLocations": ["#/allOf/0/multipleOf", "#/allOf/1/multipleOf"] + } + ] + }, + { + "description": "multipleOf constraints avoid rounding an intermediate LCM", + "schema": { + "allOf": [ + { "multipleOf": 9007199254740991 }, + { "multipleOf": 3 }, + { "multipleOf": 10 } + ] + }, + "instance": 1, + "errors": [ + { + "messageId": "multipleOf-message", + "messageParams": { "multipleOf": "270,215,977,642,229,730" }, + "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 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"] + } + ] } ] } From 3d09a2b33a588d074ab93b135d478296f7265230 Mon Sep 17 00:00:00 2001 From: Kaileshwar16 Date: Wed, 14 Oct 2026 13:41:12 +0530 Subject: [PATCH 2/3] fix: preserve numeric precision across localized messages --- src/localization.js | 21 ++++++++--- src/localization.test.js | 47 ++++++++++++++++++++++++ src/multipleOf.test.js | 8 ----- src/numeric-messages.test.js | 69 ++++++++++++++++++++++++++++++++++++ 4 files changed, 132 insertions(+), 13 deletions(-) create mode 100644 src/numeric-messages.test.js diff --git a/src/localization.js b/src/localization.js index 296745f..5ee1e67 100644 --- a/src/localization.js +++ b/src/localization.js @@ -14,6 +14,17 @@ const bundles = new Map(); /** @type Map */ const localizationCache = new Map(); +// Change implicit number rendering without changing Fluent's plural selection +// or the formatting chosen explicitly by NUMBER() in a translation. +class MessageNumber extends FluentNumber { + /** @param {Parameters[0]} [scope] */ + toString(scope) { + // Significant digits cover the full Number range, including subnormals, + // without Fluent's default rounding to three fractional digits. + return new FluentNumber(this.valueOf(), { maximumSignificantDigits: 21 }).toString(scope); + } +} + /** @type (locale: string, direction: "ltr" | "rtl") => FluentBundle */ const createBundle = (locale, direction) => { return new FluentBundle(locale, { useIsolating: direction === "rtl" }); @@ -106,10 +117,13 @@ export class Localization { * @type (messageId: string, args?: Record) => string */ format(messageId, args = {}) { + const localizedArgs = Object.fromEntries(Object.entries(args).map(([name, value]) => [ + name, typeof value === "number" ? new MessageNumber(value) : 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); } } @@ -213,10 +227,7 @@ export class Localization { /** @type (multipleOf: number) => string */ getMultipleOfErrorMessage(multipleOf) { - return this.format("multipleOf-message", { - // Fluent's default three fractional digits would display small multiples as zero. - multipleOf: new FluentNumber(multipleOf, { maximumSignificantDigits: 21 }) - }); + return this.format("multipleOf-message", { multipleOf }); } /** @type (maxLength: number) => string */ diff --git a/src/localization.test.js b/src/localization.test.js index a922120..209177d 100644 --- a/src/localization.test.js +++ b/src/localization.test.js @@ -1,6 +1,7 @@ import { beforeAll, afterAll, describe, expect, test } from "vitest"; import { translations } from "./translations/index.js"; import { addTranslation, Localization } from "./localization.js"; +import { FluentNumber } from "@fluent/bundle"; describe("Localization", () => { const fixtureLocale = "fx-TR"; @@ -96,4 +97,50 @@ fx-keyword-negated-message = Fails {$value} expect(localization.or(["a", "b", "c"])).to.equal("a, b, or c"); expect(localization.and(["a", "b", "c"])).to.equal("a, b, and c"); }); + + describe("numeric formatting", () => { + beforeAll(() => { + for (const locale of ["en-US", "de-DE", "ar-EG"]) { + addTranslation(locale, ` +fx-number = {$value} +fx-number-explicit = {NUMBER($value, maximumFractionDigits: 2)} +fx-number-significant = {NUMBER($value, maximumSignificantDigits: 3)} +fx-number-select = {$value -> + [0] zero + [1] exactly one + [one] singular + *[other] plural +} +`); + } + }); + + test("retains significant digits across the finite number range", () => { + const localization = Localization.forLocale("en-US"); + for (const value of [0, -1e-7, 2.5e-8, 1.2345678901234567, 1.0000000000000002, Number.MIN_VALUE, Number.MAX_VALUE, Number.MAX_SAFE_INTEGER, 1000000000000000100]) { + const formatted = localization.format("fx-number", { value }); + expect.soft(Number(formatted.replaceAll(",", "")), formatted).to.equal(value); + } + }); + + test.each([ + { locale: "de-DE", small: "0,000000025", large: "1.234,56789" }, + { locale: "ar-EG", small: "٠٫٠٠٠٠٠٠٠٢٥", large: "١٬٢٣٤٫٥٦٧٨٩" } + ])("preserves $locale digits, separators, and grouping", ({ locale, small, large }) => { + const localization = Localization.forLocale(locale); + expect.soft(localization.format("fx-number", { value: 2.5e-8 })).to.equal(small); + expect.soft(localization.format("fx-number", { value: 1234.56789 })).to.equal(large); + }); + + test("preserves explicit Fluent formatting and numeric selectors", () => { + const localization = Localization.forLocale("de-DE"); + expect(localization.format("fx-number-explicit", { value: 1234.56789 })).to.equal("1.234,57"); + expect(localization.format("fx-number-significant", { value: 1234.56789 })).to.equal("1.230"); + const args = { value: new FluentNumber(1.2, { minimumFractionDigits: 2 }) }; + expect(localization.format("fx-number", args)).to.equal("1,20"); + for (const [value, expected] of [[0, "zero"], [1, "exactly one"], [1.0001, "singular"], [2, "plural"]]) { + expect(localization.format("fx-number-select", { value })).to.equal(expected); + } + }); + }); }); diff --git a/src/multipleOf.test.js b/src/multipleOf.test.js index 3a8ce84..d05850e 100644 --- a/src/multipleOf.test.js +++ b/src/multipleOf.test.js @@ -37,10 +37,7 @@ describe("multipleOf error aggregation", () => { { description: "reversed decimal mantissas", multiples: [1.8e-7, 1.2e-7], instance: 1e-7, expected: "0.00000036" }, { description: "different exponents", multiples: [1.2e-7, 1.8e-8], instance: 1e-8, expected: "0.00000036" }, { description: "mixed decimal and scientific notation", multiples: [0.0003, 2e-7], instance: 1e-7, expected: "0.0003" }, - { description: "equal constraints", multiples: [1.2e-7, 1.2e-7], instance: 1e-7, expected: "0.00000012" }, - { description: "single constraint", multiples: [1e-7], instance: 1.5e-7, expected: "0.0000001" }, { description: "three constraints", multiples: [1.2e-7, 1.8e-7, 2.7e-7], instance: 1e-7, expected: "0.00000108" }, - { description: "reversed three constraints", multiples: [2.7e-7, 1.8e-7, 1.2e-7], instance: 1e-7, expected: "0.00000108" }, { description: "subnormal scale", multiples: [2e-323, 3e-323], instance: 5e-324, expected: `0.${"0".repeat(322)}6` }, { description: "overflowing intermediate product", multiples: [2e200, 3e200], instance: 1, expected: `600${",000".repeat(66)}` }, { description: "widely separated exponents", multiples: [1e-308, 1e308], instance: 1e-309, expected: `100${",000".repeat(102)}` } @@ -103,11 +100,6 @@ describe("multipleOf error aggregation", () => { }))); }); - test("successful scientific notation constraints produce no errors", async () => { - registerSchema({ allOf: [{ multipleOf: 1e-7 }, { multipleOf: 2e-7 }] }, schemaUri, dialectUri); - expect(await jsonSchemaErrors({ valid: true }, schemaUri, 2e-7)).to.eql([]); - }); - test("preserves localized digits, separators, and grouping", async () => { addTranslation("de-DE", "multipleOf-message = Erwartet ein Vielfaches von {$multipleOf}"); registerSchema({ diff --git a/src/numeric-messages.test.js b/src/numeric-messages.test.js new file mode 100644 index 0000000..352312d --- /dev/null +++ b/src/numeric-messages.test.js @@ -0,0 +1,69 @@ +import { afterEach, describe, expect, test } from "vitest"; +import { registerSchema, unregisterSchema, validate } from "@hyperjump/json-schema/draft-2020-12"; +import "@hyperjump/json-schema/draft-04"; +import { BASIC } from "@hyperjump/json-schema/experimental"; +import { JSE, jsonSchemaErrors } from "./index.js"; + +describe.each([ + "https://json-schema.org/draft/2020-12/schema", + "http://json-schema.org/draft-04/schema" +])("numeric messages - %s", (dialect) => { + const schemaUri = "https://example.com/numeric-messages"; + + afterEach(() => { + unregisterSchema(schemaUri); + }); + + test.each([ + { keyword: "minimum", value: 1e-7, formatted: "0.0000001", comparison: "greater than or equal to", negated: "less than", instance: 0 }, + { keyword: "maximum", value: 2.5e-8, formatted: "0.000000025", comparison: "less than or equal to", negated: "greater than", instance: 1 }, + { keyword: "exclusiveMinimum", value: 2.5e-8, formatted: "0.000000025", comparison: "greater than", negated: "less than or equal to", instance: 2.5e-8 }, + { keyword: "exclusiveMaximum", value: 1e-7, formatted: "0.0000001", comparison: "less than", negated: "greater than or equal to", instance: 1e-7 }, + { keyword: "multipleOf", value: 2.5e-8, formatted: "0.000000025", comparison: "a multiple of", negated: "that is not a multiple of", instance: 1e-8 } + ])("$keyword preserves error, success, and negated messages", async ({ keyword, value, formatted, comparison, negated, instance }) => { + const isDraft04Exclusive = dialect.includes("draft-04") && keyword.startsWith("exclusive"); + const bound = keyword === "exclusiveMinimum" ? "minimum" : "maximum"; + const constraint = isDraft04Exclusive ? { [bound]: value, [keyword]: true } : { [keyword]: value }; + const keywords = isDraft04Exclusive ? [bound, keyword] : [keyword]; + + for (const mode of ["error", "success", "negated"]) { + const schema = mode === "error" ? constraint : mode === "success" ? { not: { not: constraint } } : { not: constraint }; + const prefix = mode === "error" ? "" : mode === "success" ? "/not/not" : "/not"; + const data = mode === "negated" ? null : instance; + registerSchema({ properties: { amount: schema } }, schemaUri, dialect); + + /** @type {Parameters[0]} */ + let output = await validate(schemaUri, { amount: data }, BASIC); + // The validator's tolerance accepts these tiny multiples. Supply standard + // output for their failures, as in #289, to test the formatter itself. + if (keyword === "multipleOf" && mode !== "negated") { + output = { + valid: false, + errors: [ + { instanceLocation: "#/amount", keywordLocation: `/properties/amount${prefix}/multipleOf` }, + ...(mode === "success" ? [{ instanceLocation: "#/amount", keywordLocation: "/properties/amount/not" }] : []) + ] + }; + } + expect(output.valid).to.equal(false); + + const errors = await jsonSchemaErrors(output, schemaUri, { amount: data }); + const messages = mode === "error" ? errors : errors[0].alternatives?.[0]; + const message = mode === "error" + ? `Expected a number ${keyword === "multipleOf" ? "that is " : ""}${comparison} ${formatted}` + : mode === "success" + ? `The value is either not a number or is ${comparison} ${formatted}` + : `The value is a number ${negated} ${formatted}`; + expect.soft(messages).to.eql([{ + message, + instanceLocation: "#/amount", + schemaLocations: keywords.map((name) => `${schemaUri}#/properties/amount${prefix}/${name}`) + }]); + if (keyword !== "multipleOf" || mode === "negated") { + const result = await validate(schemaUri, { amount: data }, JSE); + expect(result.valid ? [] : result.errors).to.eql(errors); + } + unregisterSchema(schemaUri); + } + }); +}); From c67ace0c84dec7d637073a9fbceef94a6b5cf130 Mon Sep 17 00:00:00 2001 From: Jason Desrosiers Date: Sun, 18 Oct 2026 14:50:56 -0700 Subject: [PATCH 3/3] refactor: simplify multipleOf LCM and numeric formatting Use number arithmetic for the multipleOf LCM, with decimal counting that understands exponent notation. When a scaled value or the LCM is outside the safe integer range, fall back to the individual errors. Skip the LCM when there is only one multipleOf. Format message numbers with a plain FluentNumber instead of a custom subclass. No translation uses NUMBER() or plural selection on these values. Move test coverage into the JSON test suite and remove the standalone test files, along with tests for exact results outside the safe integer range. Co-Authored-By: Claude Opus 5.5 --- src/error-handlers/multipleOf.js | 91 +++++++----------- src/localization.js | 15 +-- src/localization.test.js | 47 ---------- src/multipleOf.test.js | 134 --------------------------- src/numeric-messages.test.js | 69 -------------- src/test-suite/tests/maximum.json | 17 ++++ src/test-suite/tests/minimum.json | 17 ++++ src/test-suite/tests/multipleOf.json | 69 ++++---------- 8 files changed, 84 insertions(+), 375 deletions(-) delete mode 100644 src/multipleOf.test.js delete mode 100644 src/numeric-messages.test.js diff --git a/src/error-handlers/multipleOf.js b/src/error-handlers/multipleOf.js index 76488c2..6a0673d 100644 --- a/src/error-handlers/multipleOf.js +++ b/src/error-handlers/multipleOf.js @@ -30,22 +30,22 @@ const multipleOfErrorHandler = { schemaLocations.push(schemaLocation); } - if (errors.length > 0) { - const combinedMultipleOf = lcm(multiples); - // If the exact LCM cannot be represented as a number, describe the - // original failing constraints rather than a rounded or infinite multiple. - if (combinedMultipleOf === null) { - return errors; - } + if (errors.length === 0 || multiples.length === 1) { + return errors; + } - return [{ - message: context.localization.getMultipleOfErrorMessage(combinedMultipleOf), - instanceLocation: Instance.uri(instance), - schemaLocations - }]; + 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) => { @@ -55,61 +55,32 @@ const multipleOfErrorHandler = { } }; -/** - * @param {number} value - * @returns {{ coefficient: bigint, exponent: number }} - */ -const toDecimal = (value) => { +/** @type (value: number) => number */ +const countDecimals = (value) => { const [mantissa, exponent = "0"] = value.toString().split("e"); - const decimals = mantissa.split(".")[1]?.length ?? 0; - return { - coefficient: BigInt(mantissa.replace(".", "")), - exponent: Number(exponent) - decimals - }; + return Math.max((mantissa.split(".")[1]?.length ?? 0) - Number(exponent), 0); }; -/** - * @param {bigint} x - * @param {bigint} y - * @returns {bigint} - */ -const gcd = (x, y) => { - while (y !== 0n) { - const temp = y; - y = x % y; - x = temp; - } - - return x; -}; +/** @type (a: number, b: number) => number */ +const gcd = (a, b) => b === 0 ? Math.abs(a) : gcd(b, a % b); -/** - * @param {number[]} multiples - * @returns {number | null} - */ +/** @type (multiples: number[]) => number | null */ const lcm = (multiples) => { - // Use the decimal representations of the schema values. Keep a common scale - // and exact integers throughout, including when combining three or more values. - const decimals = multiples.map(toDecimal); - const exponent = decimals.reduce((min, decimal) => Math.min(min, decimal.exponent), Infinity); - let combined = 1n; - for (const decimal of decimals) { - const integer = decimal.coefficient * 10n ** BigInt(decimal.exponent - exponent); - combined = (combined / gcd(combined, integer)) * integer; - } + 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; + } - const result = Number(`${combined}e${exponent}`); - if (!Number.isFinite(result)) { - return null; + combined = Math.abs(combined / gcd(combined, integer) * integer); + if (!Number.isSafeInteger(combined)) { + return null; + } } - // A finite result can still have lost significant digits during conversion. - const rounded = toDecimal(result); - const commonExponent = Math.min(exponent, rounded.exponent); - return combined * 10n ** BigInt(exponent - commonExponent) - === rounded.coefficient * 10n ** BigInt(rounded.exponent - commonExponent) - ? result - : null; + return combined / m; }; export default multipleOfErrorHandler; diff --git a/src/localization.js b/src/localization.js index 5ee1e67..9dc17ca 100644 --- a/src/localization.js +++ b/src/localization.js @@ -14,17 +14,6 @@ const bundles = new Map(); /** @type Map */ const localizationCache = new Map(); -// Change implicit number rendering without changing Fluent's plural selection -// or the formatting chosen explicitly by NUMBER() in a translation. -class MessageNumber extends FluentNumber { - /** @param {Parameters[0]} [scope] */ - toString(scope) { - // Significant digits cover the full Number range, including subnormals, - // without Fluent's default rounding to three fractional digits. - return new FluentNumber(this.valueOf(), { maximumSignificantDigits: 21 }).toString(scope); - } -} - /** @type (locale: string, direction: "ltr" | "rtl") => FluentBundle */ const createBundle = (locale, direction) => { return new FluentBundle(locale, { useIsolating: direction === "rtl" }); @@ -117,8 +106,10 @@ 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 MessageNumber(value) : 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); diff --git a/src/localization.test.js b/src/localization.test.js index 209177d..a922120 100644 --- a/src/localization.test.js +++ b/src/localization.test.js @@ -1,7 +1,6 @@ import { beforeAll, afterAll, describe, expect, test } from "vitest"; import { translations } from "./translations/index.js"; import { addTranslation, Localization } from "./localization.js"; -import { FluentNumber } from "@fluent/bundle"; describe("Localization", () => { const fixtureLocale = "fx-TR"; @@ -97,50 +96,4 @@ fx-keyword-negated-message = Fails {$value} expect(localization.or(["a", "b", "c"])).to.equal("a, b, or c"); expect(localization.and(["a", "b", "c"])).to.equal("a, b, and c"); }); - - describe("numeric formatting", () => { - beforeAll(() => { - for (const locale of ["en-US", "de-DE", "ar-EG"]) { - addTranslation(locale, ` -fx-number = {$value} -fx-number-explicit = {NUMBER($value, maximumFractionDigits: 2)} -fx-number-significant = {NUMBER($value, maximumSignificantDigits: 3)} -fx-number-select = {$value -> - [0] zero - [1] exactly one - [one] singular - *[other] plural -} -`); - } - }); - - test("retains significant digits across the finite number range", () => { - const localization = Localization.forLocale("en-US"); - for (const value of [0, -1e-7, 2.5e-8, 1.2345678901234567, 1.0000000000000002, Number.MIN_VALUE, Number.MAX_VALUE, Number.MAX_SAFE_INTEGER, 1000000000000000100]) { - const formatted = localization.format("fx-number", { value }); - expect.soft(Number(formatted.replaceAll(",", "")), formatted).to.equal(value); - } - }); - - test.each([ - { locale: "de-DE", small: "0,000000025", large: "1.234,56789" }, - { locale: "ar-EG", small: "٠٫٠٠٠٠٠٠٠٢٥", large: "١٬٢٣٤٫٥٦٧٨٩" } - ])("preserves $locale digits, separators, and grouping", ({ locale, small, large }) => { - const localization = Localization.forLocale(locale); - expect.soft(localization.format("fx-number", { value: 2.5e-8 })).to.equal(small); - expect.soft(localization.format("fx-number", { value: 1234.56789 })).to.equal(large); - }); - - test("preserves explicit Fluent formatting and numeric selectors", () => { - const localization = Localization.forLocale("de-DE"); - expect(localization.format("fx-number-explicit", { value: 1234.56789 })).to.equal("1.234,57"); - expect(localization.format("fx-number-significant", { value: 1234.56789 })).to.equal("1.230"); - const args = { value: new FluentNumber(1.2, { minimumFractionDigits: 2 }) }; - expect(localization.format("fx-number", args)).to.equal("1,20"); - for (const [value, expected] of [[0, "zero"], [1, "exactly one"], [1.0001, "singular"], [2, "plural"]]) { - expect(localization.format("fx-number-select", { value })).to.equal(expected); - } - }); - }); }); diff --git a/src/multipleOf.test.js b/src/multipleOf.test.js deleted file mode 100644 index d05850e..0000000 --- a/src/multipleOf.test.js +++ /dev/null @@ -1,134 +0,0 @@ -import { afterEach, describe, expect, test } from "vitest"; -import { registerSchema, unregisterSchema } from "@hyperjump/json-schema/draft-2020-12"; -import { addTranslation, jsonSchemaErrors } from "./index.js"; - -describe("multipleOf error aggregation", () => { - const schemaUri = "https://example.com/multipleOf"; - const dialectUri = "https://json-schema.org/draft/2020-12/schema"; - - afterEach(() => { - unregisterSchema(schemaUri); - }); - - // Hyperjump accepts the instance in #289. Simulate standard validation output - // to exercise the formatter independently of the validator's arithmetic. - test("combines scientific notation constraints (#289)", async () => { - registerSchema({ - allOf: [{ multipleOf: 1e-7 }, { multipleOf: 2e-7 }] - }, schemaUri, dialectUri); - - const output = { - valid: false, - errors: [ - { instanceLocation: "#", keywordLocation: "/allOf/0/multipleOf" }, - { instanceLocation: "#", keywordLocation: "/allOf/1/multipleOf" } - ] - }; - - expect(await jsonSchemaErrors(output, schemaUri, 1.5e-7)).to.eql([{ - message: "Expected a number that is a multiple of 0.0000002", - instanceLocation: "#", - schemaLocations: [`${schemaUri}#/allOf/0/multipleOf`, `${schemaUri}#/allOf/1/multipleOf`] - }]); - }); - - test.each([ - { description: "decimal mantissas", multiples: [1.2e-7, 1.8e-7], instance: 1e-7, expected: "0.00000036" }, - { description: "reversed decimal mantissas", multiples: [1.8e-7, 1.2e-7], instance: 1e-7, expected: "0.00000036" }, - { description: "different exponents", multiples: [1.2e-7, 1.8e-8], instance: 1e-8, expected: "0.00000036" }, - { description: "mixed decimal and scientific notation", multiples: [0.0003, 2e-7], instance: 1e-7, expected: "0.0003" }, - { description: "three constraints", multiples: [1.2e-7, 1.8e-7, 2.7e-7], instance: 1e-7, expected: "0.00000108" }, - { description: "subnormal scale", multiples: [2e-323, 3e-323], instance: 5e-324, expected: `0.${"0".repeat(322)}6` }, - { description: "overflowing intermediate product", multiples: [2e200, 3e200], instance: 1, expected: `600${",000".repeat(66)}` }, - { description: "widely separated exponents", multiples: [1e-308, 1e308], instance: 1e-309, expected: `100${",000".repeat(102)}` } - ])("combines $description", async ({ multiples, instance, expected }) => { - registerSchema({ allOf: multiples.map((multipleOf) => ({ multipleOf })) }, schemaUri, dialectUri); - const locations = multiples.map((_, index) => `/allOf/${index}/multipleOf`); - const output = { - valid: false, - errors: locations.map((keywordLocation) => ({ instanceLocation: "#", keywordLocation })) - }; - - expect(await jsonSchemaErrors(output, schemaUri, instance)).to.eql([{ - message: `Expected a number that is a multiple of ${expected}`, - instanceLocation: "#", - schemaLocations: locations.map((location) => `${schemaUri}#${location}`) - }]); - }); - - test.each([ - { multiples: [1e-7, 2e-7], failedIndex: 1 }, - { multiples: [2e-7, 1e-7], failedIndex: 0 } - ])("includes a satisfied constraint in $multiples", async ({ multiples, failedIndex }) => { - registerSchema({ - properties: { - amount: { allOf: [{ allOf: multiples.map((multipleOf) => ({ multipleOf })) }] } - } - }, schemaUri, dialectUri); - const output = { - valid: false, - errors: [{ - instanceLocation: "#/amount", - keywordLocation: `/properties/amount/allOf/0/allOf/${failedIndex}/multipleOf` - }] - }; - - expect(await jsonSchemaErrors(output, schemaUri, { amount: 1e-7 })).to.eql([{ - message: "Expected a number that is a multiple of 0.0000002", - instanceLocation: "#/amount", - schemaLocations: [ - `${schemaUri}#/properties/amount/allOf/0/allOf/0/multipleOf`, - `${schemaUri}#/properties/amount/allOf/0/allOf/1/multipleOf` - ] - }]); - }); - - test.each([ - { description: "overflow", multiples: [1e308, 3e307], instance: 1, failed: [0, 1] }, - { description: "overflow with a satisfied constraint", multiples: [1e308, 3e307], instance: 1e308, failed: [1] } - ])("reports original failing constraints for $description", async ({ multiples, instance, failed }) => { - registerSchema({ allOf: multiples.map((multipleOf) => ({ multipleOf })) }, schemaUri, dialectUri); - const output = { - valid: false, - errors: failed.map((index) => ({ instanceLocation: "#", keywordLocation: `/allOf/${index}/multipleOf` })) - }; - - expect(await jsonSchemaErrors(output, schemaUri, instance)).to.eql(failed.map((index) => ({ - message: `Expected a number that is a multiple of ${new Intl.NumberFormat("en-US").format(multiples[index])}`, - instanceLocation: "#", - schemaLocations: [`${schemaUri}#/allOf/${index}/multipleOf`] - }))); - }); - - test("preserves localized digits, separators, and grouping", async () => { - addTranslation("de-DE", "multipleOf-message = Erwartet ein Vielfaches von {$multipleOf}"); - registerSchema({ - properties: { - small: { allOf: [{ multipleOf: 1e-7 }, { multipleOf: 2e-7 }] }, - large: { allOf: [{ multipleOf: 1000 }, { multipleOf: 1500 }] } - } - }, schemaUri, dialectUri); - const output = { - valid: false, - errors: [ - { instanceLocation: "#/small", keywordLocation: "/properties/small/allOf/0/multipleOf" }, - { instanceLocation: "#/small", keywordLocation: "/properties/small/allOf/1/multipleOf" }, - { instanceLocation: "#/large", keywordLocation: "/properties/large/allOf/0/multipleOf" }, - { instanceLocation: "#/large", keywordLocation: "/properties/large/allOf/1/multipleOf" } - ] - }; - - expect(await jsonSchemaErrors(output, schemaUri, { small: 1.5e-7, large: 1 }, { locale: "de-DE" })).to.eql([ - { - message: "Erwartet ein Vielfaches von 0,0000002", - instanceLocation: "#/small", - schemaLocations: [`${schemaUri}#/properties/small/allOf/0/multipleOf`, `${schemaUri}#/properties/small/allOf/1/multipleOf`] - }, - { - message: "Erwartet ein Vielfaches von 3.000", - instanceLocation: "#/large", - schemaLocations: [`${schemaUri}#/properties/large/allOf/0/multipleOf`, `${schemaUri}#/properties/large/allOf/1/multipleOf`] - } - ]); - }); -}); diff --git a/src/numeric-messages.test.js b/src/numeric-messages.test.js deleted file mode 100644 index 352312d..0000000 --- a/src/numeric-messages.test.js +++ /dev/null @@ -1,69 +0,0 @@ -import { afterEach, describe, expect, test } from "vitest"; -import { registerSchema, unregisterSchema, validate } from "@hyperjump/json-schema/draft-2020-12"; -import "@hyperjump/json-schema/draft-04"; -import { BASIC } from "@hyperjump/json-schema/experimental"; -import { JSE, jsonSchemaErrors } from "./index.js"; - -describe.each([ - "https://json-schema.org/draft/2020-12/schema", - "http://json-schema.org/draft-04/schema" -])("numeric messages - %s", (dialect) => { - const schemaUri = "https://example.com/numeric-messages"; - - afterEach(() => { - unregisterSchema(schemaUri); - }); - - test.each([ - { keyword: "minimum", value: 1e-7, formatted: "0.0000001", comparison: "greater than or equal to", negated: "less than", instance: 0 }, - { keyword: "maximum", value: 2.5e-8, formatted: "0.000000025", comparison: "less than or equal to", negated: "greater than", instance: 1 }, - { keyword: "exclusiveMinimum", value: 2.5e-8, formatted: "0.000000025", comparison: "greater than", negated: "less than or equal to", instance: 2.5e-8 }, - { keyword: "exclusiveMaximum", value: 1e-7, formatted: "0.0000001", comparison: "less than", negated: "greater than or equal to", instance: 1e-7 }, - { keyword: "multipleOf", value: 2.5e-8, formatted: "0.000000025", comparison: "a multiple of", negated: "that is not a multiple of", instance: 1e-8 } - ])("$keyword preserves error, success, and negated messages", async ({ keyword, value, formatted, comparison, negated, instance }) => { - const isDraft04Exclusive = dialect.includes("draft-04") && keyword.startsWith("exclusive"); - const bound = keyword === "exclusiveMinimum" ? "minimum" : "maximum"; - const constraint = isDraft04Exclusive ? { [bound]: value, [keyword]: true } : { [keyword]: value }; - const keywords = isDraft04Exclusive ? [bound, keyword] : [keyword]; - - for (const mode of ["error", "success", "negated"]) { - const schema = mode === "error" ? constraint : mode === "success" ? { not: { not: constraint } } : { not: constraint }; - const prefix = mode === "error" ? "" : mode === "success" ? "/not/not" : "/not"; - const data = mode === "negated" ? null : instance; - registerSchema({ properties: { amount: schema } }, schemaUri, dialect); - - /** @type {Parameters[0]} */ - let output = await validate(schemaUri, { amount: data }, BASIC); - // The validator's tolerance accepts these tiny multiples. Supply standard - // output for their failures, as in #289, to test the formatter itself. - if (keyword === "multipleOf" && mode !== "negated") { - output = { - valid: false, - errors: [ - { instanceLocation: "#/amount", keywordLocation: `/properties/amount${prefix}/multipleOf` }, - ...(mode === "success" ? [{ instanceLocation: "#/amount", keywordLocation: "/properties/amount/not" }] : []) - ] - }; - } - expect(output.valid).to.equal(false); - - const errors = await jsonSchemaErrors(output, schemaUri, { amount: data }); - const messages = mode === "error" ? errors : errors[0].alternatives?.[0]; - const message = mode === "error" - ? `Expected a number ${keyword === "multipleOf" ? "that is " : ""}${comparison} ${formatted}` - : mode === "success" - ? `The value is either not a number or is ${comparison} ${formatted}` - : `The value is a number ${negated} ${formatted}`; - expect.soft(messages).to.eql([{ - message, - instanceLocation: "#/amount", - schemaLocations: keywords.map((name) => `${schemaUri}#/properties/amount${prefix}/${name}`) - }]); - if (keyword !== "multipleOf" || mode === "negated") { - const result = await validate(schemaUri, { amount: data }, JSE); - expect(result.valid ? [] : result.errors).to.eql(errors); - } - unregisterSchema(schemaUri); - } - }); -}); 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 dbe5615..a850789 100644 --- a/src/test-suite/tests/multipleOf.json +++ b/src/test-suite/tests/multipleOf.json @@ -193,82 +193,45 @@ ] }, { - "description": "multipleOf constraints with positive exponents and decimal mantissas", + "description": "multipleOf constraints pass together", "schema": { "allOf": [ - { "multipleOf": 1.2e+21 }, - { "multipleOf": 1.8e+21 } + { "multipleOf": 2 }, + { "multipleOf": 3 } ] }, - "instance": 1, - "errors": [ - { - "messageId": "multipleOf-message", - "messageParams": { "multipleOf": "3,600,000,000,000,000,000,000" }, - "instanceLocation": "#", - "schemaLocations": ["#/allOf/0/multipleOf", "#/allOf/1/multipleOf"] - } - ] + "instance": 6, + "errors": [] }, { - "description": "multipleOf constraints avoid unsafe integer scaling", + "description": "decimal multipleOf constraints pass together", "schema": { "allOf": [ - { "multipleOf": 9007199254740991 }, - { "multipleOf": 0.1 } + { "multipleOf": 0.2 }, + { "multipleOf": 0.3 } ] }, - "instance": 0.5, - "errors": [ - { - "messageId": "multipleOf-message", - "messageParams": { "multipleOf": "9,007,199,254,740,991" }, - "instanceLocation": "#", - "schemaLocations": ["#/allOf/0/multipleOf", "#/allOf/1/multipleOf"] - } - ] + "instance": 0.6, + "errors": [] }, { - "description": "multipleOf constraints avoid rounding an intermediate LCM", + "description": "scientific notation multipleOf constraints", "schema": { "allOf": [ - { "multipleOf": 9007199254740991 }, - { "multipleOf": 3 }, - { "multipleOf": 10 } + { "multipleOf": 4e-7 }, + { "multipleOf": 6e-7 } ] }, - "instance": 1, + "instance": 1e-6, "errors": [ { "messageId": "multipleOf-message", - "messageParams": { "multipleOf": "270,215,977,642,229,730" }, + "messageParams": { "multipleOf": "0.0000012" }, "instanceLocation": "#", - "schemaLocations": ["#/allOf/0/multipleOf", "#/allOf/1/multipleOf", "#/allOf/2/multipleOf"] + "schemaLocations": ["#/allOf/0/multipleOf", "#/allOf/1/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 pass together", "schema": {