@@ -1660,13 +1660,6 @@ public function testRoundingModeEnumSupport(string $number, int $scale, $mode, s
16601660 $ this ->markTestSkipped ('RoundingMode enum not available ' );
16611661 }
16621662
1663- // Skip unsupported modes in all versions
1664- $ unsupportedModes = [\RoundingMode::TowardsZero, \RoundingMode::AwayFromZero, \RoundingMode::NegativeInfinity];
1665- if (in_array ($ mode , $ unsupportedModes , true )) {
1666- $ this ->expectException (\ValueError::class);
1667- $ this ->expectExceptionMessage ("RoundingMode:: {$ mode ->name } is not supported " );
1668- }
1669-
16701663 $ result = BCMath::round ($ number , $ scale , $ mode );
16711664 $ this ->assertSame ($ expected , $ result );
16721665 }
@@ -1788,6 +1781,10 @@ public function testRoundingModeComprehensive(string $number, int $scale): void
17881781 ['mode ' => \RoundingMode::HalfTowardsZero, 'name ' => 'HalfTowardsZero ' ],
17891782 ['mode ' => \RoundingMode::HalfEven, 'name ' => 'HalfEven ' ],
17901783 ['mode ' => \RoundingMode::HalfOdd, 'name ' => 'HalfOdd ' ],
1784+ ['mode ' => \RoundingMode::TowardsZero, 'name ' => 'TowardsZero ' ],
1785+ ['mode ' => \RoundingMode::AwayFromZero, 'name ' => 'AwayFromZero ' ],
1786+ ['mode ' => \RoundingMode::NegativeInfinity, 'name ' => 'NegativeInfinity ' ],
1787+ ['mode ' => \RoundingMode::PositiveInfinity, 'name ' => 'PositiveInfinity ' ],
17911788 ];
17921789
17931790 foreach ($ testCases as $ testCase ) {
@@ -1821,6 +1818,32 @@ public function testRoundingModeComprehensive(string $number, int $scale): void
18211818 $ number === '1.255 ' && $ scale === 2 && $ modeName === 'HalfEven ' => '1.26 ' ,
18221819 $ number === '1.255 ' && $ scale === 2 && $ modeName === 'HalfOdd ' => '1.25 ' ,
18231820
1821+ // Directional modes (PHP 8.4+ semantics, now supported by the polyfill)
1822+ $ number === '2.5 ' && $ scale === 0 && $ modeName === 'TowardsZero ' => '2 ' ,
1823+ $ number === '2.5 ' && $ scale === 0 && $ modeName === 'AwayFromZero ' => '3 ' ,
1824+ $ number === '2.5 ' && $ scale === 0 && $ modeName === 'NegativeInfinity ' => '2 ' ,
1825+ $ number === '2.5 ' && $ scale === 0 && $ modeName === 'PositiveInfinity ' => '3 ' ,
1826+
1827+ $ number === '-2.5 ' && $ scale === 0 && $ modeName === 'TowardsZero ' => '-2 ' ,
1828+ $ number === '-2.5 ' && $ scale === 0 && $ modeName === 'AwayFromZero ' => '-3 ' ,
1829+ $ number === '-2.5 ' && $ scale === 0 && $ modeName === 'NegativeInfinity ' => '-3 ' ,
1830+ $ number === '-2.5 ' && $ scale === 0 && $ modeName === 'PositiveInfinity ' => '-2 ' ,
1831+
1832+ $ number === '3.5 ' && $ scale === 0 && $ modeName === 'TowardsZero ' => '3 ' ,
1833+ $ number === '3.5 ' && $ scale === 0 && $ modeName === 'AwayFromZero ' => '4 ' ,
1834+ $ number === '3.5 ' && $ scale === 0 && $ modeName === 'NegativeInfinity ' => '3 ' ,
1835+ $ number === '3.5 ' && $ scale === 0 && $ modeName === 'PositiveInfinity ' => '4 ' ,
1836+
1837+ $ number === '-3.5 ' && $ scale === 0 && $ modeName === 'TowardsZero ' => '-3 ' ,
1838+ $ number === '-3.5 ' && $ scale === 0 && $ modeName === 'AwayFromZero ' => '-4 ' ,
1839+ $ number === '-3.5 ' && $ scale === 0 && $ modeName === 'NegativeInfinity ' => '-4 ' ,
1840+ $ number === '-3.5 ' && $ scale === 0 && $ modeName === 'PositiveInfinity ' => '-3 ' ,
1841+
1842+ $ number === '1.255 ' && $ scale === 2 && $ modeName === 'TowardsZero ' => '1.25 ' ,
1843+ $ number === '1.255 ' && $ scale === 2 && $ modeName === 'AwayFromZero ' => '1.26 ' ,
1844+ $ number === '1.255 ' && $ scale === 2 && $ modeName === 'NegativeInfinity ' => '1.25 ' ,
1845+ $ number === '1.255 ' && $ scale === 2 && $ modeName === 'PositiveInfinity ' => '1.26 ' ,
1846+
18241847 default => null
18251848 };
18261849
@@ -1833,29 +1856,6 @@ public function testRoundingModeComprehensive(string $number, int $scale): void
18331856 );
18341857 }
18351858 }
1836-
1837- // Test unsupported modes throw ValueError
1838- $ unsupportedModes = [
1839- ['mode ' => \RoundingMode::TowardsZero, 'name ' => 'TowardsZero ' ],
1840- ['mode ' => \RoundingMode::AwayFromZero, 'name ' => 'AwayFromZero ' ],
1841- ['mode ' => \RoundingMode::NegativeInfinity, 'name ' => 'NegativeInfinity ' ],
1842- ];
1843-
1844- foreach ($ unsupportedModes as $ testCase ) {
1845- $ mode = $ testCase ['mode ' ];
1846- $ modeName = $ testCase ['name ' ];
1847-
1848- try {
1849- BCMath::round ($ number , $ scale , $ mode );
1850- $ this ->fail ("Expected ValueError for unsupported mode {$ modeName }" );
1851- } catch (\ValueError $ e ) {
1852- $ this ->assertStringContainsString (
1853- 'is not supported ' ,
1854- $ e ->getMessage (),
1855- "Expected specific error message for unsupported mode {$ modeName }"
1856- );
1857- }
1858- }
18591859 }
18601860
18611861 /**
@@ -1967,34 +1967,35 @@ public function testPolyfillRoundingModeEnumSupport(): void
19671967 }
19681968
19691969 /**
1970- * Test unsupported RoundingMode enum values in PHP < 8.4.
1971- * These modes should throw ValueError when used in polyfill environment.
1970+ * Test the directional RoundingMode enum values (TowardsZero, AwayFromZero,
1971+ * NegativeInfinity, PositiveInfinity). These are backported by the polyfill
1972+ * for PHP 8.1-8.3 and match the native PHP 8.4+ semantics.
19721973 */
19731974 #[RequiresPhp('>=8.1 ' )]
1974- public function testPolyfillUnsupportedModes (): void
1975+ public function testPolyfillDirectionalModes (): void
19751976 {
19761977 if (!enum_exists ('RoundingMode ' )) {
19771978 $ this ->markTestSkipped ('RoundingMode enum not available ' );
19781979 }
19791980
1980- // Skip this test on PHP 8.4+ where these modes are supported
1981- $ unsupportedModes = [
1982- \RoundingMode::TowardsZero,
1983- \RoundingMode::AwayFromZero,
1984- \RoundingMode::NegativeInfinity,
1981+ // [mode, number, scale, expected]
1982+ $ directionalCases = [
1983+ [\RoundingMode::TowardsZero, '1.55 ' , 1 , '1.5 ' ],
1984+ [\RoundingMode::TowardsZero, '-1.55 ' , 1 , '-1.5 ' ],
1985+ [\RoundingMode::AwayFromZero, '1.55 ' , 1 , '1.6 ' ],
1986+ [\RoundingMode::AwayFromZero, '-1.55 ' , 1 , '-1.6 ' ],
1987+ [\RoundingMode::PositiveInfinity, '1.51 ' , 1 , '1.6 ' ],
1988+ [\RoundingMode::PositiveInfinity, '-1.59 ' , 1 , '-1.5 ' ],
1989+ [\RoundingMode::NegativeInfinity, '1.59 ' , 1 , '1.5 ' ],
1990+ [\RoundingMode::NegativeInfinity, '-1.51 ' , 1 , '-1.6 ' ],
19851991 ];
19861992
1987- foreach ($ unsupportedModes as $ mode ) {
1988- try {
1989- BCMath::round ('1.55 ' , 1 , $ mode );
1990- $ this ->fail ("Expected ValueError for unsupported mode {$ mode ->name }" );
1991- } catch (\ValueError $ e ) {
1992- $ this ->assertStringContainsString (
1993- 'is not supported ' ,
1994- $ e ->getMessage (),
1995- "Expected specific error message for unsupported mode {$ mode ->name }"
1996- );
1997- }
1993+ foreach ($ directionalCases as [$ mode , $ number , $ scale , $ expected ]) {
1994+ $ this ->assertSame (
1995+ $ expected ,
1996+ BCMath::round ($ number , $ scale , $ mode ),
1997+ "Failed for mode {$ mode ->name }, number= {$ number }, scale= {$ scale }"
1998+ );
19981999 }
19992000 }
20002001
@@ -2059,6 +2060,7 @@ public function testRoundingModeEnvironmentDetection(): void
20592060 'TowardsZero ' ,
20602061 'AwayFromZero ' ,
20612062 'NegativeInfinity ' ,
2063+ 'PositiveInfinity ' ,
20622064 ];
20632065
20642066 foreach ($ expectedCases as $ caseName ) {
@@ -2070,12 +2072,73 @@ public function testRoundingModeEnvironmentDetection(): void
20702072 );
20712073 }
20722074
2073- // Test that unsupported modes always throw ValueError regardless of PHP version
2074- try {
2075- BCMath::round ('1.55 ' , 1 , \RoundingMode::TowardsZero);
2076- $ this ->fail ('TowardsZero mode should always throw ValueError ' );
2077- } catch (\ValueError $ e ) {
2078- $ this ->assertStringContainsString ('is not supported ' , $ e ->getMessage ());
2075+ // Directional modes are supported (polyfilled for 8.1-8.3, native on 8.4+)
2076+ // and produce the native PHP 8.4 result regardless of PHP version.
2077+ $ this ->assertSame ('1 ' , BCMath::round ('1.55 ' , 0 , \RoundingMode::TowardsZero));
2078+ $ this ->assertSame ('2 ' , BCMath::round ('1.55 ' , 0 , \RoundingMode::AwayFromZero));
2079+ }
2080+
2081+ /**
2082+ * Regression test: HalfEven/HalfOdd rounding must stay exact for large and
2083+ * high-precision inputs. The previous implementation fell back to float
2084+ * (round((float) ...)), which corrupted digits beyond IEEE-754 precision.
2085+ */
2086+ #[RequiresPhp('>=8.1 ' )]
2087+ public function testHighPrecisionHalfEvenOddRounding (): void
2088+ {
2089+ if (!enum_exists ('RoundingMode ' )) {
2090+ $ this ->markTestSkipped ('RoundingMode enum not available ' );
2091+ }
2092+
2093+ // Larger than 2^53: float conversion would lose the low-order digits.
2094+ $ this ->assertSame ('12345678901234567890 ' , BCMath::round ('12345678901234567890.5 ' , 0 , \RoundingMode::HalfEven));
2095+ $ this ->assertSame ('12345678901234567891 ' , BCMath::round ('12345678901234567890.5 ' , 0 , \RoundingMode::HalfOdd));
2096+
2097+ // Around 2^53 (9007199254740992).
2098+ $ this ->assertSame ('9007199254740994 ' , BCMath::round ('9007199254740993.5 ' , 0 , \RoundingMode::HalfEven));
2099+ $ this ->assertSame ('9007199254740993 ' , BCMath::round ('9007199254740993.5 ' , 0 , \RoundingMode::HalfOdd));
2100+
2101+ // 30 significant digits kept exactly.
2102+ $ this ->assertSame ('0.12345678901234567890 ' , BCMath::round ('0.123456789012345678901234567890 ' , 20 , \RoundingMode::HalfEven));
2103+ $ this ->assertSame ('0.12345678901234567890 ' , BCMath::round ('0.123456789012345678901234567890 ' , 20 , \RoundingMode::HalfOdd));
2104+ }
2105+
2106+ /**
2107+ * Test bcdivmod() (PHP 8.4+), implemented on top of BCMath::div()/mod() so it
2108+ * works without the native bcmath extension.
2109+ */
2110+ public function testDivmod (): void
2111+ {
2112+ // [num1, num2, scale, expectedQuotient, expectedRemainder]
2113+ $ cases = [
2114+ ['17 ' , '5 ' , 0 , '3 ' , '2 ' ],
2115+ ['17 ' , '5 ' , 2 , '3 ' , '2.00 ' ],
2116+ ['-17 ' , '5 ' , 0 , '-3 ' , '-2 ' ],
2117+ ['17 ' , '-5 ' , 0 , '-3 ' , '2 ' ],
2118+ ['14.14 ' , '6 ' , 2 , '2 ' , '2.14 ' ],
2119+ ['0.15 ' , '-0.01 ' , 0 , '-15 ' , '0 ' ],
2120+ ];
2121+
2122+ foreach ($ cases as [$ num1 , $ num2 , $ scale , $ expectedQuot , $ expectedRem ]) {
2123+ [$ quot , $ rem ] = BCMath::divmod ($ num1 , $ num2 , $ scale );
2124+ $ this ->assertSame ($ expectedQuot , $ quot , "quotient of {$ num1 } / {$ num2 } @ {$ scale }" );
2125+ $ this ->assertSame ($ expectedRem , $ rem , "remainder of {$ num1 } / {$ num2 } @ {$ scale }" );
20792126 }
2127+
2128+ // The result must equal the composition of bcdiv()/bcmod().
2129+ [$ quot , $ rem ] = BCMath::divmod ('15151324141414.412 ' , '7.3 ' , 10 );
2130+ $ this ->assertSame (BCMath::div ('15151324141414.412 ' , '7.3 ' , 0 ), $ quot );
2131+ $ this ->assertSame (BCMath::mod ('15151324141414.412 ' , '7.3 ' , 10 ), $ rem );
2132+ }
2133+
2134+ /**
2135+ * bcdivmod() by zero must throw DivisionByZeroError with the native message.
2136+ */
2137+ public function testDivmodByZero (): void
2138+ {
2139+ $ this ->expectException (\DivisionByZeroError::class);
2140+ $ this ->expectExceptionMessage ('Division by zero ' );
2141+
2142+ BCMath::divmod ('1 ' , '0 ' );
20802143 }
20812144}
0 commit comments