Last active
October 3, 2023 20:30
-
-
Save oliviagardiner/c1e0a7113fe8772163ac5b73f0d6acb6 to your computer and use it in GitHub Desktop.
[PHP] Bowling kata TDD
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php declare(strict_types=1); | |
namespace App; | |
use App\Exception\GameOverException; | |
use App\Exception\InvalidFrameException; | |
use App\Exception\InvalidRollException; | |
/** | |
The initial class written to pass the unit tests | |
*/ | |
class BowlingGame | |
{ | |
private $rolls = []; | |
private $currentFrame = []; | |
public function roll(int $pins): void | |
{ | |
if (count($this->rolls) === 10) { | |
throw new GameOverException('Maximum frames reached, game is over.'); | |
} | |
if (!$this->isRollValid($pins)) { | |
throw new InvalidRollException('Roll must be between 0 and 10.'); | |
} | |
if (!$this->isFrameValid($pins)) { | |
throw new InvalidFrameException('Rolls in a single frame cannot exceed the maximum number of pins.'); | |
} | |
$this->currentFrame[] = $pins; | |
if ($this->isFrameEnded()) { | |
$this->rolls[] = $this->currentFrame; | |
$this->currentFrame = []; | |
} | |
} | |
public function score(): int | |
{ | |
$score = 0; | |
foreach ($this->rolls as $index => $frame) { | |
$score += $this->frameValue($frame); | |
$bonus = 0; | |
if (isset($this->rolls[$index - 1]) && $this->frameValue($this->rolls[$index - 1]) === 10) { | |
$bonus += $frame[0]; | |
if (isset($frame[1]) && count($this->rolls[$index - 1]) === 1) { | |
$bonus += $frame[1]; | |
} | |
if (isset($this->rolls[$index - 2]) && $this->frameValue($this->rolls[$index - 2]) === 10 && count($this->rolls[$index - 2]) === 1 && count($this->rolls[$index - 1]) === 1) { | |
$bonus += $frame[0]; | |
} | |
} | |
$score += $bonus; | |
} | |
if (count($this->rolls) < 10) { | |
$score += $this->frameValue($this->currentFrame); | |
} | |
return $score; | |
} | |
public function getCurrentFrame(): int | |
{ | |
return min(10, count($this->rolls) + 1); | |
} | |
private function frameValue(array $frame): int | |
{ | |
return array_sum($frame); | |
} | |
private function isRollValid(int $roll): bool | |
{ | |
return $roll >= 0 && $roll <= 10; | |
} | |
private function isFrameValid(int $pins): bool | |
{ | |
if (count($this->rolls) < 9) { | |
return 10 - $this->frameValue($this->currentFrame) >= $pins; | |
} | |
return $this->frameValue($this->currentFrame) <= 10 * count($this->currentFrame); | |
} | |
private function isFrameEnded(): bool | |
{ | |
if (count($this->rolls) < 9) { | |
return (count($this->currentFrame) === 1 && $this->frameValue($this->currentFrame) === 10) || count($this->currentFrame) === 2; | |
} | |
return (count($this->currentFrame) === 2 && $this->frameValue($this->currentFrame) < 10) || count($this->currentFrame) === 3; | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
declare(strict_types=1); | |
namespace App\Tests; | |
use App\BowlingGame; | |
use App\Exception\GameOverException; | |
use App\Exception\InvalidFrameException; | |
use App\Exception\InvalidRollException; | |
use PHPUnit\Framework\TestCase; | |
use PHPUnit\Framework\Attributes\DataProvider; | |
/** | |
TDD solution for the bowling game kata, up to scoring a single game: https://kata-log.rocks/bowling-game-kata | |
*/ | |
class BowlingGameTest extends TestCase | |
{ | |
public function testRollingRegistersPinsKnockedDown(): void | |
{ | |
$game = new BowlingGame(); | |
$game->roll(3); | |
$this->assertSame(3, $game->score()); | |
} | |
#[DataProvider('invalidRollProvider')] | |
public function testCannotRollLowerThanBoundsOrHigherThanBounds(int $roll): void | |
{ | |
$game = new BowlingGame(); | |
$this->expectException(InvalidRollException::class); | |
$game->roll($roll); | |
} | |
public function testGameStartsAtFrameOne(): void | |
{ | |
$game = new BowlingGame(); | |
$this->assertSame(1, $game->getCurrentFrame()); | |
} | |
#[DataProvider('validFrameProvider')] | |
public function testGameMovesToNextFrameAfterValidRolls(array $frame): void | |
{ | |
$game = new BowlingGame(); | |
foreach ($frame as $roll) { | |
$this->assertSame(1, $game->getCurrentFrame()); | |
$game->roll($roll); | |
} | |
$this->assertSame(2, $game->getCurrentFrame()); | |
} | |
public function testRollsInASingleFrameCannotExceedMaxPins(): void | |
{ | |
$game = new BowlingGame(); | |
$game->roll(3); | |
$this->expectException(InvalidFrameException::class); | |
$game->roll(8); | |
} | |
public function testGameOverAfterTenFrames(): void | |
{ | |
$game = new BowlingGame(); | |
for ($i = 1; $i <= 12; $i++) { | |
$game->roll(10); | |
} | |
$this->expectException(GameOverException::class); | |
$game->roll(2); | |
} | |
public function testPointsDoubledForNextRollAfterSpare(): void | |
{ | |
$game = new BowlingGame(); | |
$game->roll(3); | |
$game->roll(7); | |
$game->roll(5); | |
$game->roll(2); | |
$this->assertSame(22, $game->score()); | |
} | |
public function testPointsDoubledForNextTwoRollsAfterStrike(): void | |
{ | |
$game = new BowlingGame(); | |
$game->roll(10); | |
$game->roll(7); | |
$game->roll(3); | |
$this->assertSame(30, $game->score()); | |
} | |
#[DataProvider('rollsProvider')] | |
public function testRollsForFullGameReturnCorrectScore( | |
array $rolls, | |
int $score | |
): void { | |
$game = new BowlingGame(); | |
foreach ($rolls as $frame) { | |
foreach ($frame as $roll) { | |
$game->roll($roll); | |
} | |
} | |
$this->assertSame($score, $game->score()); | |
} | |
public static function validFrameProvider(): iterable | |
{ | |
yield 'Gutter balls' => [ | |
'frame' => [0, 0] | |
]; | |
yield 'Strike' => [ | |
'frame' => [10] | |
]; | |
yield 'Spare' => [ | |
'frame' => [8, 2] | |
]; | |
yield 'Good try' => [ | |
'frame' => [6, 2] | |
]; | |
} | |
public static function invalidRollProvider(): iterable | |
{ | |
yield 'Lower than 0' => [ | |
'roll' => -4 | |
]; | |
yield 'Higher than 10' => [ | |
'roll' => 12 | |
]; | |
} | |
public static function rollsProvider(): iterable | |
{ | |
yield 'Gutter game' => [ | |
'rolls' => [ | |
[0, 0], | |
[0, 0], | |
[0, 0], | |
[0, 0], | |
[0, 0], | |
[0, 0], | |
[0, 0], | |
[0, 0], | |
[0, 0], | |
[0, 0] | |
], | |
'score' => 0 | |
]; | |
yield 'Perfect game' => [ | |
'rolls' => [ | |
[10], | |
[10], | |
[10], | |
[10], | |
[10], | |
[10], | |
[10], | |
[10], | |
[10], | |
[10, 10, 10] | |
], | |
'score' => 300 | |
]; | |
yield 'Good game with some strikes and spares' => [ | |
'rolls' => [ | |
[10], | |
[7, 3], | |
[7, 2], | |
[9, 1], | |
[10], | |
[10], | |
[10], | |
[2, 3], | |
[6, 4], | |
[7, 3, 3] | |
], | |
'score' => 168 | |
]; | |
yield 'Bad game with no strikes and spares' => [ | |
'rolls' => [ | |
[3, 4], | |
[8, 0], | |
[4, 4], | |
[9, 0], | |
[7, 1], | |
[9, 0], | |
[4, 3], | |
[3, 3], | |
[6, 2], | |
[7, 2] | |
], | |
'score' => 79 | |
]; | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
declare(strict_types=1); | |
namespace App; | |
use App\Exception\InvalidFrameException; | |
use App\Exception\InvalidRollException; | |
class BaseFrame | |
{ | |
protected $rolls = []; | |
public function roll(int $pins): void | |
{ | |
if (!$this->isRollValid($pins)) { | |
throw new InvalidRollException('Pins in roll out of allowed range.'); | |
} | |
$this->rolls[] = $pins; | |
if (!$this->isFrameValid()) { | |
throw new InvalidFrameException('Pins in frame out of allowed range.'); | |
} | |
} | |
public function frameValue(): int | |
{ | |
return array_sum($this->rolls); | |
} | |
public function getRollAtIndex(int $index): ?int | |
{ | |
return $this->rolls[$index] ?? null; | |
} | |
public function isStrike(): bool | |
{ | |
return count($this->rolls) === 1 && $this->frameValue() === 10; | |
} | |
public function isSpare(): bool | |
{ | |
return count($this->rolls) === 2 && $this->frameValue() === 10; | |
} | |
public function isFrameComplete(): bool | |
{ | |
return $this->isStrike() || count($this->rolls) === 2; | |
} | |
protected function isFrameValid(): bool | |
{ | |
return $this->frameValue() <= 10; | |
} | |
private function isRollValid(int $pins): bool | |
{ | |
return $pins >= 0 && $pins <= 10; | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php declare(strict_types=1); | |
namespace App; | |
use App\Exception\GameOverException; | |
/** | |
This class passes the same set of unit tests | |
*/ | |
class BowlingGame | |
{ | |
private $frames = []; | |
private BaseFrame $currentFrame; | |
public function __construct() | |
{ | |
$this->currentFrame = new BaseFrame(); | |
} | |
public function roll(int $pins): void | |
{ | |
if (count($this->frames) === 10) { | |
throw new GameOverException('Maximum frames reached, game is over.'); | |
} | |
$this->currentFrame->roll($pins); | |
if ($this->currentFrame->isFrameComplete()) { | |
$this->moveToNextFrame(); | |
} | |
} | |
public function score(): int | |
{ | |
$score = 0; | |
foreach ($this->frames as $index => $frame) { | |
$score += $frame->frameValue(); | |
$bonus = 0; | |
$lastFrame = $this->frames[$index - 1] ?? null; | |
if ($lastFrame && ($lastFrame->isStrike() || $lastFrame->isSpare())) { | |
$bonus += $frame->getRollAtIndex(0); | |
if ($frame->getRollAtIndex(1) && $lastFrame->isStrike()) { | |
$bonus += $frame->getRollAtIndex(1); | |
} | |
$lastLastFrame = $this->frames[$index - 2] ?? null; | |
if ($lastLastFrame && $lastLastFrame->isStrike() && $lastFrame->isStrike()) { | |
$bonus += $frame->getRollAtIndex(0); | |
} | |
} | |
$score += $bonus; | |
} | |
if (count($this->frames) < 10) { | |
$score += $this->currentFrame->frameValue(); | |
} | |
return $score; | |
} | |
public function getCurrentFrame(): int | |
{ | |
return min(10, count($this->frames) + 1); | |
} | |
private function moveToNextFrame(): void | |
{ | |
$this->frames[] = $this->currentFrame; | |
$this->currentFrame = $this->isNextFrameLast() ? new LastFrame() : new BaseFrame(); | |
} | |
private function isNextFrameLast(): bool | |
{ | |
return count($this->frames) === 9; | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
declare(strict_types=1); | |
namespace App; | |
class LastFrame extends BaseFrame | |
{ | |
public function isStrike(): bool | |
{ | |
return isset($this->rolls[0]) && $this->rolls[0] === 10; | |
} | |
public function isSpare(): bool | |
{ | |
return count($this->rolls) >= 2 && ($this->rolls[0] + $this->rolls[1]) === 10; | |
} | |
public function isFrameComplete(): bool | |
{ | |
return (count($this->rolls) === 2 && $this->frameValue() < 10) || count($this->rolls) === 3; | |
} | |
protected function isFrameValid(): bool | |
{ | |
if (count($this->rolls) === 1) { | |
return true; | |
} else { | |
if ($this->isStrike() || $this->isSpare() || $this->frameValue() < 10) { | |
return true; | |
} | |
return false; | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment