fix: enforce immutable match state invariants

This commit is contained in:
Keith Solomon
2026-07-04 15:33:05 -05:00
parent 94560da5b4
commit 5297b41208
3 changed files with 407 additions and 2 deletions
+182
View File
@@ -0,0 +1,182 @@
<?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\UnitState;
use InvalidArgumentException;
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 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')],
'alpha',
actionLog: $actionLog,
);
}
private function unit(string $id, string $teamId): UnitState
{
return new UnitState($id, $teamId, new Position(0, 0), 10, 10, 4, 2, 4, 2);
}
}