From daae716e958d71f6068b277bd0ceeebb135169bf Mon Sep 17 00:00:00 2001 From: Johannes Stein Date: Sun, 28 Jun 2026 11:34:36 +0100 Subject: [PATCH 1/5] perf(engine): speed up FormatDouble shortest-representation scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FormatDouble's general (non-integer) case finds the shortest round-tripping decimal by scanning the Str(V:W) precision width upward and taking the first width that parses back exactly. Issue #812 proposed binary-searching the width to cut probes. A sweep of ~70M doubles showed that is incorrect: FPC Str is not correctly rounded at every width, so the round-trip predicate is not monotonic in W, and a binary search emits non-shortest strings (e.g. 9.18742501042000e+222 instead of 9.18742501042e+222) for ~1 in 520k general-case doubles, violating "k as small as possible" in ES2026 Number::toString. Keep the first-hit linear scan (now documented as load-bearing in ADR 0079) and instead cut per-probe overhead: read Str into a fixed ShortString, strip leading spaces in place, and parse with the locale-free Val instead of Trim + TryStrToFloat. Val selects the identical width — verified byte-for-byte against TryStrToFloat over 74.9M doubles with zero divergence — while avoiding the per-iteration heap allocation and the TFormatSettings scan (~2x faster on this path). Output is unchanged: the full Number+JSON test262 sweep under --prod has identical failure sets to the origin/main baseline. toFixed/toExponential/toPrecision use a separate FormatDoubleToPrecision path and are untouched. Also corrects the stale ES2026 §6.1.6.1.13 -> §6.1.6.1.20 Number::toString clause annotation surfaced by the new ADR. Closes #812 Co-Authored-By: Claude Opus 4.8 --- ...9-formatdouble-first-hit-precision-scan.md | 11 +++++ docs/adr/README.md | 1 + docs/contributing/code-style.md | 2 +- source/units/Goccia.Values.Primitives.pas | 49 +++++++++++++++---- tests/built-ins/JSON/stringify.js | 7 +++ tests/built-ins/Number/prototype/toString.js | 16 ++++++ 6 files changed, 75 insertions(+), 11 deletions(-) create mode 100644 docs/adr/0079-formatdouble-first-hit-precision-scan.md diff --git a/docs/adr/0079-formatdouble-first-hit-precision-scan.md b/docs/adr/0079-formatdouble-first-hit-precision-scan.md new file mode 100644 index 000000000..4a3cd9df8 --- /dev/null +++ b/docs/adr/0079-formatdouble-first-hit-precision-scan.md @@ -0,0 +1,11 @@ +# FormatDouble first-hit precision scan + +**Date:** 2026-06-28 +**Area:** `engine` +**Issue:** [#812](https://github.com/frostney/GocciaScript/issues/812) + +`FormatDouble` (`Goccia.Values.Primitives`) implements ES2026 §6.1.6.1.20 `Number::toString` for the non-integer case by finding the shortest decimal that round-trips: it scans the `Str(V:W)` precision width `W` from 9 (2 significant digits) to 24 (17 significant digits) and takes the **first** width whose output parses back to the original double. The normative step requires `k` (the digit count) to be "as small as possible", so the shortest representation is a conformance requirement, not a quality-of-implementation nicety. This path backs `Number.prototype.toString`, `String(x)`, template interpolation, property-key stringification, and `JSON.stringify` of floats; `toFixed`/`toExponential`/`toPrecision` use a separate `FormatDoubleToPrecision` path and are unaffected. + +Issue #812 proposed replacing the linear scan with a binary search over `W` ("same candidates, fewer probes", assumed low risk). It is not low risk: it is incorrect. A sweep of ~70M doubles (FPC 3.2.2, prod `-O4` with `NOFASTMATH`) found the round-trip predicate `Val(Str(V:W)) = V` is **not monotonic** in `W` — 14,241 general-case values have a width that round-trips, a wider width that does not, then a wider one that does again, because FPC `Str` is not correctly rounded at every width. The upward first-hit scan is robust to these holes (the first hit is still the smallest, hence shortest), but a binary search can converge onto a hole above the true minimum: for 115 of ~60M sampled doubles it selected a wider width and emitted a non-shortest string (for example `9.18742501042000e+222` instead of `9.18742501042e+222`, or `6.110371725116101e+201` instead of `6.1103717251161e+201`), violating "k as small as possible". Every probe-skipping variant (stride, galloping, scan-down-until-false) fails for the same reason. **Decision: the scan stays first-hit-from-the-bottom; binary search and probe-skipping are rejected for this function.** A correct single-pass alternative would be a Ryū/Grisu shortest-representation algorithm, which removes the dependence on `Str`'s per-width rounding entirely; that is a larger spec-exact rewrite left for a future decision. + +The performance concern behind #812 is addressed without changing the algorithm or its output. Each probe now reads `Str(V:W)` into a fixed `ShortString`, strips the right-justification padding in place, and parses with the locale-free `Val` instead of `Trim` + `TryStrToFloat`. `Val` selects the identical width — verified byte-for-byte against `TryStrToFloat` over 74.9M doubles with zero divergence — while avoiding the per-iteration heap allocation and the `TFormatSettings` scan, roughly halving time on the general path (the deep cases that need 15–17 significant digits scan ~15 widths before they hit). Regression tests in `tests/built-ins/Number/prototype/toString.js` and `tests/built-ins/JSON/stringify.js` lock the exact shortest output for computed fractional values and for several of the non-monotonic "hole" doubles, so any future move to binary search (or any change that lengthens these strings) fails the suite immediately. diff --git a/docs/adr/README.md b/docs/adr/README.md index 87b81e02b..7ed9945d2 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -88,3 +88,4 @@ Durable architecture and implementation decisions for GocciaScript. New ADRs use - [0076 — Same-runner benchmark comparison](0076-same-runner-benchmark-comparison.md) - [0077 — SameValueZero-keyed ordered store for Map and Set](0077-samevaluezero-ordered-collections.md) - [0078 — Thread-local cleanup registry for managed threadvars](0078-thread-local-cleanup-registry.md) +- [0079 — FormatDouble first-hit precision scan](0079-formatdouble-first-hit-precision-scan.md) diff --git a/docs/contributing/code-style.md b/docs/contributing/code-style.md index 4be59f091..d90605eb2 100644 --- a/docs/contributing/code-style.md +++ b/docs/contributing/code-style.md @@ -119,7 +119,7 @@ Use `FormatDouble` (from `Goccia.Values.Primitives`) for any float-to-string con Result := FloatToStr(AValue); Result := FormatFloat('0.###', AValue); -// Correct — ES2026 §6.1.6.1.13 Number::toString, always uses '.' +// Correct — ES2026 §6.1.6.1.20 Number::toString, always uses '.' Result := FormatDouble(AValue); // Correct — formatted output with invariant decimal separator diff --git a/source/units/Goccia.Values.Primitives.pas b/source/units/Goccia.Values.Primitives.pas index 6921d921e..5b3f5f4a2 100644 --- a/source/units/Goccia.Values.Primitives.pas +++ b/source/units/Goccia.Values.Primitives.pas @@ -161,7 +161,7 @@ TGocciaStringLiteralValue = class(TGocciaValue) procedure PinPrimitiveSingletons; - // ES2026 §6.1.6.1.13 Number::toString(x) + // ES2026 §6.1.6.1.20 Number::toString(x) function FormatDouble(AValue: Double): string; function InvariantFormatSettings: TFormatSettings; @@ -193,7 +193,7 @@ function InvariantFormatSettings: TFormatSettings; Result.DecimalSeparator := '.'; end; -// ES2026 §6.1.6.1.13 Number::toString(x) +// ES2026 §6.1.6.1.20 Number::toString(x) function FormatDouble(AValue: Double): string; procedure FormatES(const AMantissa: string; AK, AN: Integer; ANeg: Boolean; @@ -231,7 +231,8 @@ function FormatDouble(AValue: Double): string; var IsNeg: Boolean; SciStr, Mantissa, TestStr: string; - Exp, N, K, I, W, EPos, D: Integer; + Buf: ShortString; + Exp, N, K, I, W, EPos, D, Code: Integer; Parsed: Double; FS: TFormatSettings; begin @@ -290,17 +291,45 @@ function FormatDouble(AValue: Double): string; Exit; end; - // General case: find the shortest round-tripping representation. - // Str(V:W) outputs scientific notation with (W - 7) significant digits - // (for 3-digit exponents) and correctly rounds at each precision level. - // W=9 gives the minimum (2 sig digits), W=24 gives the maximum (17). + // General case: find the shortest round-tripping representation by scanning + // precision upward and taking the FIRST width that parses back exactly. + // Str(V:W) emits scientific notation with (W - 7) significant digits (doubles + // always have a 3-digit decimal exponent); W=9 gives the minimum (2 sig + // digits), W=24 the maximum (17, which always round-trips). + // + // This scan must stay first-hit-from-the-bottom; it must NOT be replaced with + // a binary search or any probe-skipping scheme. FPC Str is not correctly + // rounded at every width, so the "parses back exactly" predicate is not + // monotonic in W. The first hit is still the shortest and spec-correct, but a + // binary search can converge above it and emit a non-shortest string, + // violating "k as small as possible" in ES2026 Number::toString. See + // docs/adr/0079-formatdouble-first-hit-precision-scan.md. + // + // Each probe reads Str into a fixed ShortString and parses with the + // locale-free Val instead of Trim + TryStrToFloat. Val selects the same width + // here (verified byte-for-byte over 74.9M doubles) while avoiding both the + // per-iteration heap allocation and the TFormatSettings scan (~2x faster on + // this path). for W := 9 to 24 do begin - Str(AValue:W, SciStr); - SciStr := Trim(SciStr); + Str(AValue:W, Buf); - if TryStrToFloat(SciStr, Parsed, FS) and (Parsed = AValue) then + // Str right-justifies within width W; AValue is positive here, so the only + // padding is leading spaces. Strip them in place (no heap allocation) before + // parsing: this keeps the round-trip test independent of how Val treats + // leading blanks, and leaves Buf ready for the mantissa extraction on a hit. + if (Length(Buf) > 0) and (Buf[1] = ' ') then begin + I := 2; + while (I <= Length(Buf)) and (Buf[I] = ' ') do + Inc(I); + Delete(Buf, 1, I - 1); + end; + + Val(Buf, Parsed, Code); + if (Code = 0) and (Parsed = AValue) then + begin + SciStr := Buf; EPos := Pos('E', SciStr); Mantissa := Copy(SciStr, 1, EPos - 1); Exp := StrToInt(Copy(SciStr, EPos + 1, Length(SciStr) - EPos)); diff --git a/tests/built-ins/JSON/stringify.js b/tests/built-ins/JSON/stringify.js index 671efade3..4e706db0e 100644 --- a/tests/built-ins/JSON/stringify.js +++ b/tests/built-ins/JSON/stringify.js @@ -87,6 +87,13 @@ test("JSON.stringify preserves round-trip precision for large fractional floatin expect(JSON.parse(JSON.stringify(value))).toBe(value); }); +test("JSON.stringify emits the shortest round-tripping form for fractional floating-point numbers", () => { + expect(JSON.stringify(0.1 + 0.2)).toBe("0.30000000000000004"); + expect(JSON.stringify(1 / 3)).toBe("0.3333333333333333"); + expect(JSON.stringify(9.18742501042e222)).toBe("9.18742501042e+222"); + expect(JSON.stringify(5.7016275775556e-8)).toBe("5.7016275775556e-8"); +}); + test("JSON.stringify strings with special characters", () => { expect(JSON.stringify("hello\nworld")).toBe('"hello\\nworld"'); expect(JSON.stringify("tab\there")).toBe('"tab\\there"'); diff --git a/tests/built-ins/Number/prototype/toString.js b/tests/built-ins/Number/prototype/toString.js index b3e9fea13..79437c160 100644 --- a/tests/built-ins/Number/prototype/toString.js +++ b/tests/built-ins/Number/prototype/toString.js @@ -93,6 +93,22 @@ describe("Number.prototype.toString", () => { expect((0.0000001).toString()).toBe("1e-7"); }); + test("toString returns the shortest round-tripping form for computed fractional values", () => { + expect((0.1 + 0.2).toString()).toBe("0.30000000000000004"); + expect((1 / 3).toString()).toBe("0.3333333333333333"); + expect((2 / 3).toString()).toBe("0.6666666666666666"); + expect(Math.PI.toString()).toBe("3.141592653589793"); + expect(Math.sqrt(2).toString()).toBe("1.4142135623730951"); + }); + + test("toString uses the fewest significant digits for scientific-notation values", () => { + expect(Number.MAX_VALUE.toString()).toBe("1.7976931348623157e+308"); + expect((9.18742501042e222).toString()).toBe("9.18742501042e+222"); + expect((6.1103717251161e201).toString()).toBe("6.1103717251161e+201"); + expect((7.5183158306161e142).toString()).toBe("7.5183158306161e+142"); + expect((5.7016275775556e-8).toString()).toBe("5.7016275775556e-8"); + }); + test("String() coercion matches toString for large integers", () => { expect(String(1e15)).toBe("1000000000000000"); expect(String(1e20)).toBe("100000000000000000000"); From 1495fa5c6499829289a6c71481bd2d2ef3e7cd1c Mon Sep 17 00:00:00 2001 From: Johannes Stein Date: Sun, 28 Jun 2026 11:37:15 +0100 Subject: [PATCH 2/5] docs(adr): link ADR 0079 to PR #899 Co-Authored-By: Claude Opus 4.8 --- docs/adr/0079-formatdouble-first-hit-precision-scan.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/adr/0079-formatdouble-first-hit-precision-scan.md b/docs/adr/0079-formatdouble-first-hit-precision-scan.md index 4a3cd9df8..22feefbaf 100644 --- a/docs/adr/0079-formatdouble-first-hit-precision-scan.md +++ b/docs/adr/0079-formatdouble-first-hit-precision-scan.md @@ -3,6 +3,7 @@ **Date:** 2026-06-28 **Area:** `engine` **Issue:** [#812](https://github.com/frostney/GocciaScript/issues/812) +**Pull Request:** [#899](https://github.com/frostney/GocciaScript/pull/899) `FormatDouble` (`Goccia.Values.Primitives`) implements ES2026 §6.1.6.1.20 `Number::toString` for the non-integer case by finding the shortest decimal that round-trips: it scans the `Str(V:W)` precision width `W` from 9 (2 significant digits) to 24 (17 significant digits) and takes the **first** width whose output parses back to the original double. The normative step requires `k` (the digit count) to be "as small as possible", so the shortest representation is a conformance requirement, not a quality-of-implementation nicety. This path backs `Number.prototype.toString`, `String(x)`, template interpolation, property-key stringification, and `JSON.stringify` of floats; `toFixed`/`toExponential`/`toPrecision` use a separate `FormatDoubleToPrecision` path and are unaffected. From 61d16369ff5344432ee8c808ff496efc60463527 Mon Sep 17 00:00:00 2001 From: Johannes Stein Date: Sun, 28 Jun 2026 11:58:58 +0100 Subject: [PATCH 3/5] test(benchmarks): cover non-integer float toString; correct speedup to measured end-to-end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "~2x" figure was the isolated probe-loop microbenchmark. End-to-end in the engine (bytecode, --prod), a toString-dominated float workload of 2M Number.prototype.toString calls over 15-17-significant-digit doubles runs in 13.4s with the change vs 18.8s on origin/main — ~1.4x faster (-28% execution time); the engine's per-call string allocation and dispatch are a roughly constant overhead around FormatDouble. The earlier engine measurements were invalid because GocciaScript skips unsupported traditional for(;;) loops by default, so the probe arrays were never populated. Add a `toString non-integer (shortest round-trip)` benchmark to benchmarks/ numbers.js (using for...of) so CI tracks this path; the existing toString bench only covered the integer fast path. Correct the speedup claim in ADR 0079 and the code comment accordingly. Co-Authored-By: Claude Opus 4.8 --- benchmarks/numbers.js | 17 +++++++++++++++++ ...079-formatdouble-first-hit-precision-scan.md | 2 +- source/units/Goccia.Values.Primitives.pas | 4 ++-- 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/benchmarks/numbers.js b/benchmarks/numbers.js index 0d4883984..c645c3282 100644 --- a/benchmarks/numbers.js +++ b/benchmarks/numbers.js @@ -54,6 +54,23 @@ suite("number prototype methods", () => { }, }); + bench("toString non-integer (shortest round-trip)", { + run: () => { + const xs = [ + 0.1 + 0.2, + Math.PI, + Math.sqrt(2), + Math.E, + 123.456789012345, + 9.18742501042e222, + 5.7016275775556e-8, + ]; + let total = 0; + for (const x of xs) total += x.toString().length; + return total; + }, + }); + bench("valueOf", { run: () => { const a = (42).valueOf(); diff --git a/docs/adr/0079-formatdouble-first-hit-precision-scan.md b/docs/adr/0079-formatdouble-first-hit-precision-scan.md index 22feefbaf..3d9fc3edf 100644 --- a/docs/adr/0079-formatdouble-first-hit-precision-scan.md +++ b/docs/adr/0079-formatdouble-first-hit-precision-scan.md @@ -9,4 +9,4 @@ Issue #812 proposed replacing the linear scan with a binary search over `W` ("same candidates, fewer probes", assumed low risk). It is not low risk: it is incorrect. A sweep of ~70M doubles (FPC 3.2.2, prod `-O4` with `NOFASTMATH`) found the round-trip predicate `Val(Str(V:W)) = V` is **not monotonic** in `W` — 14,241 general-case values have a width that round-trips, a wider width that does not, then a wider one that does again, because FPC `Str` is not correctly rounded at every width. The upward first-hit scan is robust to these holes (the first hit is still the smallest, hence shortest), but a binary search can converge onto a hole above the true minimum: for 115 of ~60M sampled doubles it selected a wider width and emitted a non-shortest string (for example `9.18742501042000e+222` instead of `9.18742501042e+222`, or `6.110371725116101e+201` instead of `6.1103717251161e+201`), violating "k as small as possible". Every probe-skipping variant (stride, galloping, scan-down-until-false) fails for the same reason. **Decision: the scan stays first-hit-from-the-bottom; binary search and probe-skipping are rejected for this function.** A correct single-pass alternative would be a Ryū/Grisu shortest-representation algorithm, which removes the dependence on `Str`'s per-width rounding entirely; that is a larger spec-exact rewrite left for a future decision. -The performance concern behind #812 is addressed without changing the algorithm or its output. Each probe now reads `Str(V:W)` into a fixed `ShortString`, strips the right-justification padding in place, and parses with the locale-free `Val` instead of `Trim` + `TryStrToFloat`. `Val` selects the identical width — verified byte-for-byte against `TryStrToFloat` over 74.9M doubles with zero divergence — while avoiding the per-iteration heap allocation and the `TFormatSettings` scan, roughly halving time on the general path (the deep cases that need 15–17 significant digits scan ~15 widths before they hit). Regression tests in `tests/built-ins/Number/prototype/toString.js` and `tests/built-ins/JSON/stringify.js` lock the exact shortest output for computed fractional values and for several of the non-monotonic "hole" doubles, so any future move to binary search (or any change that lengthens these strings) fails the suite immediately. +The performance concern behind #812 is addressed without changing the algorithm or its output. Each probe now reads `Str(V:W)` into a fixed `ShortString`, strips the right-justification padding in place, and parses with the locale-free `Val` instead of `Trim` + `TryStrToFloat`. `Val` selects the identical width — verified byte-for-byte against `TryStrToFloat` over 74.9M doubles with zero divergence — while avoiding the per-iteration heap allocation and the `TFormatSettings` scan. The probe loop itself is roughly halved; end-to-end the change is about **1.4× faster (−28% execution time)** on a `toString`-dominated float workload (2M `Number.prototype.toString` calls over 15–17-significant-digit doubles, bytecode, `--prod`), with the engine's per-call string allocation and dispatch a roughly constant overhead around `FormatDouble`. `benchmarks/numbers.js` covers this path (the `toString non-integer` bench). Regression tests in `tests/built-ins/Number/prototype/toString.js` and `tests/built-ins/JSON/stringify.js` lock the exact shortest output for computed fractional values and for several of the non-monotonic "hole" doubles, so any future move to binary search (or any change that lengthens these strings) fails the suite immediately. diff --git a/source/units/Goccia.Values.Primitives.pas b/source/units/Goccia.Values.Primitives.pas index 5b3f5f4a2..0320469c8 100644 --- a/source/units/Goccia.Values.Primitives.pas +++ b/source/units/Goccia.Values.Primitives.pas @@ -308,8 +308,8 @@ function FormatDouble(AValue: Double): string; // Each probe reads Str into a fixed ShortString and parses with the // locale-free Val instead of Trim + TryStrToFloat. Val selects the same width // here (verified byte-for-byte over 74.9M doubles) while avoiding both the - // per-iteration heap allocation and the TFormatSettings scan (~2x faster on - // this path). + // per-iteration heap allocation and the TFormatSettings scan; this is ~1.4x + // faster end-to-end on float-stringify-heavy workloads (see ADR 0079). for W := 9 to 24 do begin Str(AValue:W, Buf); From 2f5bdcb30b1c3687a3f53060d24f29c1b6cc943b Mon Sep 17 00:00:00 2001 From: Johannes Stein Date: Sun, 28 Jun 2026 13:00:57 +0100 Subject: [PATCH 4/5] test(number): add non-integer coercion check and hoist toString benchmark samples Address CodeRabbit review nitpicks on PR #899: - toString.js: add a coercion test asserting String(), template, and string concatenation share the non-integer shortest-round-trip FormatDouble path, not just direct toString(). - numbers.js: hoist the sample list out of the bench run callback so the benchmark measures formatting cost rather than per-iteration array rebuild. Co-Authored-By: Claude Opus 4.8 --- benchmarks/numbers.js | 23 +++++++++++--------- tests/built-ins/Number/prototype/toString.js | 6 +++++ 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/benchmarks/numbers.js b/benchmarks/numbers.js index c645c3282..d0b60949c 100644 --- a/benchmarks/numbers.js +++ b/benchmarks/numbers.js @@ -2,6 +2,18 @@ description: Number operation benchmarks ---*/ +// Hoisted so the bench measures FormatDouble's shortest-round-trip path rather +// than the per-iteration cost of rebuilding the list and recomputing constants. +const ToStringNonIntegerSamples = [ + 0.1 + 0.2, + Math.PI, + Math.sqrt(2), + Math.E, + 123.456789012345, + 9.18742501042e222, + 5.7016275775556e-8, +]; + suite("number creation", () => { bench("integer arithmetic", { run: () => { @@ -56,17 +68,8 @@ suite("number prototype methods", () => { bench("toString non-integer (shortest round-trip)", { run: () => { - const xs = [ - 0.1 + 0.2, - Math.PI, - Math.sqrt(2), - Math.E, - 123.456789012345, - 9.18742501042e222, - 5.7016275775556e-8, - ]; let total = 0; - for (const x of xs) total += x.toString().length; + for (const x of ToStringNonIntegerSamples) total += x.toString().length; return total; }, }); diff --git a/tests/built-ins/Number/prototype/toString.js b/tests/built-ins/Number/prototype/toString.js index 79437c160..e52fcfdb8 100644 --- a/tests/built-ins/Number/prototype/toString.js +++ b/tests/built-ins/Number/prototype/toString.js @@ -128,6 +128,12 @@ describe("Number.prototype.toString", () => { expect("" + 1e20).toBe("100000000000000000000"); expect("" + 1e21).toBe("1e+21"); }); + + test("String, template, and concatenation coercion share the non-integer shortest round-trip", () => { + expect(String(0.1 + 0.2)).toBe("0.30000000000000004"); + expect(`${0.1 + 0.2}`).toBe("0.30000000000000004"); + expect("" + Math.PI).toBe("3.141592653589793"); + }); }); describe("Number.prototype.toString non-finite radix", () => { From 80aeabe8c4c4faffc9eac01766e514b911005a4a Mon Sep 17 00:00:00 2001 From: Johannes Stein Date: Sun, 28 Jun 2026 16:15:08 +0100 Subject: [PATCH 5/5] test(number): assert all three coercion forms for both non-integer samples Address CodeRabbit review on PR #899: the coercion test claimed all three coercion paths agree but only checked String()/template for 0.1+0.2 and concatenation for Math.PI, so a regression in the untested combinations would pass. Assert String(), template interpolation, and string concatenation for both representative non-integer values. Co-Authored-By: Claude Opus 4.8 --- tests/built-ins/Number/prototype/toString.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/built-ins/Number/prototype/toString.js b/tests/built-ins/Number/prototype/toString.js index e52fcfdb8..275a3318f 100644 --- a/tests/built-ins/Number/prototype/toString.js +++ b/tests/built-ins/Number/prototype/toString.js @@ -132,6 +132,10 @@ describe("Number.prototype.toString", () => { test("String, template, and concatenation coercion share the non-integer shortest round-trip", () => { expect(String(0.1 + 0.2)).toBe("0.30000000000000004"); expect(`${0.1 + 0.2}`).toBe("0.30000000000000004"); + expect("" + (0.1 + 0.2)).toBe("0.30000000000000004"); + + expect(String(Math.PI)).toBe("3.141592653589793"); + expect(`${Math.PI}`).toBe("3.141592653589793"); expect("" + Math.PI).toBe("3.141592653589793"); }); });