Skip to content

Commit 872e5bf

Browse files
nanasessclaude
andcommitted
test: 方向系モード・高精度丸め・bcdivmod のテストを追加/更新
- 方向系モードが ValueError を投げる前提だった旧テストを、 ネイティブ一致の正結果検証へ変更 - 高精度 HalfEven/HalfOdd の回帰テストを追加 (float フォールバック 時代のバグ: 12345678901234567890.5 や 2^53 付近の欠落を検出) - bcdivmod / 0除算のテストを追加 - phpstan: 純粋 enum 化に伴う match の default 追加と、bcmath 拡張 ロード時にネイティブ bcround 型で解決される場合の ignore を調整 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 80d4d4b commit 872e5bf

2 files changed

Lines changed: 119 additions & 56 deletions

File tree

phpstan.neon.dist

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ parameters:
2929
message: "#Match arm comparison between RoundingMode and '(halfawayfromzero|halftowardszero|halfeven|halfodd|towardszero|awayfromzero|negativeinfinity|positiveinfinity)' is always false\\.#"
3030
-
3131
identifier: argument.type
32-
message: "#Parameter \\#3 \\$mode of (static method bcmath_compat\\\\BCMath::round\\(\\)|function bcround) expects int\\|RoundingMode, (string|int) given\\.#"
32+
message: "#Parameter \\#3 \\$mode of (static method bcmath_compat\\\\BCMath::round\\(\\)|function bcround) expects (int\\|RoundingMode|RoundingMode), (string|int) given\\.#"
3333
paths:
3434
- src/BCMath.php
3535
- tests/BCMathTest.php

tests/BCMathTest.php

Lines changed: 118 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)