diff --git a/harness/testIntl.js b/harness/testIntl.js index ee4d43d98b0..96f74e532d9 100644 --- a/harness/testIntl.js +++ b/harness/testIntl.js @@ -16,6 +16,7 @@ defines: - getLocaleSupportInfo - getInvalidLanguageTags - isCanonicalizedStructurallyValidLanguageTag + - getLocaleBaseName - getInvalidLocaleArguments - testOption - testForUnwantedRegExpChanges @@ -1813,6 +1814,28 @@ function isCanonicalizedStructurallyValidLanguageTag(locale) { } +/** + * Returns the given language tag without any extension or private use + * subtags, like Intl.Locale.prototype.baseName. Use this before appending a + * Unicode extension sequence to a locale obtained from resolvedOptions(): + * some hosts report a default locale that already carries one, such as + * "en-US-u-va-posix" under the POSIX locale, and a language tag with two "-u-" + * singletons is not structurally valid. + * @param {string} locale a canonicalized language tag + * @return {string} the language, script, region and variant subtags of locale + */ +function getLocaleBaseName(locale) { + var subtags = locale.split("-"); + // Every subtag after the language is at least two characters long, except + // for the singletons that introduce extension and private use sequences. + for (var i = 1; i < subtags.length; i++) { + if (subtags[i].length === 1) { + return subtags.slice(0, i).join("-"); + } + } + return locale; +} + /** * Returns an array of error cases handled by CanonicalizeLocaleList(). */ @@ -2387,7 +2410,7 @@ function testNumberFormat(locales, numberingSystems, options, testData) { locales.forEach(function (locale) { numberingSystems.forEach(function (numbering) { var digits = numberingSystemDigits[numbering]; - var format = new Intl.NumberFormat([locale + "-u-nu-" + numbering], options); + var format = new Intl.NumberFormat([getLocaleBaseName(locale) + "-u-nu-" + numbering], options); function getPatternParts(positive) { var n = positive ? 1.1 : -1.1; diff --git a/test/intl402/Collator/ignore-invalid-unicode-ext-values.js b/test/intl402/Collator/ignore-invalid-unicode-ext-values.js index e6c88ed3add..ae0ea9cf275 100644 --- a/test/intl402/Collator/ignore-invalid-unicode-ext-values.js +++ b/test/intl402/Collator/ignore-invalid-unicode-ext-values.js @@ -7,7 +7,7 @@ description: > Tests that Intl.Collator does not accept Unicode locale extension keys and values that are not allowed. author: Norbert Lindenberg -includes: [compareArray.js] +includes: [compareArray.js, testIntl.js] ---*/ var testArray = [ @@ -17,10 +17,10 @@ var testArray = [ "Å", "Å", "A\u030A" ]; -var defaultCollator = new Intl.Collator(); +var defaultLocale = getLocaleBaseName(new Intl.Collator().resolvedOptions().locale); +var defaultCollator = new Intl.Collator([defaultLocale]); var defaultOptions = defaultCollator.resolvedOptions(); var defaultOptionsJSON = JSON.stringify(defaultOptions); -var defaultLocale = defaultOptions.locale; var defaultSortedArray = testArray.slice(0).sort(defaultCollator.compare); var keyValues = { diff --git a/test/intl402/Collator/numeric-and-caseFirst.js b/test/intl402/Collator/numeric-and-caseFirst.js index 48719f85b06..4e8cff2c318 100644 --- a/test/intl402/Collator/numeric-and-caseFirst.js +++ b/test/intl402/Collator/numeric-and-caseFirst.js @@ -7,6 +7,7 @@ description: > Tests that the options numeric and caseFirst can be set through either the locale or the options. author: Norbert Lindenberg +includes: [testIntl.js] ---*/ var options = [ @@ -15,7 +16,7 @@ var options = [ ]; options.forEach(function (option) { - var defaultLocale = new Intl.Collator().resolvedOptions().locale; + var defaultLocale = getLocaleBaseName(new Intl.Collator().resolvedOptions().locale); var collator, opt, result; // find out which values are supported for a property in the default locale diff --git a/test/intl402/DateTimeFormat/casing-numbering-system-calendar-options.js b/test/intl402/DateTimeFormat/casing-numbering-system-calendar-options.js index a5a026a3a35..1f42eb28f71 100644 --- a/test/intl402/DateTimeFormat/casing-numbering-system-calendar-options.js +++ b/test/intl402/DateTimeFormat/casing-numbering-system-calendar-options.js @@ -8,9 +8,10 @@ description: > to lower case properly. author: Caio Lima features: [Array.prototype.includes] +includes: [testIntl.js] ---*/ -let defaultLocale = new Intl.DateTimeFormat().resolvedOptions().locale; +let defaultLocale = getLocaleBaseName(new Intl.DateTimeFormat().resolvedOptions().locale); let supportedNumberingSystems = ["latn", "arab"].filter(nu => new Intl.DateTimeFormat(defaultLocale + "-u-nu-" + nu) diff --git a/test/intl402/DateTimeFormat/numbering-system-calendar-options.js b/test/intl402/DateTimeFormat/numbering-system-calendar-options.js index a308f30de22..948ccb3208a 100644 --- a/test/intl402/DateTimeFormat/numbering-system-calendar-options.js +++ b/test/intl402/DateTimeFormat/numbering-system-calendar-options.js @@ -7,9 +7,10 @@ description: > Tests that the options numberingSystem and calendar can be set through either the locale or the options. author: Norbert Lindenberg, Daniel Ehrenberg +includes: [testIntl.js] ---*/ -let defaultLocale = new Intl.DateTimeFormat().resolvedOptions().locale; +let defaultLocale = getLocaleBaseName(new Intl.DateTimeFormat().resolvedOptions().locale); let supportedNumberingSystems = ["latn", "arab"].filter(nu => new Intl.DateTimeFormat(defaultLocale + "-u-nu-" + nu) diff --git a/test/intl402/DateTimeFormat/prototype/resolvedOptions/resolved-locale-with-hc-unicode.js b/test/intl402/DateTimeFormat/prototype/resolvedOptions/resolved-locale-with-hc-unicode.js index 2bfbb0f49c1..1d7ba8ecd23 100644 --- a/test/intl402/DateTimeFormat/prototype/resolvedOptions/resolved-locale-with-hc-unicode.js +++ b/test/intl402/DateTimeFormat/prototype/resolvedOptions/resolved-locale-with-hc-unicode.js @@ -27,9 +27,10 @@ info: | a. Let value be optionsValue. b. Let supportedExtensionAddition be "". ... +includes: [testIntl.js] ---*/ -var defaultLocale = new Intl.DateTimeFormat().resolvedOptions().locale; +var defaultLocale = getLocaleBaseName(new Intl.DateTimeFormat().resolvedOptions().locale); var defaultLocaleWithHourCycle = defaultLocale + "-u-hc-h11"; function assertLocale(locale, expectedLocale, options, message) { diff --git a/test/intl402/NumberFormat/casing-numbering-system-options.js b/test/intl402/NumberFormat/casing-numbering-system-options.js index 92bb7ecc817..f0ea6c5edbf 100644 --- a/test/intl402/NumberFormat/casing-numbering-system-options.js +++ b/test/intl402/NumberFormat/casing-numbering-system-options.js @@ -7,9 +7,10 @@ description: > Tests that the options numberingSystem are mapped to lower case. author: Caio Lima features: [Array.prototype.includes] +includes: [testIntl.js] ---*/ -let defaultLocale = new Intl.NumberFormat().resolvedOptions().locale; +let defaultLocale = getLocaleBaseName(new Intl.NumberFormat().resolvedOptions().locale); let supportedNumberingSystems = ["latn", "arab"].filter(nu => new Intl.NumberFormat(defaultLocale + "-u-nu-" + nu) diff --git a/test/intl402/NumberFormat/numbering-system-options.js b/test/intl402/NumberFormat/numbering-system-options.js index 04a768991ec..863c8ed055e 100644 --- a/test/intl402/NumberFormat/numbering-system-options.js +++ b/test/intl402/NumberFormat/numbering-system-options.js @@ -7,9 +7,10 @@ description: > Tests that the options numberingSystem and calendar can be set through either the locale or the options. author: Norbert Lindenberg, Daniel Ehrenberg +includes: [testIntl.js] ---*/ -let defaultLocale = new Intl.NumberFormat().resolvedOptions().locale; +let defaultLocale = getLocaleBaseName(new Intl.NumberFormat().resolvedOptions().locale); let supportedNumberingSystems = ["latn", "arab"].filter(nu => new Intl.NumberFormat(defaultLocale + "-u-nu-" + nu) diff --git a/test/intl402/NumberFormat/prototype/format/format-significant-digits-rounding-magnitude.js b/test/intl402/NumberFormat/prototype/format/format-significant-digits-rounding-magnitude.js new file mode 100644 index 00000000000..5b4369f37db --- /dev/null +++ b/test/intl402/NumberFormat/prototype/format/format-significant-digits-rounding-magnitude.js @@ -0,0 +1,145 @@ +// Copyright (C) 2026 Igalia, S.L. All rights reserved. +// This code is governed by the BSD license found in the LICENSE file. + +/*--- +esid: sec-torawprecision +description: > + Rounding to significant digits when the rounded value reaches the next order + of magnitude +info: | + ToRawPrecision ( x, minPrecision, maxPrecision, unsignedRoundingMode ) + + 1. Let p be maxPrecision. + ... + 3. Else, + a. Let n1 and e1 each be an integer and r1 a mathematical value, with + r1 = ToRawPrecisionFn(n1, e1, p), such that r1 ≤ x and r1 is maximized. + b. Let n2 and e2 each be an integer and r2 a mathematical value, with + r2 = ToRawPrecisionFn(n2, e2, p), such that r2 ≥ x and r2 is minimized. + c. Let xFinal be ApplyUnsignedRoundingMode(x, r1, r2, unsignedRoundingMode). + d. If xFinal is r1, then + i. Let n be n1. + ii. Let e be e1. + e. Else, + i. Let n be n2. + ii. Let e be e2. + f. Let m be the String consisting of the digits of the decimal + representation of n (in order, with no leading zeroes). + 4. If e ≥ (p - 1), then + a. Set m to the string-concatenation of m and e - p + 1 occurrences of the + code unit 0x0030 (DIGIT ZERO). + b. Let int be e + 1. + 5. Else if e ≥ 0, then + a. Set m to the string-concatenation of the first e + 1 code units of m, + the code unit 0x002E (FULL STOP), and the remaining p - (e + 1) code + units of m. + b. Let int be e + 1. + 6. Else, + a. Assert: e < 0. + b. Set m to the string-concatenation of "0.", -(e + 1) occurrences of the + code unit 0x0030 (DIGIT ZERO), and m. + c. Let int be 1. + 7. If m contains the code unit 0x002E (FULL STOP) and + maxPrecision > minPrecision, then + a. Let cut be maxPrecision - minPrecision. + b. Repeat, while cut > 0 and the last code unit of m is 0x0030 + (DIGIT ZERO), + i. Remove the last code unit from m. + ii. Set cut to cut - 1. + c. If the last code unit of m is 0x002E (FULL STOP), then + i. Remove the last code unit from m. +includes: [testIntl.js] +---*/ + +var locales = [ + new Intl.NumberFormat().resolvedOptions().locale, + "ar", "de", "th", "ja" +]; +var numberingSystems = ["latn", "arab", "thai", "hanidec"]; + +// minimumSignificantDigits = maximumSignificantDigits, so no trailing zeros +// are trimmed and the digit count of the result is visible directly. +testNumberFormat(locales, numberingSystems, + { useGrouping: false, minimumSignificantDigits: 3, maximumSignificantDigits: 3 }, + { + // step 6: e = -3 → e = -2 (still e < 0) + "0.0009999": "0.00100", + "-0.0009999": "-0.00100", + // step 6: e = -2 → e = -1 + "0.09999": "0.100", + "-0.09999": "-0.100", + // step 6 (e = -1) → step 5 (e = 0) + "0.9999": "1.00", + "-0.9999": "-1.00", + // step 5: e = 0 → e = 1 + "9.999": "10.0", + "-9.999": "-10.0", + // step 5 (e = 1) → step 4 (e = 2 = p - 1) + "99.99": "100", + "-99.99": "-100", + // step 4: e = 2 → e = 3 + "999.9": "1000", + "-999.9": "-1000", + // step 4: e = 5 → e = 6 + "999999.9": "1000000", + "-999999.9": "-1000000", + // control values that round without changing magnitude + "9.994": "9.99", + "99.94": "99.9", + "0.9994": "0.999", + }); + +// Default minimumSignificantDigits of 1: rollover still happens, and the +// zeros that appear after the FULL STOP are trimmed by step 7. +testNumberFormat(locales, numberingSystems, + { useGrouping: false, maximumSignificantDigits: 3 }, + { + "0.0009999": "0.001", + "0.09999": "0.1", + "0.9999": "1", + "-0.9999": "-1", + "9.999": "10", + "-9.999": "-10", + "99.99": "100", + "-99.99": "-100", + "999.9": "1000", + "999999.9": "1000000", + }); + +// Lower minimum: step 7 trims at most maxPrecision - minPrecision zeros. +testNumberFormat(locales, numberingSystems, + { useGrouping: false, minimumSignificantDigits: 2, maximumSignificantDigits: 3 }, + { + "0.9999": "1.0", + "9.999": "10", + "99.99": "100", + }); + +// Two significant digits +testNumberFormat(locales, numberingSystems, + { useGrouping: false, minimumSignificantDigits: 2, maximumSignificantDigits: 2 }, + { + "0.996": "1.0", + "9.96": "10", + "99.6": "100", + "996": "1000", + }); + +// Larger precision +testNumberFormat(locales, numberingSystems, + { useGrouping: false, minimumSignificantDigits: 5, maximumSignificantDigits: 5 }, + { + "9.99996": "10.000", + "99999.6": "100000", + "0.999996": "1.0000", + }); + +// The rounded value is always compared against x; a value just below the +// rounding boundary must not roll over. +testNumberFormat(locales, numberingSystems, + { useGrouping: false, minimumSignificantDigits: 3, maximumSignificantDigits: 3 }, + { + "9.9949": "9.99", + "99.949": "99.9", + "999.49": "999", + }); diff --git a/test/intl402/NumberFormat/prototype/formatToParts/significant-digits-rounding-magnitude-en-US.js b/test/intl402/NumberFormat/prototype/formatToParts/significant-digits-rounding-magnitude-en-US.js new file mode 100644 index 00000000000..f06a9582b6c --- /dev/null +++ b/test/intl402/NumberFormat/prototype/formatToParts/significant-digits-rounding-magnitude-en-US.js @@ -0,0 +1,113 @@ +// Copyright (C) 2026 Igalia, S.L. All rights reserved. +// This code is governed by the BSD license found in the LICENSE file. + +/*--- +esid: sec-torawprecision +description: > + Grouping and decimal parts reflect the magnitude of the rounded value when + rounding to significant digits carries into a new leading digit +info: | + ToRawPrecision ( x, minPrecision, maxPrecision, unsignedRoundingMode ) + + ... + 4. If e ≥ (p - 1), then + a. Set m to the string-concatenation of m and e - p + 1 occurrences of the + code unit 0x0030 (DIGIT ZERO). + b. Let int be e + 1. + 5. Else if e ≥ 0, then + a. Set m to the string-concatenation of the first e + 1 code units of m, + the code unit 0x002E (FULL STOP), and the remaining p - (e + 1) code + units of m. + b. Let int be e + 1. + ... + 8. Return the Record { [[FormattedString]]: m, [[RoundedNumber]]: xFinal, + [[IntegerDigitsCount]]: int, [[RoundingMagnitude]]: e–p+1 }. +locale: [en-US] +includes: [deepEqual.js] +---*/ + +const nf = new Intl.NumberFormat("en-US", { maximumSignificantDigits: 3 }); + +// 999.9 rounds to 1000: four integer digits, so a group separator appears. +assert.deepEqual(nf.formatToParts(999.9), [ + { type: "integer", value: "1" }, + { type: "group", value: "," }, + { type: "integer", value: "000" }, +], "999.9 with maximumSignificantDigits 3 is '1,000'"); + +assert.deepEqual(nf.formatToParts(-999.9), [ + { type: "minusSign", value: "-" }, + { type: "integer", value: "1" }, + { type: "group", value: "," }, + { type: "integer", value: "000" }, +], "-999.9 with maximumSignificantDigits 3 is '-1,000'"); + +// 999999.9 rounds to 1000000: three groups. +assert.deepEqual(nf.formatToParts(999999.9), [ + { type: "integer", value: "1" }, + { type: "group", value: "," }, + { type: "integer", value: "000" }, + { type: "group", value: "," }, + { type: "integer", value: "000" }, +], "999999.9 with maximumSignificantDigits 3 is '1,000,000'"); + +// 9.999 rounds to 10: two integer digits and, with the default minimum of one +// significant digit, no fraction. +assert.deepEqual(nf.formatToParts(9.999), [ + { type: "integer", value: "10" }, +], "9.999 with maximumSignificantDigits 3 is '10'"); + +// 0.9999 rounds to 1: the fraction disappears entirely. +assert.deepEqual(nf.formatToParts(0.9999), [ + { type: "integer", value: "1" }, +], "0.9999 with maximumSignificantDigits 3 is '1'"); + +const nfMin = new Intl.NumberFormat("en-US", { + minimumSignificantDigits: 3, + maximumSignificantDigits: 3, +}); + +// With a minimum of three significant digits the trailing zero is kept as a +// fraction digit after the carry. +assert.deepEqual(nfMin.formatToParts(9.999), [ + { type: "integer", value: "10" }, + { type: "decimal", value: "." }, + { type: "fraction", value: "0" }, +], "9.999 with minimumSignificantDigits 3 is '10.0'"); + +assert.deepEqual(nfMin.formatToParts(0.9999), [ + { type: "integer", value: "1" }, + { type: "decimal", value: "." }, + { type: "fraction", value: "00" }, +], "0.9999 with minimumSignificantDigits 3 is '1.00'"); + +assert.deepEqual(nfMin.formatToParts(99.99), [ + { type: "integer", value: "100" }, +], "99.99 with minimumSignificantDigits 3 is '100'"); + +const nfOne = new Intl.NumberFormat("en-US", { maximumSignificantDigits: 1 }); + +// A single significant digit: 9.6 rounds to 10, 96 to 100, 996 to 1,000. +assert.deepEqual(nfOne.formatToParts(0.96), [ + { type: "integer", value: "1" }, +], "0.96 with maximumSignificantDigits 1 is '1'"); + +assert.deepEqual(nfOne.formatToParts(9.6), [ + { type: "integer", value: "10" }, +], "9.6 with maximumSignificantDigits 1 is '10'"); + +assert.deepEqual(nfOne.formatToParts(96), [ + { type: "integer", value: "100" }, +], "96 with maximumSignificantDigits 1 is '100'"); + +assert.deepEqual(nfOne.formatToParts(996), [ + { type: "integer", value: "1" }, + { type: "group", value: "," }, + { type: "integer", value: "000" }, +], "996 with maximumSignificantDigits 1 is '1,000'"); + +assert.deepEqual(nfOne.formatToParts(0.096), [ + { type: "integer", value: "0" }, + { type: "decimal", value: "." }, + { type: "fraction", value: "1" }, +], "0.096 with maximumSignificantDigits 1 is '0.1'"); diff --git a/test/intl402/NumberFormat/test-option-currency.js b/test/intl402/NumberFormat/test-option-currency.js index 0988d0d1cc4..c8694eb6441 100644 --- a/test/intl402/NumberFormat/test-option-currency.js +++ b/test/intl402/NumberFormat/test-option-currency.js @@ -5,12 +5,13 @@ es5id: 11.1.1_17 description: Tests that the option currency is processed correctly. author: Norbert Lindenberg +includes: [testIntl.js] ---*/ var validValues = ["CNY", "USD", "EUR", "IDR", "jpy", {toString: function () {return "INR";}}]; var invalidValues = ["$", "SFr.", "US$", "ßP", {toString: function () {return;}}]; -var defaultLocale = new Intl.NumberFormat().resolvedOptions().locale; +var defaultLocale = getLocaleBaseName(new Intl.NumberFormat().resolvedOptions().locale); validValues.forEach(function (value) { var format, actual, expected; diff --git a/test/intl402/NumberFormat/throws-for-currency-style-without-currency-option.js b/test/intl402/NumberFormat/throws-for-currency-style-without-currency-option.js index 13a65c12a7c..967369f1013 100644 --- a/test/intl402/NumberFormat/throws-for-currency-style-without-currency-option.js +++ b/test/intl402/NumberFormat/throws-for-currency-style-without-currency-option.js @@ -7,9 +7,10 @@ description: > Tests that the currency style can not be used without a specified currency. author: Norbert Lindenberg +includes: [testIntl.js] ---*/ -var defaultLocale = new Intl.NumberFormat().resolvedOptions().locale; +var defaultLocale = getLocaleBaseName(new Intl.NumberFormat().resolvedOptions().locale); assert.throws(TypeError, function () { return new Intl.NumberFormat([defaultLocale], {style: "currency"});