Skip to content

Instantly share code, notes, and snippets.

@oliviagardiner
Last active October 3, 2023 20:30
Show Gist options
  • Save oliviagardiner/c1e0a7113fe8772163ac5b73f0d6acb6 to your computer and use it in GitHub Desktop.
Save oliviagardiner/c1e0a7113fe8772163ac5b73f0d6acb6 to your computer and use it in GitHub Desktop.
[PHP] Bowling kata TDD
<?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;
}
}
<?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
];
}
}
<?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;
}
}
<?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;
}
}
<?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