Move all files to php/pest/

This commit is contained in:
Oliver Davies 2025-09-29 23:09:31 +01:00
parent a62c22030e
commit 8784174d85
23 changed files with 0 additions and 0 deletions

31
php/pest/.github/workflows/main.yml vendored Normal file
View file

@ -0,0 +1,31 @@
---
name: Run tests
on:
push:
jobs:
phpunit:
runs-on: ubuntu-latest
strategy:
matrix:
php-version: ['7.4']
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Configure PHP ${{ matrix.php-version }}
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php-version }}
- name: Cache dependencies
uses: actions/cache@v1
with:
path: ~/.composer/cache/files
key: dependencies-composer-${{ hashFiles('composer.json') }}
- name: Run tests
run: make test

8
php/pest/.gitignore vendored Normal file
View file

@ -0,0 +1,8 @@
*
!/*
!/.github/**
!/composer.*
!/phpunit.xml
!/README.md
!/src/**
!/tests/**

19
php/pest/Makefile Normal file
View file

@ -0,0 +1,19 @@
CLEAN_PATHS=vendor
PEST_PATH=vendor/bin/pest
all: test
clean:
@for dir in $(CLEAN_PATHS); do \
rm -fr $$dir; \
done
pest: vendor
@$(PEST_PATH)
test: pest
vendor: composer.json composer.lock
@composer install
.PHONY: all clean pest test

17
php/pest/README.md Normal file
View file

@ -0,0 +1,17 @@
# PHP katas with Pest PHP
PHP code katas, tested with [Pest PHP][]. Based on exercises from [Exercism.io][].
Includes:
- **Anagrams**: select an anagram for a word from a list of condidates.
- **Flatten Array**: take a nested list and return a single flattened list with all values except nil/null.
- **Bob**: returns different responses based on input.
- **Bowling**: calculate the score for a game of bowling.
- **Grade School**: given students' names along with the grade that they are in, create a roster for the school.
- **Pangram** - determine if a sentence is a pangram (every letter of the alphabet is used at least once).
- **Rock, paper, scissors**
- **Roman numerals**: convert a number into its roman numeral value.
[exercism.io]: https://exercism.io
[pest php]: https://pestphp.com

24
php/pest/composer.json Normal file
View file

@ -0,0 +1,24 @@
{
"minimum-stability": "dev",
"prefer-stable": true,
"require": {
"beberlei/assert": "^3.2",
"tightenco/collect": "^7.12"
},
"require-dev": {
"pestphp/pest": "^0.2.3",
"phpunit/phpunit": "^9.0"
},
"autoload": {
"files": [
"src/FlattenArray.php",
"src/Pangram.php"
],
"psr-4": {
"App\\": "src/"
}
},
"config": {
"sort-packages": true
}
}

3244
php/pest/composer.lock generated Normal file

File diff suppressed because it is too large Load diff

12
php/pest/phpunit.xml Normal file
View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="./vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
>
<testsuites>
<testsuite name="Unit">
<directory suffix="Test.php">./tests</directory>
</testsuite>
</testsuites>
</phpunit>

27
php/pest/src/Anagram.php Normal file
View file

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App;
use Tightenco\Collect\Support\Collection;
final class Anagram
{
private static function sortLettersInWord(string $word): string
{
return (new Collection(str_split($word)))
->sort()
->implode('');
}
public static function forWord(string $word, array $candidates): Collection
{
$word = static::sortLettersInWord($word);
return (new Collection($candidates))
->filter(fn (string $candidate): bool =>
static::sortLettersInWord($candidate) == $word)
->values();
}
}

57
php/pest/src/Bob.php Normal file
View file

@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App;
final class Bob
{
public const RESPONSE_DEFAULT = 'Whatever.';
public const RESPONSE_SAY_NOTHING = 'Fine. Be that way!';
public const RESPONSE_QUESTION = 'Sure.';
public const RESPONSE_YELLING = 'Whoa, chill out!';
public const RESPONSE_YELLING_QUESTION = 'Calm down, I know what I\'m doing!';
private string $input;
public static function saySomethingTo(string $input): self
{
return new self($input);
}
public function getResponse(): string
{
if ($this->input === '') {
return self::RESPONSE_SAY_NOTHING;
}
if ($this->isQuestion() && $this->isYelling()) {
return self::RESPONSE_YELLING_QUESTION;
}
if ($this->isQuestion()) {
return self::RESPONSE_QUESTION;
}
if ($this->isYelling()) {
return self::RESPONSE_YELLING;
}
return self::RESPONSE_DEFAULT;
}
final private function __construct(string $input)
{
$this->input = $input;
}
private function isQuestion(): bool
{
return (bool) preg_match('/.+\?/', $this->input);
}
private function isYelling(): bool
{
return (bool) preg_match('/.+!/', $this->input);
}
}

View file

@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace App;
use Assert\Assert;
use Tightenco\Collect\Support\Collection;
final class BowlingGame
{
private const MAX_PINS_PER_FRAME = 10;
private const NUMBER_OF_FRAMES = 10;
private Collection $rolls;
public function __construct()
{
$this->rolls = new Collection();
}
public function roll(int $pins): void
{
Assert::that($pins)
->greaterOrEqualThan(0,
'You cannot knock down a negative number of pins. Knocked down %d.')
->lessOrEqualThan(self::MAX_PINS_PER_FRAME,
'You can only knock down a maximum of 10 pins in a roll. Knocked down 12.');
$this->rolls->push($pins);
}
public function getScore(): int
{
$score = 0;
$roll = 0;
foreach (range(1, self::NUMBER_OF_FRAMES) as $frame) {
if ($this->isStrike($roll)) {
$score += $this->rolls[$roll];
$score += $this->bonusForStrike($roll);
$roll += 1;
continue;
}
if ($this->isSpare($roll)) {
$score += $this->defaultFrameScore($roll);
$score += $this->bonusForSpare($roll);
$roll += 2;
continue;
}
$score += $this->defaultFrameScore($roll);
$roll += 2;
}
return $score;
}
private function bonusForSpare(int $roll): int
{
return $this->rolls->get($roll + 2);
}
private function bonusForStrike(int $roll): int
{
return $this->rolls[$roll + 1] + $this->rolls[$roll + 2];
}
private function defaultFrameScore(int $roll): int
{
return $this->rolls[$roll] + $this->rolls[$roll + 1];
}
private function isSpare(int $roll): bool
{
return $this->defaultFrameScore($roll)
== self::MAX_PINS_PER_FRAME;
}
private function isStrike(int $roll): bool
{
return $this->rolls[$roll] == self::MAX_PINS_PER_FRAME;
}
}

View file

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App;
function flattenArray(array $input, array &$output = []): array
{
foreach ($input as $item) {
if (!is_array($item)) {
$output[] = $item;
continue;
}
flattenArray($item, $output);
}
return array_values(array_filter($output));
}

View file

@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App;
use Assert\Assert;
use Tightenco\Collect\Support\Collection;
final class GradeSchool
{
private Collection $students;
public function __construct()
{
$this->students = new Collection();
}
public function addStudentToGrade(string $name, int $grade): void
{
Assert::that($name)->notEmpty('Name cannot be blank');
Assert::that($grade)
->greaterOrEqualThan(1, 'Grade must be greater than or equal to 1');
$this->students->push(['name' => $name, 'grade' => $grade]);
}
public function getAllStudents(): Collection
{
return $this->students->pluck('grade')
->unique()
->map(fn (int $grade): Collection => new Collection([
'grade' => $grade,
'students' => $this->getStudentsByGrade($grade),
]))
->sortBy('grade')
->values();
}
public function getStudentsByGrade(int $grade): Collection
{
return $this->students
->filter(fn (array $student): bool => $grade == $student['grade'])
->pluck('name')
->sort()
->values();
}
}

14
php/pest/src/Pangram.php Normal file
View file

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App;
function isPangram(string $input): bool
{
$split = str_split($input);
$lower = array_map('strtolower', $split);
$filtered = array_filter($lower, fn(string $letter) => $letter != ' ');
return count(array_unique($filtered)) == 26;
}

View file

@ -0,0 +1,77 @@
<?php
namespace App;
use Assert\Assert;
use Tightenco\Collect\Support\Collection;
class RockPaperScissorsGame
{
private const MOVE_ROCK = 'rock';
private const MOVE_PAPER = 'paper';
private const MOVE_SCISSORS = 'scissors';
private static array $possibleMoves = [
[
'moves' => [self::MOVE_ROCK, self::MOVE_PAPER],
'winner' => self::MOVE_PAPER,
],
[
'moves' => [self::MOVE_ROCK, self::MOVE_SCISSORS],
'winner' => self::MOVE_SCISSORS,
],
[
'moves' => [self::MOVE_PAPER, self::MOVE_SCISSORS],
'winner' => self::MOVE_SCISSORS,
],
];
private Collection $moves;
public function __construct()
{
$this->moves = new Collection();
}
public function firstMove(string $move): self
{
$this->validateMoveIsValid($move);
$this->moves[0] = $move;
return $this;
}
public function secondMove(string $move): self
{
$this->validateMoveIsValid($move);
$this->moves[1] = $move;
return $this;
}
public function getWinner(): ?string
{
if ($this->moves[0] == $this->moves[1]) {
return null;
}
$move = (new Collection(self::$possibleMoves))
->first(function (array $move): bool {
// Determine if the played moves match this combination of
// possible moves.
return $this->moves->diff($move['moves'])->isEmpty();
});
return $move['winner'];
}
private function validateMoveIsValid(string $move): void
{
Assert::that($move)
->notEmpty('You must specify a move.')
->inArray(
[self::MOVE_ROCK, self::MOVE_PAPER, self::MOVE_SCISSORS],
'You must enter a valid move. %s given.'
);
}
}

View file

@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App;
use Assert\Assert;
final class RomanNumeralsConverter
{
private static $map = [
1000 => 'M',
900 => 'CM',
500 => 'D',
400 => 'CD',
100 => 'C',
90 => 'XC',
50 => 'L',
40 => 'XL',
10 => 'X',
9 => 'IX',
5 => 'V',
4 => 'IV',
3 => 'III',
2 => 'II',
1 => 'I',
];
public static function convert(int $input): string
{
Assert::that($input)
->greaterOrEqualThan(0, 'Cannot convert negative numbers');
$letters = '';
while ($input > 0) {
foreach (static::$map as $number => $letter) {
if ($input >= $number) {
// Add the appropriate numeral and reduce the value of
// $input accordingly.
$letters .= $letter;
$input = ($input - $number);
break;
}
}
}
return $letters;
}
}

View file

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
use App\Anagram;
it('selects the correct anagrams for a word', function () {
$matches = Anagram::forWord(
'listen',
['enlists', 'google', 'inlets', 'banana']
);
assertSame(['inlets'], $matches->toArray());
});

View file

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
use App\Bob;
it('replies with a standard response', function (): void {
$input = 'Foo bar';
$expected = Bob::RESPONSE_DEFAULT;
assertSame($expected, Bob::saySomethingTo($input)->getResponse());
});
it('replies if you address him without saying anything', function (): void {
$input = '';
$expected = Bob::RESPONSE_SAY_NOTHING;
assertSame($expected, Bob::saySomethingTo($input)->getResponse());
});
it('replies to a question', function (): void {
$input = 'How are you?';
$expected = Bob::RESPONSE_QUESTION;
assertSame($expected, Bob::saySomethingTo($input)->getResponse());
});
it('replies to yelling', function (): void {
$input = 'Go to bed!';
$expected = Bob::RESPONSE_YELLING;
assertSame($expected, Bob::saySomethingTo($input)->getResponse());
});
it('replies to yelling a question', function (): void {
$input = 'Are you OK?!';
$expected = Bob::RESPONSE_YELLING_QUESTION;
assertSame($expected, Bob::saySomethingTo($input)->getResponse());
});

View file

@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
use App\BowlingGame;
use Assert\Assert;
use Assert\AssertionFailedException;
beforeEach(function (): void {
$this->game = new BowlingGame();
});
it('counts an empty game', function (): void {
foreach (range(1, 20) as $roll) {
$this->game->roll(0);
}
assertSame(0, $this->game->getScore());
});
it('counts all single pins', function (): void {
foreach (range(1, 20) as $roll) {
$this->game->roll(1);
}
assertSame(20, $this->game->getScore());
});
it('adds a roll one bonus for a spare', function (): void {
$this->game->roll(5);
$this->game->roll(5); // Spare
$this->game->roll(2);
foreach (range(1, 17) as $roll) {
$this->game->roll(0);
}
assertSame(14, $this->game->getScore());
});
it('adds a two roll bonus for a strike', function (): void {
$this->game->roll(10); // Strike
$this->game->roll(3);
$this->game->roll(6);
foreach (range(1, 16) as $roll) {
$this->game->roll(0);
}
assertSame(28, $this->game->getScore());
});
it('adds an extra ball for a spare on the final frame', function (): void {
foreach (range(1, 18) as $roll) {
$this->game->roll(0);
}
$this->game->roll(7);
$this->game->roll(3); // Spare
$this->game->roll(5);
assertSame(15, $this->game->getScore());
});
it('adds an two extra balls for a strike on the final frame', function (): void {
foreach (range(1, 18) as $roll) {
$this->game->roll(0);
}
$this->game->roll(10); // Strike
$this->game->roll(4);
$this->game->roll(2);
assertSame(16, $this->game->getScore());
});
it('scores a perfect game', function (): void {
foreach (range(1, 12) as $roll) {
$this->game->roll(10);
}
assertSame(300, $this->game->getScore());
});
it('scores a normal game', function (): void {
$this->game->roll(4);
$this->game->roll(3);
$this->game->roll(10);
$this->game->roll(4);
$this->game->roll(1);
$this->game->roll(0);
$this->game->roll(2);
$this->game->roll(3);
$this->game->roll(7);
$this->game->roll(8);
$this->game->roll(1);
$this->game->roll(10);
$this->game->roll(0);
$this->game->roll(7);
$this->game->roll(1);
$this->game->roll(5);
$this->game->roll(10);
$this->game->roll(6);
$this->game->roll(1);
assertSame(103, $this->game->getScore());
});
it('cannot knock down a negative pins', function (): void {
$this->game->roll(-1);
})->throws(
AssertionFailedException::class,
'You cannot knock down a negative number of pins. Knocked down -1.'
);
it('cannot knock down more than 10 pins in a roll', function (): void {
$this->game->roll(12);
})->throws(
AssertionFailedException::class,
'You can only knock down a maximum of 10 pins in a roll. Knocked down 12.'
);

View file

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
use function App\flattenArray;
it('flattens an array', function (
array $input,
array $expected
): void {
$result = flattenArray($input);
assertSame($expected, $result);
})->with([
[
'input' => [1, 2, 3],
'expected' => [1, 2, 3],
],
[
'input' => [1, [2, 3, null, 4], [null], 5],
'expected' => [1, 2, 3, 4, 5],
],
[
'input' => [1, [2, [[3]], [4, [[5]]], 6, 7], 8],
'expected' => [1, 2, 3, 4, 5, 6, 7, 8],
],
[
'input' => [null, [[[null]]], null, null, [[null, null], null], null],
'expected' => [],
]
]);

View file

@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
use App\GradeSchool;
use Assert\AssertionFailedException;
beforeEach(function (): void {
$this->school = new GradeSchool();
});
it('gets all students', function (): void {
$this->school->addStudentToGrade('Anna', 1);
$this->school->addStudentToGrade('Jim', 2);
$this->school->addStudentToGrade('Charlie', 3);
$expected = [
['grade' => 1, 'students' => ['Anna']],
['grade' => 2, 'students' => ['Jim']],
['grade' => 3, 'students' => ['Charlie']],
];
assertSame($expected, $this->school->getAllStudents()->toArray());
});
it('can get students by grade', function (): void {
$this->school->addStudentToGrade('Jim', 2);
assertSame(['Jim'], $this->school->getStudentsByGrade(2)->toArray());
});
it('should order grades numerically', function (): void {
$this->school->addStudentToGrade('Anna', 1);
$this->school->addStudentToGrade('Charlie', 3);
$this->school->addStudentToGrade('Jim', 2);
$this->school->addStudentToGrade('Harry', 4);
$expected = [
['grade' => 1, 'students' => ['Anna']],
['grade' => 2, 'students' => ['Jim']],
['grade' => 3, 'students' => ['Charlie']],
['grade' => 4, 'students' => ['Harry']],
];
assertSame($expected, $this->school->getAllStudents()->toArray());
});
it('names within a grade are ordered alphabetically', function (): void {
$this->school->addStudentToGrade('Charlie', 1);
$this->school->addStudentToGrade('Barb', 1);
$this->school->addStudentToGrade('Anna', 1);
$this->school->addStudentToGrade('Peter', 2);
$this->school->addStudentToGrade('Alex', 2);
$this->school->addStudentToGrade('Zoe', 2);
$expected = [
[
'grade' => 1,
'students' => ['Anna', 'Barb', 'Charlie'],
],
[
'grade' => 2,
'students' => ['Alex', 'Peter', 'Zoe'],
]
];
assertSame($expected, $this->school->getAllStudents()->toArray());
});
test('name cannot be empty', function (): void {
$this->school->addStudentToGrade('', 1);
})->throws(
AssertionFailedException::class,
'Name cannot be blank'
);
test('grade cannot be zero or negative', function (int $grade): void {
$this->school->addStudentToGrade('Fred', $grade);
})->with([0, -1])->throws(
AssertionFailedException::class,
'Grade must be greater than or equal to 1'
);

View file

@ -0,0 +1,19 @@
<?php
use function App\isPangram;
it('determines if a string is a pangram', function (
string $input,
bool $expected
) {
assertSame($expected, isPangram($input));
})->with([
[
'input' => 'hello word',
'expected' => false,
],
[
'input' => 'The quick brown fox jumps over the lazy dog',
'expected' => true,
]
]);

View file

@ -0,0 +1,72 @@
<?php
use Assert\AssertionFailedException;
beforeEach(function () {
$this->game = new App\RockPaperScissorsGame();
});
it('throws an exception for an empty first move', function () {
$this->game->firstMove('');
})->throws(AssertionFailedException::class, 'You must specify a move.');
it('throws an exception for an empty second move', function () {
$this->game->secondMove('');
})->throws(AssertionFailedException::class, 'You must specify a move.');
it('throws an exception if an invalid first move is entered', function () {
$this->game->firstMove('banana');
})->throws(AssertionFailedException::class, 'You must enter a valid move. banana given.');
it('throws an exception if an invalid second move is entered', function () {
$this->game->secondMove('apple');
})->throws(AssertionFailedException::class, 'You must enter a valid move. apple given.');
it('returns the result of two different valid moves', function (
string $firstMove,
string $secondMove,
string $winner
) {
$this->game
->firstMove($firstMove)
->secondMove($secondMove);
assertSame($winner, $this->game->getWinner());
})->with([
[
'firstMove' => 'rock',
'secondMove' => 'paper',
'winner' => 'paper',
],
[
'firstMove' => 'paper',
'secondMove' => 'rock',
'winner' => 'paper',
],
[
'firstMove' => 'rock',
'secondMove' => 'scissors',
'winner' => 'scissors',
],
[
'firstMove' => 'scissors',
'secondMove' => 'rock',
'winner' => 'scissors',
],
[
'firstMove' => 'scissors',
'secondMove' => 'paper',
'winner' => 'scissors',
],
[
'firstMove' => 'paper',
'secondMove' => 'scissors',
'winner' => 'scissors',
],
]);
test('it returns null if there is a tie', function (string $move) {
$this->game->firstMove($move)->secondMove($move);
assertNull($this->game->getWinner());
})->with(['rock', 'paper', 'scissors']);

View file

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
use App\RomanNumeralsConverter;
use Assert\AssertionFailedException;
it('converts numbers to roman numerals', function (int $number, string $expected): void {
assertSame($expected, RomanNumeralsConverter::convert($number));
})->with([
[1, 'I'],
[2, 'II'],
[3, 'III'],
[4, 'IV'],
[5, 'V'],
[9, 'IX'],
[10, 'X'],
[15, 'XV'],
[19, 'XIX'],
[20, 'XX'],
[21, 'XXI'],
[40, 'XL'],
[50, 'L'],
[80, 'LXXX'],
[90, 'XC'],
[100, 'C'],
[110, 'CX'],
[400, 'CD'],
[500, 'D'],
[700, 'DCC'],
[900, 'CM'],
[1000, 'M'],
[1986, 'MCMLXXXVI'],
[1990, 'MCMXC'],
[2020, 'MMXX'],
]);
it('cannot convert negative numbers', function (): void {
RomanNumeralsConverter::convert(-1);
})->throws(AssertionFailedException::class, 'Cannot convert negative numbers');