From 5297b4120853172e6e12341408e159a0199d090b Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Sat, 4 Jul 2026 15:33:05 -0500 Subject: [PATCH] fix: enforce immutable match state invariants --- src/Domain/MatchState.php | 74 ++++++++++- tests/Unit/Domain/MatchStateTest.php | 182 +++++++++++++++++++++++++++ tests/Unit/Domain/UnitStateTest.php | 153 ++++++++++++++++++++++ 3 files changed, 407 insertions(+), 2 deletions(-) create mode 100644 tests/Unit/Domain/MatchStateTest.php create mode 100644 tests/Unit/Domain/UnitStateTest.php diff --git a/src/Domain/MatchState.php b/src/Domain/MatchState.php index 5542bac..48ed30b 100644 --- a/src/Domain/MatchState.php +++ b/src/Domain/MatchState.php @@ -20,15 +20,74 @@ final readonly class MatchState public ?string $winnerTeamId = null, public array $actionLog = [], ) { + if (!self::isList($units)) { + throw new InvalidArgumentException('Match units must be a list.'); + } + + if ($units === []) { + throw new InvalidArgumentException('Match must contain at least one unit.'); + } + $unitIds = []; + $teamIds = []; foreach ($units as $unit) { + if (!self::isUnitState($unit)) { + throw new InvalidArgumentException('Match units must contain only UnitState instances.'); + } + if (isset($unitIds[$unit->id])) { throw new InvalidArgumentException("Duplicate unit id: {$unit->id}."); } $unitIds[$unit->id] = true; + $teamIds[$unit->teamId] = true; } + + if ($activeTeamId === '') { + throw new InvalidArgumentException('Active team id cannot be empty.'); + } + + if (!isset($teamIds[$activeTeamId])) { + throw new InvalidArgumentException("Active team has no units: {$activeTeamId}."); + } + + if ($round < 1) { + throw new InvalidArgumentException('Match round must be at least 1.'); + } + + if (!self::isList($actionLog)) { + throw new InvalidArgumentException('Match action log must be a list.'); + } + + foreach ($actionLog as $entry) { + if (!self::isString($entry)) { + throw new InvalidArgumentException('Match action log entries must be strings.'); + } + } + + if ($winnerTeamId !== null && $winnerTeamId === '') { + throw new InvalidArgumentException('Winner team id cannot be empty.'); + } + + if ($winnerTeamId !== null && !isset($teamIds[$winnerTeamId])) { + throw new InvalidArgumentException("Winner team has no units: {$winnerTeamId}."); + } + } + + private static function isUnitState(mixed $unit): bool + { + return $unit instanceof UnitState; + } + + private static function isString(mixed $value): bool + { + return is_string($value); + } + + private static function isList(mixed $value): bool + { + return is_array($value) && array_is_list($value); } public function unit(string $id): UnitState @@ -57,6 +116,18 @@ final readonly class MatchState throw new InvalidArgumentException("Unknown unit id: {$replacement->id}."); } + public function withWinner(?string $winnerTeamId): self + { + return new self( + $this->battlefield, + $this->units, + $this->activeTeamId, + $this->round, + $winnerTeamId, + $this->actionLog, + ); + } + /** * @param list|null $units * @param list|null $actionLog @@ -65,7 +136,6 @@ final readonly class MatchState ?array $units = null, ?string $activeTeamId = null, ?int $round = null, - ?string $winnerTeamId = null, ?array $actionLog = null, ): self { return new self( @@ -73,7 +143,7 @@ final readonly class MatchState $units ?? $this->units, $activeTeamId ?? $this->activeTeamId, $round ?? $this->round, - $winnerTeamId ?? $this->winnerTeamId, + $this->winnerTeamId, $actionLog ?? $this->actionLog, ); } diff --git a/tests/Unit/Domain/MatchStateTest.php b/tests/Unit/Domain/MatchStateTest.php new file mode 100644 index 0000000..27eb949 --- /dev/null +++ b/tests/Unit/Domain/MatchStateTest.php @@ -0,0 +1,182 @@ +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 $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); + } +} diff --git a/tests/Unit/Domain/UnitStateTest.php b/tests/Unit/Domain/UnitStateTest.php new file mode 100644 index 0000000..0bf48f9 --- /dev/null +++ b/tests/Unit/Domain/UnitStateTest.php @@ -0,0 +1,153 @@ +expectException(InvalidArgumentException::class); + + $createUnit(); + } + + /** + * @return iterable + */ + public static function invalidUnitProvider(): iterable + { + yield 'empty unit id' => [ + static fn (): UnitState => new UnitState('', 'alpha', new Position(0, 0), 10, 10, 4, 2, 4, 2), + ]; + yield 'empty team id' => [ + static fn (): UnitState => new UnitState('unit-1', '', new Position(0, 0), 10, 10, 4, 2, 4, 2), + ]; + yield 'zero maximum health' => [ + static fn (): UnitState => new UnitState('unit-1', 'alpha', new Position(0, 0), 0, 0, 4, 2, 4, 2), + ]; + yield 'negative health' => [ + static fn (): UnitState => new UnitState('unit-1', 'alpha', new Position(0, 0), 10, -1, 4, 2, 4, 2), + ]; + yield 'health above maximum' => [ + static fn (): UnitState => new UnitState('unit-1', 'alpha', new Position(0, 0), 10, 11, 4, 2, 4, 2), + ]; + yield 'negative attack' => [ + static fn (): UnitState => new UnitState('unit-1', 'alpha', new Position(0, 0), 10, 10, -1, 2, 4, 2), + ]; + yield 'negative defense' => [ + static fn (): UnitState => new UnitState('unit-1', 'alpha', new Position(0, 0), 10, 10, 4, -1, 4, 2), + ]; + yield 'zero speed' => [ + static fn (): UnitState => new UnitState('unit-1', 'alpha', new Position(0, 0), 10, 10, 4, 2, 0, 2), + ]; + yield 'negative actions remaining' => [ + static fn (): UnitState => new UnitState('unit-1', 'alpha', new Position(0, 0), 10, 10, 4, 2, 4, -1), + ]; + yield 'too many actions remaining' => [ + static fn (): UnitState => new UnitState('unit-1', 'alpha', new Position(0, 0), 10, 10, 4, 2, 4, 3), + ]; + } + + public function testItReportsWhetherItIsDefeated(): void + { + self::assertTrue($this->unit(health: 0)->isDefeated()); + self::assertFalse($this->unit(health: 1)->isDefeated()); + } + + public function testMovingReturnsAChangedCopy(): void + { + $unit = $this->unit(); + $moved = $unit->moveTo(new Position(2, 3)); + + self::assertNotSame($unit, $moved); + self::assertSame('0:0', $unit->position->key()); + self::assertSame('2:3', $moved->position->key()); + } + + public function testSpendingAnActionReturnsAChangedCopyAndRejectsExhaustedUnits(): void + { + $unit = $this->unit(actionsRemaining: 1); + $spent = $unit->spendAction(); + + self::assertSame(1, $unit->actionsRemaining); + self::assertSame(0, $spent->actionsRemaining); + + $this->expectException(InvalidArgumentException::class); + $spent->spendAction(); + } + + public function testDamageCannotReduceHealthBelowZero(): void + { + $unit = $this->unit(health: 3); + $damaged = $unit->takeDamage(5); + + self::assertSame(3, $unit->health); + self::assertSame(0, $damaged->health); + } + + public function testNegativeDamageIsANoOp(): void + { + $unit = $this->unit(health: 7); + $damaged = $unit->takeDamage(-3); + + self::assertNotSame($unit, $damaged); + self::assertSame(7, $damaged->health); + } + + public function testMarkingAttackedReturnsAChangedCopy(): void + { + $unit = $this->unit(); + $attacked = $unit->markAttacked(); + + self::assertFalse($unit->hasAttacked); + self::assertTrue($attacked->hasAttacked); + } + + public function testStartingTurnResetsALivingUnit(): void + { + $unit = $this->unit(actionsRemaining: 0, hasAttacked: true); + $started = $unit->startTurn(); + + self::assertNotSame($unit, $started); + self::assertSame(2, $started->actionsRemaining); + self::assertFalse($started->hasAttacked); + } + + public function testStartingTurnReturnsTheSameDefeatedUnit(): void + { + $unit = $this->unit(health: 0, actionsRemaining: 0, hasAttacked: true); + + self::assertSame($unit, $unit->startTurn()); + } + + private function unit( + int $health = 10, + int $actionsRemaining = 2, + bool $hasAttacked = false, + ): UnitState { + return new UnitState( + 'unit-1', + 'alpha', + new Position(0, 0), + 10, + $health, + 4, + 2, + 4, + $actionsRemaining, + $hasAttacked, + ); + } +}