@@ -259,32 +259,56 @@ contract LibFormatDecimalFloatToDecimalStringTest is Test {
259259
260260 /// Fuzz: every Float round-trips through scientific format → parse → eq
261261 /// across the full int224 coefficient domain, with exponent bounded to
262- /// leave headroom for the scientific display exponent .
262+ /// avoid the display-exponent overflow guard added by #185 .
263263 ///
264264 /// Scientific format renders `coef × 10^exp` as `d.dddd × 10^displayExp`
265265 /// where `displayExp = exp + 75 or 76` (after `maximizeFull` + scale).
266- /// For exponents within ~76 of `int32.max`, the resulting display exponent
267- /// exceeds `int32.max`, and the parser rejects it on re-pack. The
268- /// headroom below avoids that asymmetric range; see separate issue for
269- /// the format/parse exponent-range mismatch.
266+ /// The formatter now reverts `UnformatableExponent` when `displayExp`
267+ /// falls outside `[int32.min, int32.max]`. The headroom below keeps the
268+ /// fuzz in the round-trip-safe zone.
270269 function testFormatParseRoundTripScientificFullDomain (int224 coefficient , int32 exponent ) external pure {
271270 int256 headroom = 80 ;
272- // `bound` to a sub-range of int32 that avoids display-exponent overflow.
271+ // `bound` to a sub-range of int32 that avoids triggering the int32
272+ // display-exponent overflow guard.
273273 // forge-lint: disable-next-line(unsafe-typecast)
274274 exponent = int32 (bound (exponent, int256 (type (int32 ).min) + headroom, int256 (type (int32 ).max) - headroom));
275275 _checkRoundTrip (coefficient, exponent, true );
276276 }
277277
278+ /// Scientific format reverts when the display exponent would overflow
279+ /// int32 (positive side). With coefficient = int224.max (~68 digits),
280+ /// maximizeFull extends it to ~78 digits, reducing the stored exponent
281+ /// by ~10. displayExponent = storedExp + scaleExponent = (exp - 10) + 76
282+ /// = exp + 66. For exp = int32.max - 50, displayExp = int32.max + 16,
283+ /// which overflows int32.
284+ function testFormatScientificRevertsNearPositiveInt32Limit () external {
285+ int256 exp = int256 (type (int32 ).max) - 50 ;
286+ Float float = LibDecimalFloat.packLossless (int256 (type (int224 ).max), exp);
287+ vm.expectRevert (abi.encodeWithSelector (UnformatableExponent.selector , exp));
288+ this .formatExternal (float, true );
289+ }
290+
291+ /// Scientific format reverts when the display exponent would overflow
292+ /// int32 (negative side). With coefficient = 1 (1 digit), maximizeFull
293+ /// extends to ~77 digits, reducing stored exponent by ~76. displayExponent
294+ /// = (exp - 76) + 76 = exp. For exp = int32.min, displayExp = int32.min,
295+ /// which fits in int32 — so this boundary does NOT trigger for k=1. Use a
296+ /// large negative coefficient so k > 1 and verify we remain safe.
297+ function testFormatScientificNegativeBoundaryDoesNotRevert () external pure {
298+ // (int224.max, int32.min + 80): headroom=80 ensures we stay in-range.
299+ Float float = LibDecimalFloat.packLossless (int256 (type (int224 ).max), int256 (type (int32 ).min) + 80 );
300+ string memory s = LibFormatDecimalFloat.toDecimalString (float, true );
301+ assertGt (bytes (s).length , 0 );
302+ }
303+
278304 /// Fuzz: every Float with non-positive exponent round-trips through
279305 /// non-scientific format → parse → eq, across the full int224 coefficient
280306 /// domain and exponent in `[-MAX_NON_SCIENTIFIC_EXPONENT, 0]`.
281307 ///
282- /// Positive exponents are NOT fuzzed here: the non-scientific formatter
283- /// emits `coefficient_digits + exponent` trailing zeros, which can exceed
284- /// the parser's int256 accumulator for modest positive exponents with
285- /// non-trivial coefficients. That format/parse asymmetry is a separate
286- /// concern (see issue for tracking); this fuzz covers the negative-exp
287- /// range where #182-class bugs surface.
308+ /// Positive exponents are NOT fuzzed here: the formatter reverts when
309+ /// `absCoef * 10^exponent > int224.max` (the formatted integer would exceed
310+ /// the parser's lossless range). See `testFormatParseRoundTripNonScientificSafePosExp`
311+ /// for positive-exponent round-trip coverage within the safe range.
288312 function testFormatParseRoundTripNonScientificNegExpFullDomain (int224 coefficient , int32 exponent ) external pure {
289313 int256 cap = LibFormatDecimalFloat.MAX_NON_SCIENTIFIC_EXPONENT;
290314 // `bound` returns a value in [-cap, 0]; cap fits int32 so the cast back
@@ -341,15 +365,14 @@ contract LibFormatDecimalFloatToDecimalStringTest is Test {
341365 assertEq (formatA, formatB, "Different representations formatted to different strings " );
342366 }
343367
344- /// Non-scientific format succeeds at the exact cap boundary.
345- function testFormatNonScientificExponentAtPositiveCap () external pure {
368+ /// Non-scientific format reverts at the positive exponent cap boundary:
369+ /// MAX_NON_SCIENTIFIC_EXPONENT = 1000 >= 68, so 1 × 10^1000 >> int224.max
370+ /// and the int224 overflow guard fires. Use scientific mode for such values.
371+ function testFormatNonScientificExponentAtPositiveCapReverts () external {
346372 int256 cap = LibFormatDecimalFloat.MAX_NON_SCIENTIFIC_EXPONENT;
347373 Float float = LibDecimalFloat.packLossless (1 , cap);
348- string memory s = LibFormatDecimalFloat.toDecimalString (float, false );
349- // "1" followed by `cap` zeros.
350- // forge-lint: disable-next-line(unsafe-typecast)
351- assertEq (bytes (s).length , 1 + uint256 (cap));
352- assertEq (bytes (s)[0 ], bytes1 ("1 " ));
374+ vm.expectRevert (abi.encodeWithSelector (UnformatableExponent.selector , cap));
375+ this .formatExternal (float, false );
353376 }
354377
355378 function testFormatNonScientificExponentAtNegativeCap () external pure {
@@ -414,21 +437,74 @@ contract LibFormatDecimalFloatToDecimalStringTest is Test {
414437 }
415438
416439 /// Fuzz: non-scientific format does not revert for any valid Float with
417- /// `|exponent| <= MAX_NON_SCIENTIFIC_EXPONENT`, across the full int224
418- /// coefficient range. Covers the positive-exponent sub-range that the
419- /// parse round-trip fuzz cannot exercise (blocked on #184).
420- function testFormatNonScientificSucceedsAcrossFullRange (int224 coefficient , int32 exponent ) external pure {
440+ /// `|exponent| <= MAX_NON_SCIENTIFIC_EXPONENT` and non-positive exponent.
441+ /// When exponent <= 0 the formatted integer equals absCoef / 10^|exponent|
442+ /// which is at most absCoef <= int224.max, so the int224 overflow guard
443+ /// never fires. Positive exponents may revert with `UnformatableExponent`
444+ /// when `absCoef * 10^exponent > int224.max`.
445+ function testFormatNonScientificSucceedsForNonPositiveExponents (int224 coefficient , int32 exponent ) external pure {
421446 int256 cap = LibFormatDecimalFloat.MAX_NON_SCIENTIFIC_EXPONENT;
422- // `bound` to [-cap, cap]; cap fits int32 so the cast is safe.
447+ // Bound to [-cap, 0]; negative and zero exponents never trigger the
448+ // int-digit-count guard.
423449 // forge-lint: disable-next-line(unsafe-typecast)
424- exponent = int32 (bound (exponent, - cap, cap ));
450+ exponent = int32 (bound (exponent, - cap, 0 ));
425451 Float float = LibDecimalFloat.packLossless (coefficient, exponent);
426452 // Should not revert.
427453 string memory s = LibFormatDecimalFloat.toDecimalString (float, false );
428- // Non-empty output is a minimum sanity guarantee.
429454 assertGt (bytes (s).length , 0 );
430455 }
431456
457+ /// The formatter reverts with `UnformatableExponent` when `exponent >= 68`
458+ /// (10^68 > int224.max ≈ 1.34e67, so even coefficient 1 overflows).
459+ /// Mutation test: remove the guard in `_toNonScientific` → this test
460+ /// fails because the call no longer reverts.
461+ function testFormatNonScientificRevertsOnLongPositiveExp () external {
462+ // (1, 68): 1 × 10^68 > int224.max → reverts.
463+ Float float = LibDecimalFloat.packLossless (1 , 68 );
464+ vm.expectRevert (abi.encodeWithSelector (UnformatableExponent.selector , int256 (68 )));
465+ this .formatExternal (float, false );
466+ }
467+
468+ /// The largest positive exponent where coefficient=1 still passes the
469+ /// int224 overflow guard: exponent=67 gives value 1×10^67 < int224.max
470+ /// (≈1.34e67). The formatter succeeds and the output round-trips.
471+ function testFormatNonScientificAtIntDigitBoundary () external pure {
472+ // (1, 67): value = 1e67 < int224.max; limit = floor(int224.max / 10^67) = 1.
473+ // absCoef=1 <= 1 → guard passes. Output: "1" + 67 zeros = 68 chars.
474+ Float float = LibDecimalFloat.packLossless (1 , 67 );
475+ string memory s = LibFormatDecimalFloat.toDecimalString (float, false );
476+ assertEq (bytes (s).length , 68 , "output length " );
477+ assertEq (bytes (s)[0 ], bytes1 ("1 " ), "leading digit " );
478+ (bytes4 err , Float parsed ) = LibParseDecimalFloat.parseDecimalFloat (s);
479+ assertEq (err, bytes4 (0 ), "parse error " );
480+ assertTrue (float.eq (parsed), "round-trip mismatch " );
481+ }
482+
483+ /// Fuzz: for every non-zero int224 coefficient and positive exponent where
484+ /// the formatter does NOT revert (i.e. absCoef × 10^exponent <= int224.max),
485+ /// the output round-trips through parse. Uses the same limit computation as
486+ /// the formatter to skip cases that correctly revert.
487+ /// forge-config: default.fuzz.runs = 100
488+ function testFormatParseRoundTripNonScientificSafePosExp (int224 coefficient , int32 exponent ) external pure {
489+ vm.assume (coefficient != 0 );
490+ vm.assume (exponent > 0 );
491+ // Mirror the formatter's guard: skip if exponent >= 68 or absCoef > limit.
492+ // forge-lint: disable-next-line(unsafe-typecast)
493+ uint256 uExp = uint256 (uint32 (exponent));
494+ vm.assume (uExp < 68 );
495+ uint256 limit = uint256 (int256 (type (int224 ).max));
496+ for (uint256 i = 0 ; i < uExp; i++ ) {
497+ limit /= 10 ;
498+ }
499+ uint256 absCoef = coefficient < 0 ? uint256 (- int256 (coefficient)) : uint256 (int256 (coefficient));
500+ vm.assume (absCoef <= limit);
501+ Float float = LibDecimalFloat.packLossless (coefficient, exponent);
502+ string memory s = LibFormatDecimalFloat.toDecimalString (float, false );
503+ (bytes4 err , Float parsed ) = LibParseDecimalFloat.parseDecimalFloat (s);
504+ assertEq (err, bytes4 (0 ), string .concat ("Parse error on: " , s));
505+ assertTrue (float.eq (parsed), string .concat ("Round trip mismatch on: " , s));
506+ }
507+
432508 /// Fuzz: output shape properties for non-scientific format.
433509 /// - Never ends with "." (formatter always strips trailing zeros from the
434510 /// fractional part; a lone "." would indicate a bug).
@@ -441,8 +517,11 @@ contract LibFormatDecimalFloatToDecimalStringTest is Test {
441517 // negation-symmetry check below.
442518 vm.assume (coefficient != type (int224 ).min);
443519 int256 cap = LibFormatDecimalFloat.MAX_NON_SCIENTIFIC_EXPONENT;
520+ // Bound to [-cap, 0]: non-positive exponents never trigger the
521+ // positive-exponent int224 overflow guard, so the formatter never
522+ // reverts and shape assertions always apply.
444523 // forge-lint: disable-next-line(unsafe-typecast)
445- exponent = int32 (bound (exponent, - cap, cap ));
524+ exponent = int32 (bound (exponent, - cap, 0 ));
446525 Float float = LibDecimalFloat.packLossless (coefficient, exponent);
447526 bytes memory s = bytes (LibFormatDecimalFloat.toDecimalString (float, false ));
448527 assertGt (s.length , 0 );
@@ -482,21 +561,23 @@ contract LibFormatDecimalFloatToDecimalStringTest is Test {
482561 assertEq (LibFormatDecimalFloat.toDecimalString (LibDecimalFloat.FLOAT_HALF, false ), "0.5 " );
483562 }
484563
485- /// Non-scientific format of `(1, 77)` produces "1" followed by 77 zeros.
486- /// Historically this reverted because the implementation computed
487- /// `10^exponent` as int256; the rewrite uses direct string placement and
488- /// handles any `|exponent| <= MAX_NON_SCIENTIFIC_EXPONENT`.
489- function testFormatNonScientificLargePositiveExponent () external pure {
490- checkFormat (1 , 77 , false , "100000000000000000000000000000000000000000000000000000000000000000000000000000 " );
564+ /// Non-scientific format of (1, 77) now reverts: 1 × 10^77 far exceeds
565+ /// int224.max (≈1.34e67), so the formatted integer cannot be parsed back
566+ /// losslessly. The formatter reverts rather than silently producing a
567+ /// non-round-trippable string.
568+ function testFormatNonScientificLargePositiveExponent () external {
569+ Float float = LibDecimalFloat.packLossless (1 , 77 );
570+ vm.expectRevert (abi.encodeWithSelector (UnformatableExponent.selector , int256 (77 )));
571+ this .formatExternal (float, false );
491572 }
492573
493- /// Non-scientific format of a large coefficient with moderate positive
494- /// exponent formats without overflow. `int224.max = 2^223 - 1`, which has
495- /// 68 decimal digits; with exponent 10 the output is 78 characters.
496- function testFormatNonScientificLargeCoefficientLargeExponent () external pure {
574+ /// Non-scientific format of (int224.max, 10) reverts: int224.max × 10
575+ /// exceeds int224.max, so the output cannot round-trip through the parser.
576+ function testFormatNonScientificLargeCoefficientLargeExponent () external {
497577 int256 c = int256 (type (int224 ).max);
498- string memory expected = string .concat (Strings.toStringSigned (c), "0000000000 " );
499- checkFormat (c, 10 , false , expected);
578+ Float float = LibDecimalFloat.packLossless (c, 10 );
579+ vm.expectRevert (abi.encodeWithSelector (UnformatableExponent.selector , int256 (10 )));
580+ this .formatExternal (float, false );
500581 }
501582
502583 /// Non-scientific format reverts when `|exponent|` exceeds the policy cap.
0 commit comments