252 lines
7.8 KiB
PHP
252 lines
7.8 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace BattleForge\Tests\Unit\Domain;
|
|
|
|
use BattleForge\Domain\Battlefield;
|
|
use BattleForge\Domain\MatchState;
|
|
use BattleForge\Domain\Position;
|
|
use BattleForge\Domain\Terrain;
|
|
use BattleForge\Domain\UnitState;
|
|
use InvalidArgumentException;
|
|
use PHPUnit\Framework\Attributes\DataProvider;
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
final class MatchStateTest extends TestCase
|
|
{
|
|
public function testItRejectsDuplicateUnitIds(): void
|
|
{
|
|
$this->expectException(InvalidArgumentException::class);
|
|
|
|
new MatchState(
|
|
new Battlefield(8, 8),
|
|
[$this->unit('unit-1', 'alpha'), $this->unit('unit-1', 'bravo')],
|
|
'alpha',
|
|
);
|
|
}
|
|
|
|
public function testItRejectsAUnitOutsideTheBattlefield(): void
|
|
{
|
|
$this->expectException(InvalidArgumentException::class);
|
|
$this->expectExceptionMessage('Unit alpha-1 is outside the battlefield.');
|
|
|
|
new MatchState(
|
|
new Battlefield(8, 8),
|
|
[$this->unit('alpha-1', 'alpha', new Position(8, 0))],
|
|
'alpha',
|
|
);
|
|
}
|
|
|
|
#[DataProvider('impassableTerrainProvider')]
|
|
public function testItRejectsALivingUnitOnImpassableTerrain(Terrain $terrain): void
|
|
{
|
|
$this->expectException(InvalidArgumentException::class);
|
|
$this->expectExceptionMessage('Living unit alpha-1 must stand on passable terrain.');
|
|
|
|
new MatchState(
|
|
new Battlefield(8, 8, ['0:0' => $terrain]),
|
|
[$this->unit('alpha-1', 'alpha')],
|
|
'alpha',
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @return iterable<string, array{Terrain}>
|
|
*/
|
|
public static function impassableTerrainProvider(): iterable
|
|
{
|
|
yield 'blocking' => [Terrain::Blocking];
|
|
}
|
|
|
|
public function testItRejectsLivingUnitsAtTheSamePosition(): void
|
|
{
|
|
$this->expectException(InvalidArgumentException::class);
|
|
$this->expectExceptionMessage('Living units cannot share position 0:0.');
|
|
|
|
new MatchState(
|
|
new Battlefield(8, 8),
|
|
[$this->unit('alpha-1', 'alpha'), $this->unit('bravo-1', 'bravo')],
|
|
'alpha',
|
|
);
|
|
}
|
|
|
|
public function testItAcceptsLivingAndDefeatedUnitsAtTheSamePosition(): void
|
|
{
|
|
$match = new MatchState(
|
|
new Battlefield(8, 8),
|
|
[
|
|
$this->unit('alpha-1', 'alpha'),
|
|
$this->unit('bravo-1', 'bravo', health: 0),
|
|
],
|
|
'alpha',
|
|
);
|
|
|
|
self::assertSame('0:0', $match->unit('alpha-1')->position->key());
|
|
self::assertSame('0:0', $match->unit('bravo-1')->position->key());
|
|
}
|
|
|
|
public function testItRejectsAnUnknownUnitLookup(): void
|
|
{
|
|
$match = $this->match();
|
|
|
|
$this->expectException(InvalidArgumentException::class);
|
|
$match->unit('unknown');
|
|
}
|
|
|
|
public function testItRejectsAnUnknownReplacement(): void
|
|
{
|
|
$match = $this->match();
|
|
|
|
$this->expectException(InvalidArgumentException::class);
|
|
$match->withUnit($this->unit('unknown', 'alpha'));
|
|
}
|
|
|
|
public function testReplacingAUnitDoesNotMutateTheMatch(): void
|
|
{
|
|
$match = $this->match();
|
|
$replacement = $match->unit('alpha-1')->moveTo(new Position(2, 0));
|
|
$replaced = $match->withUnit($replacement);
|
|
|
|
self::assertSame('0:0', $match->unit('alpha-1')->position->key());
|
|
self::assertSame('2:0', $replaced->unit('alpha-1')->position->key());
|
|
}
|
|
|
|
public function testCopyReplacesMutableMatchProgressIncludingAnEmptyLog(): void
|
|
{
|
|
$match = $this->match(actionLog: ['started']);
|
|
$copied = $match->copy(activeTeamId: 'bravo', round: 2, actionLog: []);
|
|
|
|
self::assertSame('alpha', $match->activeTeamId);
|
|
self::assertSame(1, $match->round);
|
|
self::assertSame(['started'], $match->actionLog);
|
|
self::assertSame('bravo', $copied->activeTeamId);
|
|
self::assertSame(2, $copied->round);
|
|
self::assertSame([], $copied->actionLog);
|
|
}
|
|
|
|
public function testWinnerCanBeSetAndClearedWithoutChangingOtherState(): void
|
|
{
|
|
$match = $this->match(actionLog: ['started']);
|
|
$won = $match->withWinner('alpha');
|
|
$cleared = $won->withWinner(null);
|
|
|
|
self::assertSame('alpha', $won->winnerTeamId);
|
|
self::assertNull($cleared->winnerTeamId);
|
|
self::assertSame($match->battlefield, $cleared->battlefield);
|
|
self::assertSame($match->units, $cleared->units);
|
|
self::assertSame($match->activeTeamId, $cleared->activeTeamId);
|
|
self::assertSame($match->round, $cleared->round);
|
|
self::assertSame($match->actionLog, $cleared->actionLog);
|
|
}
|
|
|
|
public function testItRejectsANonListUnitsArray(): void
|
|
{
|
|
$this->expectException(InvalidArgumentException::class);
|
|
|
|
// @phpstan-ignore argument.type
|
|
new MatchState(new Battlefield(8, 8), ['first' => $this->unit('alpha-1', 'alpha')], 'alpha');
|
|
}
|
|
|
|
public function testItRejectsAnEmptyUnitsArray(): void
|
|
{
|
|
$this->expectException(InvalidArgumentException::class);
|
|
|
|
new MatchState(new Battlefield(8, 8), [], 'alpha');
|
|
}
|
|
|
|
public function testItRejectsANonUnitMember(): void
|
|
{
|
|
$this->expectException(InvalidArgumentException::class);
|
|
|
|
// @phpstan-ignore argument.type
|
|
new MatchState(new Battlefield(8, 8), ['not-a-unit'], 'alpha');
|
|
}
|
|
|
|
public function testItRejectsAnEmptyActiveTeam(): void
|
|
{
|
|
$this->expectException(InvalidArgumentException::class);
|
|
|
|
new MatchState(new Battlefield(8, 8), [$this->unit('alpha-1', 'alpha')], '');
|
|
}
|
|
|
|
public function testItRejectsAnActiveTeamWithoutAUnit(): void
|
|
{
|
|
$this->expectException(InvalidArgumentException::class);
|
|
|
|
new MatchState(new Battlefield(8, 8), [$this->unit('alpha-1', 'alpha')], 'bravo');
|
|
}
|
|
|
|
public function testItRejectsARoundBelowOne(): void
|
|
{
|
|
$this->expectException(InvalidArgumentException::class);
|
|
|
|
new MatchState(new Battlefield(8, 8), [$this->unit('alpha-1', 'alpha')], 'alpha', 0);
|
|
}
|
|
|
|
public function testItRejectsANonListActionLog(): void
|
|
{
|
|
$this->expectException(InvalidArgumentException::class);
|
|
|
|
new MatchState(
|
|
new Battlefield(8, 8),
|
|
[$this->unit('alpha-1', 'alpha')],
|
|
'alpha',
|
|
// @phpstan-ignore argument.type
|
|
actionLog: ['first' => 'started'],
|
|
);
|
|
}
|
|
|
|
public function testItRejectsANonStringActionLogEntry(): void
|
|
{
|
|
$this->expectException(InvalidArgumentException::class);
|
|
|
|
// @phpstan-ignore argument.type
|
|
new MatchState(new Battlefield(8, 8), [$this->unit('alpha-1', 'alpha')], 'alpha', actionLog: [1]);
|
|
}
|
|
|
|
public function testItRejectsAnEmptyWinnerTeam(): void
|
|
{
|
|
$this->expectException(InvalidArgumentException::class);
|
|
|
|
new MatchState(new Battlefield(8, 8), [$this->unit('alpha-1', 'alpha')], 'alpha', winnerTeamId: '');
|
|
}
|
|
|
|
public function testItRejectsAWinnerWithoutAUnit(): void
|
|
{
|
|
$this->expectException(InvalidArgumentException::class);
|
|
|
|
new MatchState(
|
|
new Battlefield(8, 8),
|
|
[$this->unit('alpha-1', 'alpha')],
|
|
'alpha',
|
|
winnerTeamId: 'bravo',
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param list<string> $actionLog
|
|
*/
|
|
private function match(array $actionLog = []): MatchState
|
|
{
|
|
return new MatchState(
|
|
new Battlefield(8, 8),
|
|
[
|
|
$this->unit('alpha-1', 'alpha'),
|
|
$this->unit('bravo-1', 'bravo', new Position(7, 7)),
|
|
],
|
|
'alpha',
|
|
actionLog: $actionLog,
|
|
);
|
|
}
|
|
|
|
private function unit(
|
|
string $id,
|
|
string $teamId,
|
|
?Position $position = null,
|
|
int $health = 10,
|
|
): UnitState {
|
|
return new UnitState($id, $teamId, $position ?? new Position(0, 0), 10, $health, 4, 2, 4, 2);
|
|
}
|
|
}
|