diff --git a/app/Console/Commands/GeneratePdf.php b/app/Console/Commands/GeneratePdf.php index b1700d5..bffe376 100644 --- a/app/Console/Commands/GeneratePdf.php +++ b/app/Console/Commands/GeneratePdf.php @@ -16,8 +16,7 @@ class GeneratePdf extends Command { public function handle(ScoresheetGenerator $generator): void { - /** @var Game $game */ - $game = Game::query()->first(); + $game = Game::current(); $pdf = $generator->generate($game); diff --git a/app/Enums/MatchPhase.php b/app/Enums/MatchPhase.php new file mode 100644 index 0000000..0e3a588 --- /dev/null +++ b/app/Enums/MatchPhase.php @@ -0,0 +1,38 @@ + true, + self::InProgress, self::Completed, self::PdfGenerated => false, + }; + } + + public function allowsGameplayRecording(): bool + { + return match ($this) { + self::Ready, self::InProgress => true, + self::Setup, self::Completed, self::PdfGenerated => false, + }; + } + + public function allowsPdfGeneration(): bool + { + return match ($this) { + self::Completed, self::PdfGenerated => true, + self::Setup, self::Ready, self::InProgress => false, + }; + } +} diff --git a/app/Jobs/RecalculateGameStateSnapshots.php b/app/Jobs/RecalculateGameStateSnapshots.php index 03f21d7..a5371a5 100644 --- a/app/Jobs/RecalculateGameStateSnapshots.php +++ b/app/Jobs/RecalculateGameStateSnapshots.php @@ -4,7 +4,7 @@ namespace App\Jobs; -use App\Models\Game; +use App\Services\CurrentMatchResolver; use App\Services\GameState\GameStateRecalculator; use Carbon\CarbonImmutable; use Illuminate\Contracts\Queue\ShouldQueue; @@ -15,22 +15,18 @@ class RecalculateGameStateSnapshots implements ShouldQueue use Queueable; public function __construct( - public int $gameId, public ?string $upTo = null, ) {} /** * Execute the job. */ - public function handle(GameStateRecalculator $recalculator): void + public function handle(GameStateRecalculator $recalculator, CurrentMatchResolver $currentMatchResolver): void { - /** @var Game $game */ - $game = Game::query()->findOrFail($this->gameId); - $upTo = $this->upTo === null ? null : CarbonImmutable::parse($this->upTo); - $recalculator->recalculate($game, $upTo); + $recalculator->recalculate($currentMatchResolver->currentOrFail(), $upTo); } } diff --git a/app/Livewire/Court.php b/app/Livewire/Court.php index 70f422f..c0faba6 100644 --- a/app/Livewire/Court.php +++ b/app/Livewire/Court.php @@ -15,6 +15,7 @@ use App\Models\GameEvent; use App\Models\Player; use App\Models\Staff; +use App\Services\CurrentMatchResolver; use App\Services\GameSideResolver; use Flux\Flux; use Illuminate\Contracts\View\View; @@ -23,9 +24,6 @@ class Court extends Component { - #[Reactive] - public ?int $gameId = null; - #[Reactive] public ?GameState $gameState = null; @@ -47,11 +45,6 @@ class Court extends Component public ?string $pendingMisconductSubjectLabel = null; - public function mount(?int $gameId = null): void - { - $this->gameId = $gameId; - } - public function render(): View { return view('livewire.court', $this->courtContext()); @@ -101,12 +94,17 @@ public function selectMisconductSubject(string $subjectType, int $subjectId): vo public function recordPendingDelayWarning(): void { - if ($this->pendingDelayWarningTeam === null || $this->gameId === null) { + if ($this->pendingDelayWarningTeam === null) { return; } $team = TeamAB::from($this->pendingDelayWarningTeam); - $game = Game::query()->findSole($this->gameId); + $game = $this->activeGame(); + + if ($game === null) { + return; + } + $game->recordDelayWarning($team); $this->pendingDelayWarningTeam = null; @@ -121,12 +119,17 @@ public function recordPendingDelayWarning(): void public function recordPendingDelayPenalty(): void { - if ($this->pendingDelayPenaltyTeam === null || $this->gameId === null) { + if ($this->pendingDelayPenaltyTeam === null) { return; } $team = TeamAB::from($this->pendingDelayPenaltyTeam); - $game = Game::query()->findSole($this->gameId); + $game = $this->activeGame(); + + if ($game === null) { + return; + } + $game->recordDelayPenalty($team); $this->pendingDelayPenaltyTeam = null; @@ -146,7 +149,6 @@ public function recordPendingMisconduct(): void || $this->pendingMisconductSanction === null || $this->pendingMisconductSubjectType === null || $this->pendingMisconductSubjectId === null - || $this->gameId === null ) { return; } @@ -154,7 +156,11 @@ public function recordPendingMisconduct(): void $team = TeamAB::from($this->pendingMisconductTeam); $sanction = MisconductSanction::from($this->pendingMisconductSanction); $subjectType = MisconductSubjectType::from($this->pendingMisconductSubjectType); - $game = Game::query()->findSole($this->gameId); + $game = $this->activeGame(); + + if ($game === null) { + return; + } $game->recordMisconduct($team, $subjectType, $this->pendingMisconductSubjectId, $sanction); @@ -253,11 +259,7 @@ private function isBeforeInitialToss(GameState $state, ?Game $game): bool private function activeGame(): ?Game { - if ($this->gameId === null) { - return null; - } - - return Game::query()->whereKey($this->gameId)->first(); + return app(CurrentMatchResolver::class)->current(); } private function gameSideResolver(): GameSideResolver @@ -292,7 +294,7 @@ private function pendingMisconductSubjects(): array return [ 'players' => $players - ->orderByPivot('number') + ->orderBy('number') ->get() ->map(function (Player $player) use ($game, $team, $sanction): array { $recordedSanction = $sanction === null @@ -309,7 +311,7 @@ private function pendingMisconductSubjects(): array ]; }) ->all(), - 'staff' => $this->staffMisconductSubjects($staff->orderByPivot('id')->get()->all(), $game, $team, $sanction), + 'staff' => $this->staffMisconductSubjects($staff->orderBy('id')->get()->all(), $game, $team, $sanction), ]; } diff --git a/app/Livewire/Game.php b/app/Livewire/Game.php index 991408e..ce6c5ef 100644 --- a/app/Livewire/Game.php +++ b/app/Livewire/Game.php @@ -8,6 +8,7 @@ use App\Enums\TeamAB; use App\Enums\TeamSide; use App\Models\Game as GameModel; +use App\Services\CurrentMatchResolver; use App\Services\GameSideResolver; use Flux\Flux; use Illuminate\Contracts\View\View; @@ -16,8 +17,6 @@ class Game extends Component { - public int $gameId; - public GameState $gameState; public ?int $justEndedSetNumber = null; @@ -30,15 +29,34 @@ class Game extends Component public bool $showFifthSetSideChangePrompt = false; - public function mount(GameModel $game): void + public function mount(CurrentMatchResolver $currentMatchResolver): void { - $this->gameId = $game->getKey(); + $resolvedGame = $currentMatchResolver->current(); + + if ($resolvedGame === null) { + $this->redirectRoute('match.setup', navigate: true); + + return; + } + + if (! $currentMatchResolver->isSetupComplete($resolvedGame)) { + $this->redirectRoute('match.setup', navigate: true); + + return; + } + $this->synchronizeGameContext(); } #[On('game-event-recorded')] public function synchronizeGameContext(): void { + $game = $this->activeGame(); + + if ($game === null) { + return; + } + $wasSetInProgress = isset($this->gameState) && $this->gameState->setInProgress; $previousSetNumber = isset($this->gameState) ? $this->gameState->setNumber : 0; $previousSetsWonTeamA = isset($this->gameState) ? $this->gameState->setsWonTeamA : 0; @@ -46,7 +64,6 @@ public function synchronizeGameContext(): void $previousScoreTeamB = isset($this->gameState) ? $this->gameState->scoreTeamB : 0; $previousShowFifthSetSideChangePrompt = $this->showFifthSetSideChangePrompt; - $game = GameModel::query()->findSole($this->gameId); $this->gameState = $game->stateAt(); $this->showFifthSetSideChangePrompt = $this->gameSideResolver()->shouldPromptForFifthSetSideSwap($this->gameState); @@ -80,7 +97,12 @@ public function acknowledgeSetEnd(): void public function acknowledgeFifthSetSideChange(): void { - $game = GameModel::query()->findSole($this->gameId); + $game = $this->activeGame(); + + if ($game === null) { + return; + } + $game->recordCourtSidesSwapped(); $this->synchronizeGameContext(); $this->dispatch('game-event-recorded'); @@ -117,4 +139,9 @@ private function isBeforeInitialToss(): bool return ! $this->gameSideResolver()->hasRequiredToss($this->gameState) && ! $this->gameSideResolver()->requiresFifthSetToss($this->gameState); } + + private function activeGame(): ?GameModel + { + return app(CurrentMatchResolver::class)->current(); + } } diff --git a/app/Livewire/LineupSubmission.php b/app/Livewire/LineupSubmission.php index 610ceb1..ef74412 100644 --- a/app/Livewire/LineupSubmission.php +++ b/app/Livewire/LineupSubmission.php @@ -9,22 +9,18 @@ use App\Enums\TeamSide; use App\Exceptions\InvalidGameEventTransition; use App\Models\Game; +use App\Services\CurrentMatchResolver; use App\Services\GameSideResolver; use Flux\Flux; use Illuminate\Contracts\View\View; use Illuminate\Support\Facades\Validator; use InvalidArgumentException; use Livewire\Attributes\Computed; -use Livewire\Attributes\Locked; use Livewire\Attributes\Reactive; use Livewire\Component; class LineupSubmission extends Component { - #[Reactive] - #[Locked] - public int $gameId; - #[Reactive] public TeamAB $team = TeamAB::TeamA; @@ -37,12 +33,9 @@ class LineupSubmission extends Component /** @var array */ public array $lineup = []; - public function mount(TeamAB $team, ?int $gameId = null, string $courtSide = 'left'): void + public function mount(TeamAB $team, string $courtSide = 'left'): void { - abort_if(is_null($gameId), 404); - $this->team = $team; - $this->gameId = $gameId; $this->courtSide = $courtSide; $this->lineup = $this->defaultLineup(); } @@ -116,7 +109,7 @@ public function render(): View #[Computed] public function activeGame(): ?Game { - return Game::query()->whereKey($this->gameId)->first(); + return app(CurrentMatchResolver::class)->current(); } /** @@ -170,8 +163,8 @@ private function eligibleRosterNumbers(Game $game): array } $rosterNumbers = $teamSide === TeamSide::Home - ? $game->homePlayers()->wherePivot('is_libero', false)->orderByPivot('number')->pluck('game_player.number') - : $game->awayPlayers()->wherePivot('is_libero', false)->orderByPivot('number')->pluck('game_player.number'); + ? $game->homePlayers()->where('is_libero', false)->orderBy('number')->pluck('players.number') + : $game->awayPlayers()->where('is_libero', false)->orderBy('number')->pluck('players.number'); return $rosterNumbers ->map(fn (mixed $number): int => (int) $number) diff --git a/app/Livewire/MatchSetup.php b/app/Livewire/MatchSetup.php new file mode 100644 index 0000000..537884e --- /dev/null +++ b/app/Livewire/MatchSetup.php @@ -0,0 +1,790 @@ + */ + public array $homePlayerRows = []; + + /** @var array */ + public array $awayPlayerRows = []; + + public string $homeCaptainSelection = ''; + + public string $awayCaptainSelection = ''; + + /** @var array */ + public array $homeStaffRows = []; + + /** @var array */ + public array $awayStaffRows = []; + + /** @var array */ + public array $officialRows = []; + + public function mount(CurrentMatchResolver $currentMatchResolver): void + { + $this->synchronizeState($currentMatchResolver->current()); + } + + public function createMatch(): void + { + if ($this->game !== null) { + $this->synchronizeState($this->game->fresh(['homeTeam', 'awayTeam', 'officials'])); + + return; + } + + $this->synchronizeState(Game::ensureSingleton()); + } + + public function openStep(string $step): void + { + if (! in_array($step, ['match-details', 'rosters', 'officials', 'ready'], true)) { + return; + } + + if ($this->game === null) { + return; + } + + if ($this->game->setupLocked()) { + $this->step = 'ready'; + + return; + } + + $requiredStep = $this->resolver()->nextStep($this->game); + $availableSteps = ['match-details']; + + if ($this->game->hasCompleteMatchDetails()) { + $availableSteps[] = 'rosters'; + } + + if ($this->game->hasSubmittedInitialRosters()) { + $availableSteps[] = 'officials'; + } + + if ($this->resolver()->isSetupComplete($this->game)) { + $availableSteps[] = 'ready'; + } + + if (in_array($step, $availableSteps, true)) { + $this->step = $step; + + return; + } + + $this->step = $requiredStep; + } + + public function addPlayerRow(string $side): void + { + if ($this->setupLocked()) { + return; + } + + if ($side === 'home') { + $this->homePlayerRows[] = $this->emptyPlayerRow(); + + return; + } + + if ($side === 'away') { + $this->awayPlayerRows[] = $this->emptyPlayerRow(); + } + } + + public function removePlayerRow(string $side, int $index): void + { + if ($this->setupLocked()) { + return; + } + + if ($side === 'home') { + if (count($this->homePlayerRows) <= 6) { + return; + } + + $this->adjustCaptainSelectionAfterRowRemoval('home', $index); + unset($this->homePlayerRows[$index]); + $this->homePlayerRows = array_values($this->homePlayerRows); + + return; + } + + if ($side === 'away') { + if (count($this->awayPlayerRows) <= 6) { + return; + } + + $this->adjustCaptainSelectionAfterRowRemoval('away', $index); + unset($this->awayPlayerRows[$index]); + $this->awayPlayerRows = array_values($this->awayPlayerRows); + } + } + + public function saveMatchDetails(): void + { + $game = $this->activeGameForSetup(); + + if ($game === null) { + return; + } + + if (! $this->ensureSetupEditable($game)) { + + return; + } + + $validated = $this->validate([ + 'matchNumber' => ['required', 'integer', 'min:1', 'max:99'], + 'matchCountryCode' => ['required', 'regex:/^[A-Za-z]{3}$/'], + 'city' => ['required', 'string', 'max:255'], + 'hall' => ['required', 'string', 'max:255'], + 'matchDateTime' => ['required', 'date'], + 'division' => ['required', 'in:Men,Women'], + 'pool' => ['required', 'string', 'max:255'], + 'category' => ['required', 'in:Senior,Junior,Youth'], + 'homeTeamName' => ['required', 'string', 'max:255'], + 'homeTeamCountryCode' => ['required', 'regex:/^[A-Za-z]{3}$/'], + 'awayTeamName' => ['required', 'string', 'max:255'], + 'awayTeamCountryCode' => ['required', 'regex:/^[A-Za-z]{3}$/'], + ]); + + $game->homeTeam->forceFill([ + 'name' => trim((string) $validated['homeTeamName']), + 'country_code' => $this->normalizeCountryCode($validated['homeTeamCountryCode']), + ])->save(); + + $game->awayTeam->forceFill([ + 'name' => trim((string) $validated['awayTeamName']), + 'country_code' => $this->normalizeCountryCode($validated['awayTeamCountryCode']), + ])->save(); + + $game->forceFill([ + 'number' => (int) $validated['matchNumber'], + 'country_code' => $this->normalizeCountryCode($validated['matchCountryCode']), + 'city' => trim((string) $validated['city']), + 'hall' => trim((string) $validated['hall']), + 'date_time' => $validated['matchDateTime'], + 'division' => $validated['division'], + 'pool' => trim((string) $validated['pool']), + 'category' => $validated['category'], + ])->save(); + $game->synchronizeStatus(); + + $this->resetValidation(); + $this->synchronizeState($game->fresh(['homeTeam', 'awayTeam', 'officials'])); + $this->step = 'rosters'; + } + + public function saveRosters(): void + { + $game = $this->activeGameForSetup(); + + if ($game === null) { + return; + } + + if (! $this->ensureSetupEditable($game)) { + + return; + } + + $this->resetValidation(); + + $homePlayers = $this->validatedPlayerRows($this->homePlayerRows, 'homePlayerRows', $this->homeCaptainSelection); + $awayPlayers = $this->validatedPlayerRows($this->awayPlayerRows, 'awayPlayerRows', $this->awayCaptainSelection); + $homeStaff = $this->validatedStaffRows($this->homeStaffRows, 'homeStaffRows'); + $awayStaff = $this->validatedStaffRows($this->awayStaffRows, 'awayStaffRows'); + + if ($this->getErrorBag()->isNotEmpty()) { + return; + } + + DB::transaction(function () use ($game, $homePlayers, $awayPlayers, $homeStaff, $awayStaff): void { + $this->syncTeamRoster($game, $game->homeTeam, TeamAB::TeamA, $homePlayers, $homeStaff); + $this->syncTeamRoster($game, $game->awayTeam, TeamAB::TeamB, $awayPlayers, $awayStaff); + $game->markRostersSubmitted(); + }); + + $this->synchronizeState($game->fresh(['homeTeam', 'awayTeam', 'officials'])); + $this->step = 'officials'; + } + + public function saveOfficials(): void + { + $game = $this->activeGameForSetup(); + + if ($game === null) { + return; + } + + if (! $this->ensureSetupEditable($game)) { + + return; + } + + $this->resetValidation(); + + $officials = $this->validatedOfficialRows(); + + if ($this->getErrorBag()->isNotEmpty()) { + return; + } + + $game->replaceOfficials($officials); + + $this->synchronizeState($game->fresh(['homeTeam', 'awayTeam', 'officials'])); + $this->step = 'ready'; + } + + public function render(): View + { + return view('livewire.match-setup', [ + 'competitionName' => $this->game?->competitionName() ?? Config::string('competition.name'), + 'currentRequiredStep' => $this->resolver()->nextStep($this->game), + 'isSetupComplete' => $this->game !== null && $this->resolver()->isSetupComplete($this->game), + 'isSetupLocked' => $this->setupLocked(), + 'setupSteps' => [ + 'match-details' => 'Match details', + 'rosters' => 'Team rosters', + 'officials' => 'Officials', + 'ready' => 'Ready state', + ], + ]); + } + + private function synchronizeState(?Game $game): void + { + $this->game = $game; + + if ($game === null) { + $this->step = 'missing-match'; + $this->resetFormState(); + + return; + } + + $game->loadMissing(['homeTeam', 'awayTeam', 'officials']); + + $this->matchNumber = (string) $game->number; + $this->matchCountryCode = $game->country_code; + $this->city = $game->city; + $this->hall = $game->hall; + $this->matchDateTime = $game->date_time->format('Y-m-d\TH:i'); + $this->division = $game->division === '' ? 'Men' : $game->division; + $this->pool = $game->pool; + $this->category = $game->category === '' ? 'Senior' : $game->category; + $this->homeTeamName = $game->homeTeam->name; + $this->homeTeamCountryCode = $game->homeTeam->country_code; + $this->awayTeamName = $game->awayTeam->name; + $this->awayTeamCountryCode = $game->awayTeam->country_code; + $this->homePlayerRows = $this->playerRowsForTeam($game->homeTeam, $game->homePlayers()->get()); + $this->awayPlayerRows = $this->playerRowsForTeam($game->awayTeam, $game->awayPlayers()->get()); + $this->homeCaptainSelection = $this->captainSelectionForRows($this->homePlayerRows); + $this->awayCaptainSelection = $this->captainSelectionForRows($this->awayPlayerRows); + $this->homeStaffRows = $this->staffRowsForTeam($game->homeTeam, $game->homeStaff()->get()); + $this->awayStaffRows = $this->staffRowsForTeam($game->awayTeam, $game->awayStaff()->get()); + $this->officialRows = $this->officialRowsForGame($game); + $this->step = $game->setupLocked() + ? 'ready' + : $this->resolver()->nextStep($game); + } + + private function resetFormState(): void + { + $this->matchNumber = ''; + $this->matchCountryCode = ''; + $this->city = ''; + $this->hall = ''; + $this->matchDateTime = ''; + $this->division = 'Men'; + $this->pool = ''; + $this->category = 'Senior'; + $this->homeTeamName = ''; + $this->homeTeamCountryCode = ''; + $this->awayTeamName = ''; + $this->awayTeamCountryCode = ''; + $this->homePlayerRows = $this->emptyPlayerRows(); + $this->awayPlayerRows = $this->emptyPlayerRows(); + $this->homeCaptainSelection = ''; + $this->awayCaptainSelection = ''; + $this->homeStaffRows = $this->emptyStaffRows(); + $this->awayStaffRows = $this->emptyStaffRows(); + $this->officialRows = $this->emptyOfficialRows(); + } + + /** + * @param array $players + * @param array $staffRows + */ + private function syncTeamRoster(Game $game, Team $team, TeamAB $teamAb, array $players, array $staffRows): void + { + Player::query()->where('team_id', $team->getKey())->delete(); + Staff::query()->where('team_id', $team->getKey())->delete(); + + $createdPlayers = []; + + foreach ($players as $playerRow) { + $player = $team->players()->create([ + 'game_id' => $game->getKey(), + 'first_name' => $playerRow['first_name'], + 'last_name' => $playerRow['last_name'], + ]); + + $createdPlayers[] = [ + 'player' => $player, + 'number' => $playerRow['number'], + 'is_captain' => $playerRow['is_captain'], + 'is_libero' => $playerRow['is_libero'], + ]; + } + + $createdStaff = []; + + foreach ($staffRows as $staffRow) { + $createdStaff[] = $team->staff()->create([ + 'game_id' => $game->getKey(), + 'first_name' => $staffRow['first_name'], + 'last_name' => $staffRow['last_name'], + 'role' => $staffRow['role'], + ]); + } + + $game->replaceRosterForTeam($teamAb, $createdPlayers, $createdStaff); + } + + /** + * @param array $rows + * @return array + */ + private function validatedPlayerRows(array $rows, string $fieldPrefix, string $captainSelection): array + { + $validatedRows = []; + $selectedNumbers = []; + $captainCount = 0; + $liberoCount = 0; + $nonLiberoCount = 0; + + foreach ($rows as $index => $row) { + $firstName = trim($row['first_name']); + $lastName = trim($row['last_name']); + $numberInput = trim($row['number']); + $isCaptain = $captainSelection !== '' && (int) $captainSelection === $index; + $isLibero = $row['is_libero']; + $hasAnyValue = $firstName !== '' || $lastName !== '' || $numberInput !== ''; + + if (! $hasAnyValue) { + if ($isLibero) { + $this->addError("{$fieldPrefix}.{$index}", 'Only rostered players can be marked as libero.'); + } + + continue; + } + + if ($firstName === '') { + $this->addError("{$fieldPrefix}.{$index}.first_name", 'Enter the player first name.'); + } + + if ($lastName === '') { + $this->addError("{$fieldPrefix}.{$index}.last_name", 'Enter the player last name.'); + } + + if ($numberInput === '') { + $this->addError("{$fieldPrefix}.{$index}.number", 'Enter a roster number between 1 and 99.'); + + continue; + } + + if (! preg_match('/^\d+$/', $numberInput)) { + $this->addError("{$fieldPrefix}.{$index}.number", 'Roster numbers must be numeric.'); + + continue; + } + + $number = (int) $numberInput; + + if ($number < 1 || $number > 99) { + $this->addError("{$fieldPrefix}.{$index}.number", 'Roster numbers must be between 1 and 99.'); + + continue; + } + + if (in_array($number, $selectedNumbers, true)) { + $this->addError("{$fieldPrefix}.{$index}.number", 'Roster numbers must be unique within the team.'); + + continue; + } + + $selectedNumbers[] = $number; + $captainCount += $isCaptain ? 1 : 0; + $liberoCount += $isLibero ? 1 : 0; + $nonLiberoCount += $isLibero ? 0 : 1; + + $validatedRows[] = [ + 'first_name' => $firstName, + 'last_name' => $lastName, + 'number' => $number, + 'is_captain' => $isCaptain, + 'is_libero' => $isLibero, + ]; + } + + if ($captainCount !== 1) { + $this->addError($fieldPrefix, 'Select exactly one captain for the roster.'); + } + + if ($liberoCount > 2) { + $this->addError($fieldPrefix, 'A team can have at most two liberos.'); + } + + if ($nonLiberoCount < 6) { + $this->addError($fieldPrefix, 'A team must have at least six non-libero players.'); + } + + if ($nonLiberoCount > 12) { + $this->addError($fieldPrefix, 'A team can have at most 12 non-libero players.'); + } + + return $validatedRows; + } + + /** + * @param array $rows + * @return array + */ + private function validatedStaffRows(array $rows, string $fieldPrefix): array + { + $validatedRows = []; + + foreach ($rows as $index => $row) { + $firstName = trim($row['first_name']); + $lastName = trim($row['last_name']); + + if ($firstName === '' && $lastName === '') { + continue; + } + + if ($firstName === '') { + $this->addError("{$fieldPrefix}.{$index}.first_name", 'Enter the staff first name.'); + } + + if ($lastName === '') { + $this->addError("{$fieldPrefix}.{$index}.last_name", 'Enter the staff last name.'); + } + + $validatedRows[] = [ + 'role' => StaffRole::from($row['role']), + 'first_name' => $firstName, + 'last_name' => $lastName, + ]; + } + + return $validatedRows; + } + + /** + * @return array + */ + private function validatedOfficialRows(): array + { + $validatedRows = []; + + foreach ($this->officialRows as $index => $row) { + $firstName = trim($row['first_name']); + $lastName = trim($row['last_name']); + $countryCode = $this->normalizeCountryCode($row['country_code']); + + if ($firstName === '') { + $this->addError("officialRows.{$index}.first_name", 'Enter the official first name.'); + } + + if ($lastName === '') { + $this->addError("officialRows.{$index}.last_name", 'Enter the official last name.'); + } + + if (! preg_match('/^[A-Z]{3}$/', $countryCode)) { + $this->addError("officialRows.{$index}.country_code", 'Use a three-letter country code.'); + } + + $validatedRows[] = [ + 'role' => OfficialRole::from($row['role']), + 'first_name' => $firstName, + 'last_name' => $lastName, + 'country_code' => $countryCode, + ]; + } + + return $validatedRows; + } + + /** + * @param Collection $rosteredPlayers + * @return array + */ + private function playerRowsForTeam(Team $team, Collection $rosteredPlayers): array + { + $rows = $team->players() + ->orderBy('id') + ->get() + ->map(function (Player $player) use ($rosteredPlayers): array { + /** @var Player|null $rosteredPlayer */ + $rosteredPlayer = $rosteredPlayers->firstWhere($player->getKeyName(), $player->getKey()); + + return [ + 'first_name' => $player->first_name, + 'last_name' => $player->last_name, + 'number' => $rosteredPlayer === null ? '' : (string) $rosteredPlayer->roster->number, + 'is_captain' => $rosteredPlayer === null ? false : $rosteredPlayer->roster->is_captain, + 'is_libero' => $rosteredPlayer === null ? false : $rosteredPlayer->roster->is_libero, + ]; + }) + ->values() + ->all(); + + while (count($rows) < 6) { + $rows[] = $this->emptyPlayerRow(); + } + + return $rows; + } + + /** + * @param Collection $rosteredStaff + * @return array + */ + private function staffRowsForTeam(Team $team, Collection $rosteredStaff): array + { + return collect(StaffRole::cases()) + ->map(function (StaffRole $role) use ($team, $rosteredStaff): array { + /** @var Staff|null $staffMember */ + $staffMember = $rosteredStaff->first(fn (Staff $staff): bool => $staff->roster->role === $role); + $staffMember ??= $team->staff()->where('role', $role)->first(); + + return [ + 'role' => $role->value, + 'first_name' => $staffMember === null ? '' : $staffMember->first_name, + 'last_name' => $staffMember === null ? '' : $staffMember->last_name, + ]; + }) + ->values() + ->all(); + } + + /** + * @return array + */ + private function officialRowsForGame(Game $game): array + { + $officials = $game->officials()->get(); + + return collect(OfficialRole::cases()) + ->map(function (OfficialRole $role) use ($officials): array { + $official = $officials->first( + fn (Official $assignedOfficial): bool => $assignedOfficial->assignment->role === $role, + ); + + return [ + 'role' => $role->value, + 'first_name' => $official === null ? '' : $official->first_name, + 'last_name' => $official === null ? '' : $official->last_name, + 'country_code' => $official === null ? '' : $official->country_code, + ]; + }) + ->values() + ->all(); + } + + /** + * @return array + */ + private function emptyPlayerRows(): array + { + return array_fill(0, 6, $this->emptyPlayerRow()); + } + + /** + * @return array{first_name: string, last_name: string, number: string, is_captain: bool, is_libero: bool} + */ + private function emptyPlayerRow(): array + { + return [ + 'first_name' => '', + 'last_name' => '', + 'number' => '', + 'is_captain' => false, + 'is_libero' => false, + ]; + } + + /** + * @return array + */ + private function emptyStaffRows(): array + { + return collect(StaffRole::cases()) + ->map(fn (StaffRole $role): array => [ + 'role' => $role->value, + 'first_name' => '', + 'last_name' => '', + ]) + ->values() + ->all(); + } + + /** + * @return array + */ + private function emptyOfficialRows(): array + { + return collect(OfficialRole::cases()) + ->map(fn (OfficialRole $role): array => [ + 'role' => $role->value, + 'first_name' => '', + 'last_name' => '', + 'country_code' => '', + ]) + ->values() + ->all(); + } + + private function activeGameForSetup(): ?Game + { + $game = $this->game ?? $this->resolver()->current(); + + if ($game !== null) { + return $game; + } + + $this->addError('setup', 'Create the current match before continuing.'); + + return null; + } + + private function normalizeCountryCode(string $countryCode): string + { + return strtoupper(trim($countryCode)); + } + + /** + * @param array $rows + */ + private function captainSelectionForRows(array $rows): string + { + foreach ($rows as $index => $row) { + if ($row['is_captain']) { + return (string) $index; + } + } + + return ''; + } + + private function adjustCaptainSelectionAfterRowRemoval(string $side, int $removedIndex): void + { + $selection = $side === 'home' + ? $this->homeCaptainSelection + : $this->awayCaptainSelection; + + if ($selection === '') { + return; + } + + $selectedIndex = (int) $selection; + + if ($selectedIndex === $removedIndex) { + if ($side === 'home') { + $this->homeCaptainSelection = ''; + } else { + $this->awayCaptainSelection = ''; + } + + return; + } + + if ($selectedIndex > $removedIndex) { + if ($side === 'home') { + $this->homeCaptainSelection = (string) ($selectedIndex - 1); + } else { + $this->awayCaptainSelection = (string) ($selectedIndex - 1); + } + } + } + + private function setupLocked(): bool + { + return $this->game?->setupLocked() ?? false; + } + + private function ensureSetupEditable(Game $game): bool + { + try { + $game->assertSetupEditable(); + } catch (InvalidGameEventTransition $exception) { + $this->addError('setup', $exception->getMessage()); + + return false; + } + + return true; + } + + private function resolver(): CurrentMatchResolver + { + return app(CurrentMatchResolver::class); + } +} diff --git a/app/Livewire/RallyWinnerControls.php b/app/Livewire/RallyWinnerControls.php index fd780a3..542092e 100644 --- a/app/Livewire/RallyWinnerControls.php +++ b/app/Livewire/RallyWinnerControls.php @@ -8,28 +8,21 @@ use App\Enums\TeamAB; use App\Exceptions\InvalidGameEventTransition; use App\Models\Game; +use App\Services\CurrentMatchResolver; use App\Services\GameSideResolver; use Illuminate\Contracts\View\View; -use Livewire\Attributes\Locked; use Livewire\Attributes\Reactive; use Livewire\Component; class RallyWinnerControls extends Component { - #[Reactive] - #[Locked] - public ?int $gameId = null; - #[Reactive] public ?GameState $gameState = null; public string $side = 'left'; - public function mount(?int $gameId = null, string $side = 'left'): void + public function mount(string $side = 'left'): void { - abort_if($gameId === null, 404); - - $this->gameId = $gameId; $this->side = $side; } @@ -75,11 +68,7 @@ public function render(): View private function activeGame(): ?Game { - if ($this->gameId === null) { - return null; - } - - return Game::query()->whereKey($this->gameId)->first(); + return app(CurrentMatchResolver::class)->current(); } private function gameSideResolver(): GameSideResolver diff --git a/app/Livewire/RosterSubmission.php b/app/Livewire/RosterSubmission.php index 1db698c..0b402ad 100644 --- a/app/Livewire/RosterSubmission.php +++ b/app/Livewire/RosterSubmission.php @@ -10,21 +10,17 @@ use App\Models\Game; use App\Models\Player; use App\Models\Staff; +use App\Services\CurrentMatchResolver; use App\Services\GameSideResolver; use Flux\Flux; use Illuminate\Contracts\View\View; use Illuminate\Database\Eloquent\Collection as EloquentCollection; use Illuminate\Support\Collection; -use Livewire\Attributes\Locked; use Livewire\Attributes\Reactive; use Livewire\Component; class RosterSubmission extends Component { - #[Reactive] - #[Locked] - public ?int $gameId = null; - #[Reactive] public ?GameState $gameState = null; @@ -50,11 +46,6 @@ class RosterSubmission extends Component public string $awayCaptainSelection = ''; - public function mount(?int $gameId = null): void - { - $this->gameId = $gameId; - } - public function updated(string $property): void { if ( @@ -496,25 +487,14 @@ private function resolvedGameState(?Game $activeGame = null): GameState return $activeGame->stateAt(); } - if ($this->gameId === null) { - return $this->gameState ?? GameState::initial(); - } - - $activeGame = Game::query()->whereKey($this->gameId)->first(); + $activeGame = $this->activeGame(); return $activeGame?->stateAt() ?? ($this->gameState ?? GameState::initial()); } private function activeGame(): ?Game { - if ($this->gameId === null) { - return null; - } - - return Game::query() - ->with(['homeTeam', 'awayTeam']) - ->whereKey($this->gameId) - ->first(); + return app(CurrentMatchResolver::class)->current(); } private function gameSideResolver(): GameSideResolver diff --git a/app/Livewire/Scoreboard.php b/app/Livewire/Scoreboard.php index 74f3c9f..1ceee0c 100644 --- a/app/Livewire/Scoreboard.php +++ b/app/Livewire/Scoreboard.php @@ -8,19 +8,15 @@ use App\Enums\TeamAB; use App\Enums\TeamSide; use App\Models\Game; +use App\Services\CurrentMatchResolver; use App\Services\GameSideResolver; use Illuminate\Contracts\View\View; use Livewire\Attributes\Computed; -use Livewire\Attributes\Locked; use Livewire\Attributes\Reactive; use Livewire\Component; class Scoreboard extends Component { - #[Reactive] - #[Locked] - public ?int $gameId = null; - #[Reactive] public ?GameState $gameState = null; @@ -73,14 +69,7 @@ public function teamCountryCodes(): array private function activeGame(): ?Game { - if ($this->gameId === null) { - return null; - } - - return Game::query() - ->with(['homeTeam', 'awayTeam']) - ->whereKey($this->gameId) - ->first(); + return app(CurrentMatchResolver::class)->current(); } private function gameSideResolver(): GameSideResolver diff --git a/app/Livewire/StartSetSubmission.php b/app/Livewire/StartSetSubmission.php index 636e5a2..50eef9a 100644 --- a/app/Livewire/StartSetSubmission.php +++ b/app/Livewire/StartSetSubmission.php @@ -10,28 +10,19 @@ use App\Exceptions\InvalidGameEventTransition; use App\Models\Game; use App\Models\GameEvent; +use App\Services\CurrentMatchResolver; use App\Services\GameSideResolver; use Carbon\CarbonImmutable; use Illuminate\Contracts\View\View; use Livewire\Attributes\Computed; -use Livewire\Attributes\Locked; use Livewire\Attributes\Reactive; use Livewire\Component; class StartSetSubmission extends Component { - #[Reactive] - #[Locked] - public ?int $gameId = null; - #[Reactive] public ?GameState $gameState = null; - public function mount(?int $gameId = null): void - { - $this->gameId = $gameId; - } - public function startSet(): void { $this->resetValidation('startSet'); @@ -110,11 +101,7 @@ private function hasStartSetPrerequisites(): bool #[Computed] public function activeGame(): ?Game { - if ($this->gameId === null) { - return null; - } - - return Game::query()->whereKey($this->gameId)->first(); + return app(CurrentMatchResolver::class)->current(); } #[Computed] diff --git a/app/Livewire/TeamRoster.php b/app/Livewire/TeamRoster.php index 497a2f7..11e7113 100644 --- a/app/Livewire/TeamRoster.php +++ b/app/Livewire/TeamRoster.php @@ -14,20 +14,16 @@ use App\Exceptions\InvalidGameEventTransition; use App\Models\Game; use App\Models\GameEvent; +use App\Services\CurrentMatchResolver; use App\Services\GameSideResolver; use App\Services\ScoresheetDataRepository; use Illuminate\Contracts\View\View; use Livewire\Attributes\Computed; -use Livewire\Attributes\Locked; use Livewire\Attributes\Reactive; use Livewire\Component; class TeamRoster extends Component { - #[Reactive] - #[Locked] - public int $gameId; - #[Reactive] public TeamAB $team = TeamAB::TeamA; @@ -38,13 +34,9 @@ class TeamRoster extends Component public ?GameState $gameState = null; public function mount( - ?int $gameId = null, TeamAB $team = TeamAB::TeamA, bool $leftSide = true, ): void { - abort_if(is_null($gameId), 404); - - $this->gameId = $gameId; $this->team = $team; $this->leftSide = $leftSide; } @@ -300,7 +292,7 @@ public function render(): View #[Computed] public function activeGame(): ?Game { - return Game::query()->find($this->gameId); + return app(CurrentMatchResolver::class)->current(); } /** diff --git a/app/Livewire/TossResultSubmission.php b/app/Livewire/TossResultSubmission.php index 16fbb27..2d85f57 100644 --- a/app/Livewire/TossResultSubmission.php +++ b/app/Livewire/TossResultSubmission.php @@ -9,20 +9,16 @@ use App\Enums\TeamSide; use App\Exceptions\InvalidGameEventTransition; use App\Models\Game; +use App\Services\CurrentMatchResolver; use App\Services\GameSideResolver; use Flux\Flux; use Illuminate\Contracts\View\View; use Illuminate\Validation\Rule; -use Livewire\Attributes\Locked; use Livewire\Attributes\Reactive; use Livewire\Component; class TossResultSubmission extends Component { - #[Reactive] - #[Locked] - public ?int $gameId = null; - #[Reactive] public ?GameState $gameState = null; @@ -32,11 +28,6 @@ class TossResultSubmission extends Component public string $serving = TeamSide::Home->value; - public function mount(?int $gameId = null): void - { - $this->gameId = $gameId; - } - /** * @return array> */ @@ -72,13 +63,7 @@ public function submit(): void $this->validate(); - if ($this->gameId === null) { - $this->addError('submit', 'No active game is available to record the toss.'); - - return; - } - - $activeGame = Game::query()->whereKey($this->gameId)->first(); + $activeGame = $this->activeGame(); if ($activeGame === null) { $this->addError('submit', 'No active game is available to record the toss.'); @@ -155,25 +140,14 @@ private function resolvedGameState(?Game $activeGame = null): GameState return $activeGame->stateAt(); } - if ($this->gameId === null) { - return $this->gameState ?? GameState::initial(); - } - - $activeGame = Game::query()->whereKey($this->gameId)->first(); + $activeGame = $this->activeGame(); return $activeGame?->stateAt() ?? ($this->gameState ?? GameState::initial()); } private function activeGame(): ?Game { - if ($this->gameId === null) { - return null; - } - - return Game::query() - ->with(['homeTeam', 'awayTeam']) - ->whereKey($this->gameId) - ->first(); + return app(CurrentMatchResolver::class)->current(); } private function isFifthSetToss(): bool diff --git a/app/Models/Championship.php b/app/Models/Championship.php deleted file mode 100644 index 4918d37..0000000 --- a/app/Models/Championship.php +++ /dev/null @@ -1,44 +0,0 @@ - $games - */ -class Championship extends Model -{ - /** @use HasFactory */ - use HasFactory; - - /** - * @return array - */ - #[\Override] - protected function casts(): array - { - return [ - 'created_at' => 'immutable_datetime', - 'updated_at' => 'immutable_datetime', - ]; - } - - /** - * @return HasMany - */ - public function games(): HasMany - { - return $this->hasMany(Game::class); - } -} diff --git a/app/Models/Concerns/RecordsLineup.php b/app/Models/Concerns/RecordsLineup.php index a74e187..0fe0712 100644 --- a/app/Models/Concerns/RecordsLineup.php +++ b/app/Models/Concerns/RecordsLineup.php @@ -65,7 +65,7 @@ private function resolveRosterNumbersForTeam(TeamAB $team, TossCompletedPayload : ($tossPayload->teamA === TeamSide::Home ? TeamSide::Away : TeamSide::Home); return $side === TeamSide::Home - ? $this->homePlayers()->wherePivot('is_libero', false)->pluck('game_player.number') - : $this->awayPlayers()->wherePivot('is_libero', false)->pluck('game_player.number'); + ? $this->homePlayers()->where('is_libero', false)->pluck('players.number') + : $this->awayPlayers()->where('is_libero', false)->pluck('players.number'); } } diff --git a/app/Models/Game.php b/app/Models/Game.php index a6cefcc..112d503 100644 --- a/app/Models/Game.php +++ b/app/Models/Game.php @@ -5,9 +5,13 @@ namespace App\Models; use App\Data\GameState\GameState; +use App\Enums\GameEventType; +use App\Enums\MatchPhase; use App\Enums\OfficialRole; use App\Enums\StaffRole; use App\Enums\TeamAB; +use App\Enums\TeamSide; +use App\Exceptions\InvalidGameEventTransition; use App\Models\Concerns\RecordsCourtSideSwap; use App\Models\Concerns\RecordsEndOfGame; use App\Models\Concerns\RecordsEndOfRally; @@ -19,18 +23,19 @@ use App\Models\Concerns\RecordsSubstitution; use App\Models\Concerns\RecordsTimeOut; use App\Models\Concerns\RecordsToss; +use App\Services\CurrentMatchResolver; use Carbon\CarbonImmutable; use Database\Factories\GameFactory; use Illuminate\Database\Eloquent\Collection as EloquentCollection; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; -use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\QueryException; +use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\DB; /** - * @property int $championship_id * @property int $home_team_id * @property int $away_team_id * @property int $number @@ -42,9 +47,9 @@ * @property string $pool * @property string $category * @property bool $rosters_submitted + * @property MatchPhase $status * @property CarbonImmutable|null $created_at * @property CarbonImmutable|null $updated_at - * @property-read Championship $championship * @property-read Team $homeTeam * @property-read Team $awayTeam * @property-read EloquentCollection $officials @@ -83,6 +88,7 @@ protected function casts(): array return [ 'number' => 'integer', 'rosters_submitted' => 'boolean', + 'status' => MatchPhase::class, 'date_time' => 'immutable_datetime', 'created_at' => 'immutable_datetime', 'updated_at' => 'immutable_datetime', @@ -90,11 +96,126 @@ protected function casts(): array } /** - * @return BelongsTo + * Resolve the singleton match for the current database. */ - public function championship(): BelongsTo + public static function current(): self { - return $this->belongsTo(Championship::class); + return app(CurrentMatchResolver::class)->currentOrFail(); + } + + /** + * Create or reuse the singleton match record and its scoped teams. + * + * @param array $gameAttributes + * @param array $homeTeamAttributes + * @param array $awayTeamAttributes + */ + public static function ensureSingleton( + array $gameAttributes = [], + array $homeTeamAttributes = [], + array $awayTeamAttributes = [], + ): self { + /** @var self|null $existingGame */ + $existingGame = static::query()->first(); + + if ($existingGame !== null) { + return self::synchronizeSingleton($existingGame, $gameAttributes, $homeTeamAttributes, $awayTeamAttributes); + } + + try { + /** @var self */ + return DB::transaction(function () use ($gameAttributes, $homeTeamAttributes, $awayTeamAttributes): self { + $homeTeam = new Team; + $homeTeam->forceFill(array_merge([ + 'name' => '', + 'country_code' => '', + ], $homeTeamAttributes))->save(); + + $awayTeam = new Team; + $awayTeam->forceFill(array_merge([ + 'name' => '', + 'country_code' => '', + ], $awayTeamAttributes))->save(); + + $game = new self; + $game->forceFill(array_merge([ + 'home_team_id' => $homeTeam->getKey(), + 'away_team_id' => $awayTeam->getKey(), + 'number' => 1, + 'country_code' => '', + 'city' => '', + 'hall' => '', + 'date_time' => now(), + 'division' => '', + 'pool' => '', + 'category' => '', + 'rosters_submitted' => false, + 'status' => MatchPhase::Setup, + ], $gameAttributes))->save(); + + return self::synchronizeSingleton($game, [], [], []); + }); + } catch (QueryException $exception) { + /** @var self|null $createdByConcurrentRequest */ + $createdByConcurrentRequest = static::query()->first(); + + if ($createdByConcurrentRequest !== null) { + return self::synchronizeSingleton( + $createdByConcurrentRequest, + $gameAttributes, + $homeTeamAttributes, + $awayTeamAttributes, + ); + } + + throw $exception; + } + } + + /** + * Runtime competition metadata now comes from application config. + */ + public function competitionName(): string + { + return Config::string('competition.name'); + } + + public function resetForSetup(): void + { + DB::transaction(function (): void { + $this->stateSnapshots()->delete(); + $this->events()->delete(); + + Player::query() + ->where('game_id', $this->getKey()) + ->delete(); + + Staff::query() + ->where('game_id', $this->getKey()) + ->delete(); + + $this->officials()->delete(); + + $this->forceFill([ + 'rosters_submitted' => false, + 'status' => MatchPhase::Setup, + ])->save(); + }); + } + + public function hasCompleteMatchDetails(): bool + { + return $this->number > 0 + && $this->country_code !== '' + && $this->city !== '' + && $this->hall !== '' + && $this->division !== '' + && $this->pool !== '' + && $this->category !== '' + && $this->homeTeam->name !== '' + && $this->homeTeam->country_code !== '' + && $this->awayTeam->name !== '' + && $this->awayTeam->country_code !== ''; } /** @@ -114,71 +235,59 @@ public function awayTeam(): BelongsTo } /** - * @return BelongsToMany + * @return HasMany */ - public function officials(): BelongsToMany + public function officials(): HasMany { - return $this->belongsToMany(Official::class) - ->using(GameOfficial::class) - ->as('assignment') - ->withPivot('role') - ->withTimestamps(); + return $this->hasMany(Official::class)->orderBy('id'); } /** - * @return BelongsToMany + * @return HasMany */ - public function players(): BelongsToMany + public function players(): HasMany { - return $this->belongsToMany(Player::class) - ->using(RosterPlayer::class) - ->as('roster') - ->withPivot('number', 'is_captain', 'is_libero', 'team_id') - ->withTimestamps(); + return $this->hasMany(Player::class)->where('is_rostered', true); } /** - * @return BelongsToMany + * @return HasMany */ - public function homePlayers(): BelongsToMany + public function homePlayers(): HasMany { - return $this->players()->wherePivot('team_id', $this->home_team_id); + return $this->players()->where('team_id', $this->homeTeam->getKey()); } /** - * @return BelongsToMany + * @return HasMany */ - public function awayPlayers(): BelongsToMany + public function awayPlayers(): HasMany { - return $this->players()->wherePivot('team_id', $this->away_team_id); + return $this->players()->where('team_id', $this->awayTeam->getKey()); } /** - * @return BelongsToMany + * @return HasMany */ - public function staff(): BelongsToMany + public function staff(): HasMany { - return $this->belongsToMany(Staff::class) - ->using(RosterStaff::class) - ->as('roster') - ->withPivot('role', 'team_id') - ->withTimestamps(); + return $this->hasMany(Staff::class)->where('is_rostered', true); } /** - * @return BelongsToMany + * @return HasMany */ - public function homeStaff(): BelongsToMany + public function homeStaff(): HasMany { - return $this->staff()->wherePivot('team_id', $this->home_team_id); + return $this->staff()->where('team_id', $this->homeTeam->getKey()); } /** - * @return BelongsToMany + * @return HasMany */ - public function awayStaff(): BelongsToMany + public function awayStaff(): HasMany { - return $this->staff()->wherePivot('team_id', $this->away_team_id); + return $this->staff()->where('team_id', $this->awayTeam->getKey()); } /** @@ -186,12 +295,15 @@ public function awayStaff(): BelongsToMany */ public function addPlayer(Player $player, int $number, bool $isCaptain = false, bool $isLibero = false): void { - $this->players()->attach($player, [ - 'team_id' => $player->team_id, + $player->forceFill([ + 'game_id' => $this->getKey(), 'number' => $number, 'is_captain' => $isCaptain, 'is_libero' => $isLibero, - ]); + 'is_rostered' => true, + ])->save(); + + $this->synchronizeStatus(); } /** @@ -203,10 +315,13 @@ public function addStaff(Staff $staff, StaffRole|string|null $role = null): void ? $role : ($role !== null ? StaffRole::from($role) : $staff->role); - $this->staff()->attach($staff, [ - 'team_id' => $staff->team_id, + $staff->forceFill([ + 'game_id' => $this->getKey(), 'role' => $resolvedRole, - ]); + 'is_rostered' => true, + ])->save(); + + $this->synchronizeStatus(); } /** @@ -215,18 +330,28 @@ public function addStaff(Staff $staff, StaffRole|string|null $role = null): void */ public function replaceRosterForTeam(TeamAB $team, array $players, array $staff): void { - $teamId = $team === TeamAB::TeamA ? $this->home_team_id : $this->away_team_id; + $this->assertSetupEditable(); + + $teamModel = $team === TeamAB::TeamA ? $this->homeTeam : $this->awayTeam; + $teamId = $teamModel->getKey(); DB::transaction(function () use ($players, $staff, $teamId): void { - RosterPlayer::query() + Player::query() ->where('game_id', $this->getKey()) ->where('team_id', $teamId) - ->delete(); - - RosterStaff::query() + ->update([ + 'number' => null, + 'is_captain' => false, + 'is_libero' => false, + 'is_rostered' => false, + ]); + + Staff::query() ->where('game_id', $this->getKey()) ->where('team_id', $teamId) - ->delete(); + ->update([ + 'is_rostered' => false, + ]); foreach ($players as $playerRoster) { $this->addPlayer( @@ -241,6 +366,8 @@ public function replaceRosterForTeam(TeamAB $team, array $players, array $staff) $this->addStaff($staffMember); } }); + + $this->synchronizeStatus(); } public function hasSubmittedInitialRosters(): bool @@ -255,6 +382,76 @@ public function markRostersSubmitted(): void $this->forceFill([ 'rosters_submitted' => true, ])->save(); + + $this->synchronizeStatus(); + } + + public function hasRequiredOfficials(): bool + { + /** @var array $assignedRoles */ + $assignedRoles = $this->officials() + ->get() + ->map(fn (Official $official): ?string => $official->role?->value) + ->unique() + ->filter() + ->values() + ->all(); + + if (count($assignedRoles) !== count(OfficialRole::cases())) { + return false; + } + + return array_all(OfficialRole::cases(), fn (OfficialRole $role): bool => in_array($role->value, $assignedRoles, true)); + } + + /** + * @param array $assignments + */ + public function replaceOfficials(array $assignments): void + { + $this->assertSetupEditable(); + + DB::transaction(function () use ($assignments): void { + /** @var EloquentCollection $existingOfficials */ + $existingOfficials = $this->officials()->get(); + $existingByRole = $existingOfficials->keyBy( + fn (Official $official): string => $official->role === null ? '' : $official->role->value, + ); + + foreach ($assignments as $assignment) { + /** @var Official|null $official */ + $official = $existingByRole->get($assignment['role']->value); + $official ??= new Official; + + $official->forceFill([ + 'game_id' => $this->getKey(), + 'role' => $assignment['role'], + 'first_name' => $assignment['first_name'], + 'last_name' => $assignment['last_name'], + 'country_code' => $assignment['country_code'], + ])->save(); + } + + $rolesToKeep = collect($assignments) + ->map(fn (array $assignment): string => $assignment['role']->value) + ->all(); + + $this->officials() + ->whereNotIn('role', $rolesToKeep) + ->delete(); + }); + + $this->synchronizeStatus(); + } + + public function setupLocked(): bool + { + return ! $this->status->allowsSetupEdits(); } /** @@ -262,7 +459,12 @@ public function markRostersSubmitted(): void */ public function addOfficial(Official $official, OfficialRole $role): void { - $this->officials()->attach($official, ['role' => $role]); + $official->forceFill([ + 'game_id' => $this->getKey(), + 'role' => $role, + ])->save(); + + $this->synchronizeStatus(); } /** @@ -307,4 +509,116 @@ public function stateAt(?CarbonImmutable $at = null): GameState ? GameState::initial() : GameState::fromSnapshot($snapshot); } + + public function isSetupEditable(): bool + { + return $this->status->allowsSetupEdits(); + } + + public function canRecordGameplay(): bool + { + return $this->status->allowsGameplayRecording(); + } + + public function canGeneratePdf(): bool + { + return $this->status->allowsPdfGeneration(); + } + + public function assertSetupEditable(): void + { + if ($this->isSetupEditable()) { + return; + } + + throw new InvalidGameEventTransition('Match setup cannot be edited after gameplay has begun.'); + } + + public function assertCanGeneratePdf(): void + { + if ($this->canGeneratePdf()) { + return; + } + + throw new InvalidGameEventTransition('The PDF can only be generated after the match has been completed.'); + } + + public function markPdfGenerated(): void + { + $this->assertCanGeneratePdf(); + + if ($this->status === MatchPhase::PdfGenerated) { + return; + } + + $this->forceFill([ + 'status' => MatchPhase::PdfGenerated, + ])->save(); + } + + public function synchronizeStatus(): void + { + $resolvedStatus = $this->resolvedStatus(); + + if ($this->status === $resolvedStatus) { + return; + } + + $this->forceFill([ + 'status' => $resolvedStatus, + ])->save(); + } + + private function resolvedStatus(): MatchPhase + { + $baseStatus = $this->events()->where('type', GameEventType::GameEnded->value)->exists() + ? MatchPhase::Completed + : ($this->events()->exists() + ? MatchPhase::InProgress + : ($this->hasCompleteMatchDetails() + && $this->hasSubmittedInitialRosters() + && $this->hasRequiredOfficials() + ? MatchPhase::Ready + : MatchPhase::Setup)); + + if ($this->status === MatchPhase::PdfGenerated && $baseStatus === MatchPhase::Completed) { + return MatchPhase::PdfGenerated; + } + + return $baseStatus; + } + + /** + * @param array $gameAttributes + * @param array $homeTeamAttributes + * @param array $awayTeamAttributes + */ + private static function synchronizeSingleton( + self $game, + array $gameAttributes, + array $homeTeamAttributes, + array $awayTeamAttributes, + ): self { + /** @var self */ + return DB::transaction(function () use ($game, $gameAttributes, $homeTeamAttributes, $awayTeamAttributes): self { + $game->loadMissing(['homeTeam', 'awayTeam', 'officials']); + + $game->homeTeam->forceFill(array_merge($homeTeamAttributes, [ + 'game_id' => $game->getKey(), + 'side' => TeamSide::Home, + ]))->save(); + + $game->awayTeam->forceFill(array_merge($awayTeamAttributes, [ + 'game_id' => $game->getKey(), + 'side' => TeamSide::Away, + ]))->save(); + + if ($gameAttributes !== []) { + $game->forceFill($gameAttributes)->save(); + } + + /** @var self */ + return $game->fresh(['homeTeam', 'awayTeam', 'officials']); + }); + } } diff --git a/app/Models/GameOfficial.php b/app/Models/GameOfficial.php deleted file mode 100644 index 523c4b8..0000000 --- a/app/Models/GameOfficial.php +++ /dev/null @@ -1,35 +0,0 @@ - - */ - #[\Override] - protected function casts(): array - { - return [ - 'role' => OfficialRole::class, - 'created_at' => 'immutable_datetime', - 'updated_at' => 'immutable_datetime', - ]; - } -} diff --git a/app/Models/Official.php b/app/Models/Official.php index 1555536..1008332 100644 --- a/app/Models/Official.php +++ b/app/Models/Official.php @@ -4,21 +4,24 @@ namespace App\Models; +use App\Enums\OfficialRole; use Carbon\CarbonImmutable; use Database\Factories\OfficialFactory; -use Illuminate\Database\Eloquent\Collection as EloquentCollection; +use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; -use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Illuminate\Database\Eloquent\Relations\BelongsTo; /** + * @property int|null $game_id * @property string $first_name * @property string $last_name * @property string $country_code + * @property OfficialRole|null $role * @property CarbonImmutable|null $created_at * @property CarbonImmutable|null $updated_at - * @property-read EloquentCollection $games - * @property-read GameOfficial $assignment + * @property-read Game|null $game + * @property-read Official $assignment */ class Official extends Model { @@ -32,20 +35,25 @@ class Official extends Model protected function casts(): array { return [ + 'role' => OfficialRole::class, 'created_at' => 'immutable_datetime', 'updated_at' => 'immutable_datetime', ]; } /** - * @return BelongsToMany + * @return BelongsTo */ - public function games(): BelongsToMany + public function game(): BelongsTo { - return $this->belongsToMany(Game::class) - ->using(GameOfficial::class) - ->as('assignment') - ->withPivot('role') - ->withTimestamps(); + return $this->belongsTo(Game::class); + } + + /** + * @return Attribute<$this, never> + */ + protected function assignment(): Attribute + { + return Attribute::get(fn (): self => $this); } } diff --git a/app/Models/Player.php b/app/Models/Player.php index 5a8c696..b40acbb 100644 --- a/app/Models/Player.php +++ b/app/Models/Player.php @@ -6,21 +6,25 @@ use Carbon\CarbonImmutable; use Database\Factories\PlayerFactory; -use Illuminate\Database\Eloquent\Collection as EloquentCollection; +use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; -use Illuminate\Database\Eloquent\Relations\BelongsToMany; /** + * @property int|null $game_id * @property int $team_id * @property string $first_name * @property string $last_name + * @property int|null $number + * @property bool $is_captain + * @property bool $is_libero + * @property bool $is_rostered * @property CarbonImmutable|null $created_at * @property CarbonImmutable|null $updated_at + * @property-read Game|null $game * @property-read Team $team - * @property-read EloquentCollection $games - * @property-read RosterPlayer $roster + * @property-read Player $roster */ class Player extends Model { @@ -34,11 +38,23 @@ class Player extends Model protected function casts(): array { return [ + 'number' => 'integer', + 'is_captain' => 'boolean', + 'is_libero' => 'boolean', + 'is_rostered' => 'boolean', 'created_at' => 'immutable_datetime', 'updated_at' => 'immutable_datetime', ]; } + /** + * @return BelongsTo + */ + public function game(): BelongsTo + { + return $this->belongsTo(Game::class); + } + /** * @return BelongsTo */ @@ -48,14 +64,10 @@ public function team(): BelongsTo } /** - * @return BelongsToMany + * @return Attribute<$this, never> */ - public function games(): BelongsToMany + protected function roster(): Attribute { - return $this->belongsToMany(Game::class) - ->using(RosterPlayer::class) - ->as('roster') - ->withPivot('number', 'is_captain', 'is_libero', 'team_id') - ->withTimestamps(); + return Attribute::get(fn (): self => $this); } } diff --git a/app/Models/RosterPlayer.php b/app/Models/RosterPlayer.php deleted file mode 100644 index e832bbf..0000000 --- a/app/Models/RosterPlayer.php +++ /dev/null @@ -1,38 +0,0 @@ - 'integer', - 'is_captain' => 'boolean', - 'is_libero' => 'boolean', - 'created_at' => 'immutable_datetime', - 'updated_at' => 'immutable_datetime', - ]; - } -} diff --git a/app/Models/RosterStaff.php b/app/Models/RosterStaff.php deleted file mode 100644 index 2c44bf9..0000000 --- a/app/Models/RosterStaff.php +++ /dev/null @@ -1,35 +0,0 @@ - StaffRole::class, - 'created_at' => 'immutable_datetime', - 'updated_at' => 'immutable_datetime', - ]; - } -} diff --git a/app/Models/Staff.php b/app/Models/Staff.php index 2a8e663..9dd2b4e 100644 --- a/app/Models/Staff.php +++ b/app/Models/Staff.php @@ -7,22 +7,23 @@ use App\Enums\StaffRole; use Carbon\CarbonImmutable; use Database\Factories\StaffFactory; -use Illuminate\Database\Eloquent\Collection as EloquentCollection; +use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; -use Illuminate\Database\Eloquent\Relations\BelongsToMany; /** + * @property int|null $game_id * @property int $team_id * @property string $first_name * @property string $last_name * @property StaffRole $role + * @property bool $is_rostered * @property CarbonImmutable|null $created_at * @property CarbonImmutable|null $updated_at + * @property-read Game|null $game * @property-read Team $team - * @property-read EloquentCollection $games - * @property-read RosterStaff $roster + * @property-read Staff $roster */ class Staff extends Model { @@ -34,11 +35,20 @@ protected function casts(): array { return [ 'role' => StaffRole::class, + 'is_rostered' => 'boolean', 'created_at' => 'immutable_datetime', 'updated_at' => 'immutable_datetime', ]; } + /** + * @return BelongsTo + */ + public function game(): BelongsTo + { + return $this->belongsTo(Game::class); + } + /** * @return BelongsTo */ @@ -48,14 +58,10 @@ public function team(): BelongsTo } /** - * @return BelongsToMany + * @return Attribute<$this, never> */ - public function games(): BelongsToMany + protected function roster(): Attribute { - return $this->belongsToMany(Game::class) - ->using(RosterStaff::class) - ->as('roster') - ->withPivot('role', 'team_id') - ->withTimestamps(); + return Attribute::get(fn (): self => $this); } } diff --git a/app/Models/Team.php b/app/Models/Team.php index 65619ae..244ee10 100644 --- a/app/Models/Team.php +++ b/app/Models/Team.php @@ -4,18 +4,23 @@ namespace App\Models; +use App\Enums\TeamSide; use Carbon\CarbonImmutable; use Database\Factories\TeamFactory; use Illuminate\Database\Eloquent\Collection as EloquentCollection; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; /** + * @property int|null $game_id + * @property TeamSide|null $side * @property string $name * @property string $country_code * @property CarbonImmutable|null $created_at * @property CarbonImmutable|null $updated_at + * @property-read Game|null $game * @property-read EloquentCollection $players * @property-read EloquentCollection $staff */ @@ -31,11 +36,20 @@ class Team extends Model protected function casts(): array { return [ + 'side' => TeamSide::class, 'created_at' => 'immutable_datetime', 'updated_at' => 'immutable_datetime', ]; } + /** + * @return BelongsTo + */ + public function game(): BelongsTo + { + return $this->belongsTo(Game::class); + } + /** * @return HasMany */ diff --git a/app/Observers/GameEventObserver.php b/app/Observers/GameEventObserver.php index 566dee2..6c8c50e 100644 --- a/app/Observers/GameEventObserver.php +++ b/app/Observers/GameEventObserver.php @@ -14,5 +14,6 @@ public function created(GameEvent $gameEvent): void { resolve(GameStateProjector::class)->projectAndStore($gameEvent); resolve(GameEventReactor::class)->reactTo($gameEvent); + $gameEvent->game->synchronizeStatus(); } } diff --git a/app/Services/CurrentMatchResolver.php b/app/Services/CurrentMatchResolver.php new file mode 100644 index 0000000..947f7a9 --- /dev/null +++ b/app/Services/CurrentMatchResolver.php @@ -0,0 +1,87 @@ +sole(); + } catch (ModelNotFoundException) { + return null; + } catch (MultipleRecordsFoundException $exception) { + throw new LogicException('The single-match application found multiple game records.', previous: $exception); + } + } + + public function currentOrFail(): Game + { + $game = $this->current(); + + if ($game !== null) { + return $game; + } + + $exception = new ModelNotFoundException; + $exception->setModel(Game::class); + + throw $exception; + } + + public function landingRouteName(): string + { + $currentGame = $this->current(); + + if ($currentGame === null) { + return 'match.setup'; + } + + return $currentGame->status === MatchPhase::Setup + ? 'match.setup' + : 'game'; + } + + public function nextStep(?Game $game = null): string + { + $currentGame = $game ?? $this->current(); + + if ($currentGame === null) { + return 'missing-match'; + } + + if ($currentGame->status !== MatchPhase::Setup) { + return 'ready'; + } + + if (! $currentGame->hasCompleteMatchDetails()) { + return 'match-details'; + } + + if (! $currentGame->hasSubmittedInitialRosters()) { + return 'rosters'; + } + + if (! $currentGame->hasRequiredOfficials()) { + return 'officials'; + } + + return 'ready'; + } + + public function isSetupComplete(?Game $game = null): bool + { + $currentGame = $game ?? $this->current(); + + return $currentGame !== null && $currentGame->status !== MatchPhase::Setup; + } +} diff --git a/app/Services/GameState/GameEventRuleValidator.php b/app/Services/GameState/GameEventRuleValidator.php index 13a4821..b26d2ba 100644 --- a/app/Services/GameState/GameEventRuleValidator.php +++ b/app/Services/GameState/GameEventRuleValidator.php @@ -194,16 +194,16 @@ public function assertCanRecordMisconduct( $this->fail('This team already has a minor misconduct warning.'); } - $teamId = $team === TeamAB::TeamA ? $game->home_team_id : $game->away_team_id; + $teamId = $team === TeamAB::TeamA ? $game->homeTeam->getKey() : $game->awayTeam->getKey(); $isRostered = match ($subjectType) { MisconductSubjectType::Player => $game->players() ->whereKey($subjectId) - ->wherePivot('team_id', $teamId) + ->where('team_id', $teamId) ->exists(), MisconductSubjectType::Staff => $game->staff() ->whereKey($subjectId) - ->wherePivot('team_id', $teamId) + ->where('team_id', $teamId) ->exists(), }; diff --git a/app/Services/GameState/GameStateProjector.php b/app/Services/GameState/GameStateProjector.php index 612f172..b1c0835 100644 --- a/app/Services/GameState/GameStateProjector.php +++ b/app/Services/GameState/GameStateProjector.php @@ -18,14 +18,16 @@ use App\Events\Payloads\SubstitutionCompletedPayload; use App\Events\Payloads\TimeOutRequestedPayload; use App\Events\Payloads\TossCompletedPayload; +use App\Models\Game; use App\Models\GameEvent; use App\Models\GameStateSnapshot; use Illuminate\Database\Eloquent\Builder; class GameStateProjector { - /** @var array */ - private array $tossServingTeamByGame = []; + private bool $resolvedTossServingTeam = false; + + private ?TeamAB $tossServingTeam = null; public function project(GameState $state, GameEvent $event): GameState { @@ -238,7 +240,7 @@ private function applySetStarted(GameState $state, GameEvent $event): GameState $state->resetCurrentSetCounters(); if ($state->setNumber !== 5 || $state->fifthSetLeftTeam === null) { - $state->servingTeam = $this->servingTeamForSet($event->game_id, $state->setNumber) ?? $state->servingTeam; + $state->servingTeam = $this->servingTeamForSet($state->setNumber) ?? $state->servingTeam; } $state->setInProgress = true; @@ -261,7 +263,7 @@ private function applySetEnded(GameState $state, GameEvent $event): GameState $state->fifthSetLeftTeam = null; $state->fifthSetSideSwapped = false; } else { - $state->servingTeam = $this->servingTeamForSet($event->game_id, $nextSetNumber) ?? $state->servingTeam; + $state->servingTeam = $this->servingTeamForSet($nextSetNumber) ?? $state->servingTeam; } $state->scoreTeamA = 0; @@ -348,9 +350,9 @@ private function substitute(array $positions, int $playerOut, int $playerIn): ar return $positions; } - private function servingTeamForSet(int $gameId, int $setNumber): ?TeamAB + private function servingTeamForSet(int $setNumber): ?TeamAB { - $tossServingTeam = $this->tossServingTeamForGame($gameId); + $tossServingTeam = $this->tossServingTeam(); if ($tossServingTeam === null) { return null; @@ -361,29 +363,33 @@ private function servingTeamForSet(int $gameId, int $setNumber): ?TeamAB : $this->oppositeTeam($tossServingTeam); } - private function tossServingTeamForGame(int $gameId): ?TeamAB + private function tossServingTeam(): ?TeamAB { - if (array_key_exists($gameId, $this->tossServingTeamByGame)) { - return $this->tossServingTeamByGame[$gameId]; + if ($this->resolvedTossServingTeam) { + return $this->tossServingTeam; } + $game = Game::current(); + /** @var GameEvent|null $tossEvent */ - $tossEvent = GameEvent::query() - ->where('game_id', $gameId) + $tossEvent = $game->events() + ->reorder() ->where('type', GameEventType::TossCompleted) ->orderBy('created_at') ->orderBy('id') ->first(); if ($tossEvent === null || ! $tossEvent->payload instanceof TossCompletedPayload) { - $this->tossServingTeamByGame[$gameId] = null; + $this->resolvedTossServingTeam = true; + $this->tossServingTeam = null; return null; } - $this->tossServingTeamByGame[$gameId] = $tossEvent->payload->serving; + $this->resolvedTossServingTeam = true; + $this->tossServingTeam = $tossEvent->payload->serving; - return $this->tossServingTeamByGame[$gameId]; + return $this->tossServingTeam; } private function oppositeTeam(TeamAB $team): TeamAB diff --git a/app/Services/Scoresheet/ScoresheetGenerator.php b/app/Services/Scoresheet/ScoresheetGenerator.php index f210084..aa4078e 100644 --- a/app/Services/Scoresheet/ScoresheetGenerator.php +++ b/app/Services/Scoresheet/ScoresheetGenerator.php @@ -9,30 +9,22 @@ class ScoresheetGenerator { public function __construct( - protected ScoresheetWriterFactory $writerFactory + protected ScoresheetWriterFactory $writerFactory, + protected ScoresheetPdfFactory $pdfFactory, ) {} public function generate(Game $game): ScoresheetPdf { - $pdf = new ScoresheetPdf; + $game->assertCanGeneratePdf(); - $path = storage_path('app/private/FIVB_VB_OfficialScoresheet_2013_updated2.pdf'); - $pdf->setSourceFile($path); - $pdf->SetMargins(0, 0, 0); - $pdf->SetAutoPageBreak(false); + $pdf = $this->pdfFactory->make(); - $templateId = $pdf->importPage(1); - - $pdf->AddPage(); - $pdf->useTemplate($templateId, adjustPageSize: true); - - $pdf->SetDisplayMode(zoom: 'fullpage', layout: 'single'); - $pdf->SetFont(family: 'Courier', size: 12); - - // Use the factory to get writers and execute them $this->writerFactory->make('match_info')->write($pdf, $game); $this->writerFactory->make('teams')->write($pdf, $game); $this->writerFactory->make('officials')->write($pdf, $game); + $this->writerFactory->make('results')->write($pdf, $game); + + $game->markPdfGenerated(); return $pdf; } diff --git a/app/Services/Scoresheet/ScoresheetPdfFactory.php b/app/Services/Scoresheet/ScoresheetPdfFactory.php new file mode 100644 index 0000000..2287f7b --- /dev/null +++ b/app/Services/Scoresheet/ScoresheetPdfFactory.php @@ -0,0 +1,28 @@ +setSourceFile($path); + $pdf->SetMargins(0, 0, 0); + $pdf->SetAutoPageBreak(false); + + $templateId = $pdf->importPage(1); + + $pdf->AddPage(); + $pdf->useTemplate($templateId, adjustPageSize: true); + $pdf->SetDisplayMode(zoom: 'fullpage', layout: 'single'); + $pdf->SetFont(family: 'Courier', size: 12); + + return $pdf; + } +} diff --git a/app/Services/Scoresheet/ScoresheetWriterFactory.php b/app/Services/Scoresheet/ScoresheetWriterFactory.php index eed37de..0844e04 100644 --- a/app/Services/Scoresheet/ScoresheetWriterFactory.php +++ b/app/Services/Scoresheet/ScoresheetWriterFactory.php @@ -7,6 +7,7 @@ use App\Services\Scoresheet\Contracts\ScoresheetSectionWriter; use App\Services\Scoresheet\Writers\MatchInfoWriter; use App\Services\Scoresheet\Writers\OfficialsWriter; +use App\Services\Scoresheet\Writers\ResultsWriter; use App\Services\Scoresheet\Writers\TeamsWriter; use InvalidArgumentException; @@ -18,6 +19,7 @@ public function make(string $type): ScoresheetSectionWriter 'match_info' => app(MatchInfoWriter::class), 'teams' => app(TeamsWriter::class), 'officials' => app(OfficialsWriter::class), + 'results' => app(ResultsWriter::class), default => throw new InvalidArgumentException("Unknown writer type: {$type}"), }; } diff --git a/app/Services/Scoresheet/Writers/MatchInfoWriter.php b/app/Services/Scoresheet/Writers/MatchInfoWriter.php index 6d91c36..0e0aff0 100644 --- a/app/Services/Scoresheet/Writers/MatchInfoWriter.php +++ b/app/Services/Scoresheet/Writers/MatchInfoWriter.php @@ -7,44 +7,51 @@ use App\Models\Game; use App\Services\Scoresheet\Contracts\ScoresheetSectionWriter; use App\Services\Scoresheet\ScoresheetPdf; +use App\Services\ScoresheetDataRepository; class MatchInfoWriter implements ScoresheetSectionWriter { + public function __construct( + private readonly ScoresheetDataRepository $scoresheetDataRepository + ) {} + public function write(ScoresheetPdf $pdf, Game $game): void { + $matchInfo = $this->scoresheetDataRepository->matchInfo($game); + // Name of the competition $pdf->SetXY(65, 16); - $pdf->Write(0, $game->championship->name); + $pdf->Write(0, $matchInfo['competition_name']); // City, Country Code, Hall, Pool, Match number - $pdf->spacedPrint(27, 23, $game->city); - $pdf->spacedPrint(140, 23, $game->country_code); - $pdf->spacedPrint(27, 29, $game->hall); - $pdf->spacedPrint(111, 29, $game->pool); - $pdf->spacedPrint(146, 29, (string) $game->number); + $pdf->spacedPrint(27, 23, $matchInfo['city']); + $pdf->spacedPrint(140, 23, $matchInfo['country_code']); + $pdf->spacedPrint(27, 29, $matchInfo['hall']); + $pdf->spacedPrint(111, 29, $matchInfo['pool']); + $pdf->spacedPrint(146, 29, (string) $matchInfo['match_number']); // Date and time - $pdf->spacedPrint(170, 23, $game->date_time->format('dmy')); - $pdf->spacedPrint(220, 23, $game->date_time->format('hi')); + $pdf->spacedPrint(170, 23, $matchInfo['scheduled_at']->format('dmy')); + $pdf->spacedPrint(220, 23, $matchInfo['scheduled_at']->format('Hi')); // Division and category - if ($game->division === 'Men') { + if ($matchInfo['division'] === 'Men') { $this->cross($pdf, 41, 34); } else { $this->cross($pdf, 63, 34); } - if ($game->category === 'Senior') { + if ($matchInfo['category'] === 'Senior') { $this->cross($pdf, 111, 34); - } elseif ($game->category === 'Junior') { + } elseif ($matchInfo['category'] === 'Junior') { $this->cross($pdf, 132, 34); } else { $this->cross($pdf, 152, 34); } // Home and away teams - $pdf->spacedPrint(175, 35, $game->homeTeam->country_code); - $pdf->spacedPrint(214, 35, $game->awayTeam->country_code); + $pdf->spacedPrint(175, 35, $matchInfo['home_team_code']); + $pdf->spacedPrint(214, 35, $matchInfo['away_team_code']); } private function cross(ScoresheetPdf $pdf, int $x, int $y): void diff --git a/app/Services/Scoresheet/Writers/OfficialsWriter.php b/app/Services/Scoresheet/Writers/OfficialsWriter.php index df71ba9..5ee3fc8 100644 --- a/app/Services/Scoresheet/Writers/OfficialsWriter.php +++ b/app/Services/Scoresheet/Writers/OfficialsWriter.php @@ -6,18 +6,21 @@ use App\Enums\OfficialRole; use App\Models\Game; -use App\Models\Official; use App\Services\Scoresheet\Contracts\ScoresheetSectionWriter; use App\Services\Scoresheet\ScoresheetPdf; +use App\Services\ScoresheetDataRepository; class OfficialsWriter implements ScoresheetSectionWriter { + public function __construct( + private readonly ScoresheetDataRepository $scoresheetDataRepository + ) {} + public function write(ScoresheetPdf $pdf, Game $game): void { $pdf->SetFontSize(10); - /** @var Official $official */ - foreach ($game->officials as $official) { - [$x, $y] = match ($official->assignment->role) { + foreach ($this->scoresheetDataRepository->officials($game) as $official) { + [$x, $y] = match ($official['role']) { OfficialRole::FirstReferee => [93, 253], OfficialRole::SecondReferee => [93, 259], OfficialRole::Scorer => [93, 265], @@ -29,13 +32,13 @@ public function write(ScoresheetPdf $pdf, Game $game): void }; $pdf->SetXY($x, $y); - $pdf->Write(0, str($official->first_name.' '.$official->last_name)->upper()); + $pdf->Write(0, str($official['first_name'].' '.$official['last_name'])->upper()); - if (in_array($official->assignment->role, [ + if (in_array($official['role'], [ OfficialRole::FirstReferee, OfficialRole::SecondReferee, OfficialRole::Scorer, OfficialRole::AssistantScorer, ])) { - [$x, $y] = match ($official->assignment->role) { + [$x, $y] = match ($official['role']) { OfficialRole::FirstReferee => [169, 253], OfficialRole::SecondReferee => [169, 259], OfficialRole::Scorer => [169, 265], @@ -43,7 +46,7 @@ public function write(ScoresheetPdf $pdf, Game $game): void }; $pdf->SetXY($x, $y); - $pdf->Write(0, $official->country_code); + $pdf->Write(0, $official['country_code']); } } diff --git a/app/Services/Scoresheet/Writers/ResultsWriter.php b/app/Services/Scoresheet/Writers/ResultsWriter.php new file mode 100644 index 0000000..83c061f --- /dev/null +++ b/app/Services/Scoresheet/Writers/ResultsWriter.php @@ -0,0 +1,69 @@ +scoresheetDataRepository->results($game); + + $pdf->SetFontSize(10); + $pdf->spacedPrint(271, 283, $results['team_a_code']); + $pdf->spacedPrint(398, 283, $results['team_b_code']); + + foreach ($results['sets'] as $index => $setSummary) { + $y = 294 + ($index * 10); + + $this->writeCellValue($pdf, 251, $y, $setSummary['team_a_timeouts']); + $this->writeCellValue($pdf, 260, $y, $setSummary['team_a_substitutions']); + $this->writeCellValue($pdf, 269, $y, $setSummary['team_a_sets_won']); + $this->writeCellValue($pdf, 280, $y, $setSummary['team_a_points']); + $this->writeCellValue($pdf, 307, $y, $setSummary['set_number']); + $this->writeCellValue($pdf, 322, $y, $setSummary['duration_minutes']); + $this->writeCellValue($pdf, 347, $y, $setSummary['team_b_points']); + $this->writeCellValue($pdf, 359, $y, $setSummary['team_b_sets_won']); + $this->writeCellValue($pdf, 369, $y, $setSummary['team_b_substitutions']); + $this->writeCellValue($pdf, 379, $y, $setSummary['team_b_timeouts']); + } + + if ($results['match_start_time'] !== null) { + $pdf->spacedPrint(245, 369, $results['match_start_time']->format('Hi')); + } + + if ($results['match_end_time'] !== null) { + $pdf->spacedPrint(311, 369, $results['match_end_time']->format('Hi')); + } + + if ($results['total_duration_minutes'] !== null) { + $hours = intdiv($results['total_duration_minutes'], 60); + $minutes = $results['total_duration_minutes'] % 60; + + $this->writeCellValue($pdf, 386, 369, $hours); + $this->writeCellValue($pdf, 402, 369, $minutes); + } + + $this->writeCellValue($pdf, 325, 354, $results['total_set_duration_minutes']); + $pdf->spacedPrint(300, 380, $results['winner_team_code']); + $pdf->SetXY(389, 380); + $pdf->Write(0, sprintf('%d : %d', $results['team_a_sets_won'], $results['team_b_sets_won'])); + $pdf->SetFontSize(12); + } + + private function writeCellValue(ScoresheetPdf $pdf, int $x, int $y, int $value): void + { + $pdf->SetXY($value < 10 ? $x + 1 : $x, $y); + $pdf->Write(0, (string) $value); + } +} diff --git a/app/Services/Scoresheet/Writers/TeamsWriter.php b/app/Services/Scoresheet/Writers/TeamsWriter.php index 86a2f46..9cdc4f3 100644 --- a/app/Services/Scoresheet/Writers/TeamsWriter.php +++ b/app/Services/Scoresheet/Writers/TeamsWriter.php @@ -5,56 +5,64 @@ namespace App\Services\Scoresheet\Writers; use App\Enums\StaffRole; +use App\Enums\TeamSide; use App\Models\Game; -use App\Models\Player; -use App\Models\Staff; use App\Services\Scoresheet\Contracts\ScoresheetSectionWriter; use App\Services\Scoresheet\ScoresheetPdf; -use Illuminate\Database\Eloquent\Collection as EloquentCollection; +use App\Services\ScoresheetDataRepository; class TeamsWriter implements ScoresheetSectionWriter { + public function __construct( + private readonly ScoresheetDataRepository $scoresheetDataRepository + ) {} + public function write(ScoresheetPdf $pdf, Game $game): void { + $homeTeamSheet = $this->scoresheetDataRepository->teamSheet($game, TeamSide::Home); + $awayTeamSheet = $this->scoresheetDataRepository->teamSheet($game, TeamSide::Away); + $pdf->SetFontSize(12); - $pdf->spacedPrint(341, 160, $game->homeTeam->country_code); - $pdf->spacedPrint(375, 160, $game->awayTeam->country_code); + $pdf->spacedPrint(341, 160, $homeTeamSheet['team_code']); + $pdf->spacedPrint(375, 160, $awayTeamSheet['team_code']); - $this->writePlayers($pdf, $game->homePlayers, 327); - $this->writePlayers($pdf, $game->awayPlayers, 367); + $this->writePlayers($pdf, $homeTeamSheet['players'], $homeTeamSheet['liberos'], 327); + $this->writePlayers($pdf, $awayTeamSheet['players'], $awayTeamSheet['liberos'], 367); - $this->writeStaff($pdf, $game->homeStaff, 327); - $this->writeStaff($pdf, $game->awayStaff, 372); + $this->writeStaff($pdf, $homeTeamSheet['staff'], 327); + $this->writeStaff($pdf, $awayTeamSheet['staff'], 372); } /** - * @param EloquentCollection $players + * @param array $players + * @param array $liberos */ - private function writePlayers(ScoresheetPdf $pdf, EloquentCollection $players, int $x): void + private function writePlayers(ScoresheetPdf $pdf, array $players, array $liberos, int $x): void { $y = [ 171, 175, 180, 184, 188, 193, 197, 201, 206, 210, 214, 219, 223, 227, ]; $pdf->SetFontSize(8); - $liberos = $players - ->filter(fn (Player $player) => $player->roster->is_libero) - ->sortBy('roster.number') - ->values(); - $players = $players - ->reject(fn (Player $player) => $player->roster->is_libero) - ->sortBy('roster.number') - ->values(); foreach ($players as $i => $player) { $pdf->SetXY($x, $y[$i]); - if ($player->roster->number < 10) { + if ($player['number'] < 10) { $pdf->SetX($x + 1); } - $pdf->Write(0, $player->roster->number); + $pdf->Write(0, (string) $player['number']); $pdf->SetXY($x + 5, $y[$i]); - $pdf->Write(0, $this->name($player)); + $pdf->Write(0, $this->formattedName($player['last_name'])); - if ($player->roster->is_captain) { + if ($player['is_captain']) { $pdf->circle($x + 2.5, $y[$i] - 0.45, 2.2, 0.4); } } @@ -62,45 +70,54 @@ private function writePlayers(ScoresheetPdf $pdf, EloquentCollection $players, i $y = 236; foreach ($liberos as $libero) { $pdf->SetXY($x, $y); - if ($libero->roster->number < 10) { + if ($libero['number'] < 10) { $pdf->SetX($x + 1); } - $pdf->Write(0, $libero->roster->number); + $pdf->Write(0, (string) $libero['number']); $pdf->SetXY($x + 5, $y); - $pdf->Write(0, $this->name($libero)); + $pdf->Write(0, $this->formattedName($libero['last_name'])); $y += 4; } } /** - * @param EloquentCollection $staff + * @param array $staff */ - private function writeStaff(ScoresheetPdf $pdf, EloquentCollection $staff, int $x): void + private function writeStaff(ScoresheetPdf $pdf, array $staff, int $x): void { $firstAssistantCoach = true; $pdf->SetFontSize(8); foreach ($staff as $staffMember) { - $y = match ($staffMember->roster->role) { + $y = match ($staffMember['role']) { StaffRole::Coach => 248, StaffRole::AssistantCoach => $firstAssistantCoach ? 252 : 256, StaffRole::Therapist => 260, StaffRole::Doctor => 264, }; $pdf->SetXY($x, $y); - $pdf->Write(0, $this->name($staffMember)); - if ($staffMember->roster->role === StaffRole::AssistantCoach) { + $pdf->Write(0, $this->formattedName($staffMember['last_name'], $staffMember['first_name'])); + if ($staffMember['role'] === StaffRole::AssistantCoach) { $firstAssistantCoach = false; } } } - private function name(Player|Staff $playerOrStaff): string + private function formattedName(string $lastName, ?string $firstName = null): string { - $lastName = str($playerOrStaff->last_name)->upper(); - $initial = str($playerOrStaff->first_name)->upper()->charAt(0); + $formattedLastName = str($lastName)->upper(); + $formattedFirstName = $firstName === null || $firstName === '' + ? '' + : str($firstName)->upper()->substr(0, 1)->toString(); - return "$lastName, $initial."; + return $formattedFirstName === '' + ? $formattedLastName->toString() + : "{$formattedLastName}, {$formattedFirstName}."; } } diff --git a/app/Services/ScoresheetDataRepository.php b/app/Services/ScoresheetDataRepository.php index 64c1f5a..43d7437 100644 --- a/app/Services/ScoresheetDataRepository.php +++ b/app/Services/ScoresheetDataRepository.php @@ -4,22 +4,100 @@ namespace App\Services; +use App\Data\GameState\GameState; use App\Enums\GameEventType; +use App\Enums\OfficialRole; use App\Enums\StaffRole; +use App\Enums\TeamAB; use App\Enums\TeamSide; use App\Events\Payloads\TossCompletedPayload; use App\Models\Game; use App\Models\GameEvent; +use App\Models\Official; use App\Models\Player; use App\Models\Staff; +use App\Services\GameState\GameStateProjector; +use Carbon\CarbonImmutable; +use Illuminate\Database\Eloquent\Collection as EloquentCollection; class ScoresheetDataRepository { + /** + * @return array{ + * competition_name: string, + * city: string, + * country_code: string, + * hall: string, + * pool: string, + * match_number: int, + * scheduled_at: CarbonImmutable, + * division: string, + * category: string, + * home_team_code: string, + * away_team_code: string + * } + */ + public function matchInfo(Game $game): array + { + $game->loadMissing(['homeTeam', 'awayTeam']); + + return [ + 'competition_name' => $game->competitionName(), + 'city' => $game->city, + 'country_code' => $game->country_code, + 'hall' => $game->hall, + 'pool' => $game->pool, + 'match_number' => $game->number, + 'scheduled_at' => $game->date_time, + 'division' => $game->division, + 'category' => $game->category, + 'home_team_code' => $game->homeTeam->country_code, + 'away_team_code' => $game->awayTeam->country_code, + ]; + } + + /** + * @return array{ + * team_code: string, + * players: array, + * liberos: array, + * staff: array + * } + */ + public function teamSheet(Game $game, TeamSide $side): array + { + $game->loadMissing(['homeTeam', 'awayTeam']); + + return [ + 'team_code' => $side === TeamSide::Home + ? $game->homeTeam->country_code + : $game->awayTeam->country_code, + 'players' => $this->playersForSide($game, $side), + 'liberos' => $this->liberosForSide($game, $side), + 'staff' => $this->staffForSide($game, $side), + ]; + } + /** * @return array */ public function playersForSide(Game $game, TeamSide $side): array @@ -29,21 +107,51 @@ public function playersForSide(Game $game, TeamSide $side): array : $game->awayPlayers(); return $players - ->wherePivot('is_libero', false) - ->orderByPivot('number') + ->where('is_libero', false) + ->orderBy('number') ->get() - ->map(fn (Player $player): array => [ + ->map(fn (Player $player): ?array => $player->number === null ? null : [ 'player_key' => $player->getKey(), - 'number' => $player->roster->number, + 'number' => $player->number, 'last_name' => $player->last_name, + 'is_captain' => $player->roster->is_captain, ]) + ->filter() + ->all(); + } + + /** + * @return array + */ + public function liberosForSide(Game $game, TeamSide $side): array + { + $players = $side === TeamSide::Home + ? $game->homePlayers() + : $game->awayPlayers(); + + return $players + ->where('is_libero', true) + ->orderBy('number') + ->get() + ->map(fn (Player $player): ?array => $player->number === null ? null : [ + 'player_key' => $player->getKey(), + 'number' => $player->number, + 'last_name' => $player->last_name, + ]) + ->filter() ->all(); } /** * @return array */ public function staffForSide(Game $game, TeamSide $side): array @@ -53,15 +161,55 @@ public function staffForSide(Game $game, TeamSide $side): array : $game->awayStaff(); return $staff - ->orderByPivot('id') + ->orderBy('id') ->get() ->map(fn (Staff $staffMember): array => [ 'staff_key' => $staffMember->getKey(), 'role' => $staffMember->roster->role, + 'last_name' => $staffMember->last_name, + 'first_name' => $staffMember->first_name, ]) ->all(); } + /** + * @return array + */ + public function officials(Game $game): array + { + /** @var EloquentCollection $officials */ + $officials = $game->officials()->get(); + + $officialsByRole = $officials->keyBy( + fn (Official $official): string => $official->role === null ? '' : $official->role->value, + ); + + return collect(OfficialRole::cases()) + ->map(function (OfficialRole $role) use ($officialsByRole): ?array { + /** @var Official|null $official */ + $official = $officialsByRole->get($role->value); + + if ($official === null) { + return null; + } + + return [ + 'role' => $role, + 'first_name' => $official->first_name, + 'last_name' => $official->last_name, + 'country_code' => $official->country_code, + ]; + }) + ->filter() + ->values() + ->all(); + } + public function latestTossPayload(Game $game): ?TossCompletedPayload { /** @var GameEvent|null $tossEvent */ @@ -79,4 +227,109 @@ public function latestTossPayload(Game $game): ?TossCompletedPayload /** @var TossCompletedPayload */ return $tossEvent->payload; } + + /** + * @return array{ + * team_a_code: string, + * team_b_code: string, + * winner_team_code: string, + * team_a_sets_won: int, + * team_b_sets_won: int, + * match_start_time: ?CarbonImmutable, + * match_end_time: ?CarbonImmutable, + * total_duration_minutes: ?int, + * total_set_duration_minutes: int, + * sets: array + * } + */ + public function results(Game $game): array + { + $game->loadMissing(['homeTeam', 'awayTeam']); + + $projector = new GameStateProjector; + $state = GameState::initial(); + $setStartedAt = null; + $matchStartedAt = null; + $matchEndedAt = null; + $totalSetDurationMinutes = 0; + $setSummaries = []; + + /** @var EloquentCollection $events */ + $events = $game->events()->get(); + + /** @var GameEvent $event */ + foreach ($events as $event) { + $stateBeforeEvent = GameState::fromAttributes($state->toAttributes()); + $state = $projector->project($state, $event); + + if ($event->type === GameEventType::SetStarted) { + $setStartedAt = $event->created_at; + $matchStartedAt ??= $event->created_at; + + continue; + } + + if ($event->type === GameEventType::SetEnded) { + $durationMinutes = (int) ($setStartedAt?->diffInMinutes($event->created_at) ?? 0); + $totalSetDurationMinutes += $durationMinutes; + $matchEndedAt = $event->created_at; + + $setSummaries[] = [ + 'set_number' => $stateBeforeEvent->setNumber, + 'team_a_points' => $stateBeforeEvent->scoreTeamA, + 'team_b_points' => $stateBeforeEvent->scoreTeamB, + 'team_a_timeouts' => $stateBeforeEvent->timeoutsTeamA, + 'team_b_timeouts' => $stateBeforeEvent->timeoutsTeamB, + 'team_a_substitutions' => $stateBeforeEvent->substitutionsTeamA, + 'team_b_substitutions' => $stateBeforeEvent->substitutionsTeamB, + 'team_a_sets_won' => $state->setsWonTeamA, + 'team_b_sets_won' => $state->setsWonTeamB, + 'duration_minutes' => $durationMinutes, + ]; + $setStartedAt = null; + + continue; + } + + if ($event->type === GameEventType::GameEnded) { + $matchEndedAt = $event->created_at; + } + } + + $winner = match (true) { + $state->setsWonTeamA > $state->setsWonTeamB => TeamAB::TeamA, + $state->setsWonTeamB > $state->setsWonTeamA => TeamAB::TeamB, + default => null, + }; + + return [ + 'team_a_code' => $game->homeTeam->country_code, + 'team_b_code' => $game->awayTeam->country_code, + 'winner_team_code' => match ($winner) { + TeamAB::TeamA => $game->homeTeam->country_code, + TeamAB::TeamB => $game->awayTeam->country_code, + default => '', + }, + 'team_a_sets_won' => $state->setsWonTeamA, + 'team_b_sets_won' => $state->setsWonTeamB, + 'match_start_time' => $matchStartedAt, + 'match_end_time' => $matchEndedAt, + 'total_duration_minutes' => $matchStartedAt !== null && $matchEndedAt !== null + ? (int) $matchStartedAt->diffInMinutes($matchEndedAt) + : null, + 'total_set_duration_minutes' => $totalSetDurationMinutes, + 'sets' => $setSummaries, + ]; + } } diff --git a/config/competition.php b/config/competition.php new file mode 100644 index 0000000..61ae986 --- /dev/null +++ b/config/competition.php @@ -0,0 +1,7 @@ + env('COMPETITION_NAME', env('APP_NAME', 'Laravel')), +]; diff --git a/database/factories/ChampionshipFactory.php b/database/factories/ChampionshipFactory.php deleted file mode 100644 index 52a4aa6..0000000 --- a/database/factories/ChampionshipFactory.php +++ /dev/null @@ -1,39 +0,0 @@ - - */ -class ChampionshipFactory extends Factory -{ - /** - * Define the model's default state. - * - * @return array - */ - public function definition(): array - { - /** @var string $name */ - $name = str($this->faker->words(3, true))->title(); /** @phpstan-ignore argument.type */ - - return [ - 'name' => $name.' Championship', - ]; - } - - /** - * Set the name of the championship. - */ - public function named(string $name): static - { - return $this->state(fn (array $attributes) => [ - 'name' => $name, - ]); - } -} diff --git a/database/factories/GameFactory.php b/database/factories/GameFactory.php index ca6b724..78e3490 100644 --- a/database/factories/GameFactory.php +++ b/database/factories/GameFactory.php @@ -4,7 +4,8 @@ namespace Database\Factories; -use App\Models\Championship; +use App\Enums\MatchPhase; +use App\Enums\TeamSide; use App\Models\Game; use App\Models\Team; use DateTimeInterface; @@ -27,7 +28,6 @@ public function definition(): array $code = $this->randomCountryCode(); return [ - 'championship_id' => Championship::factory(), 'home_team_id' => Team::factory(), 'away_team_id' => Team::factory(), 'number' => $this->faker->numberBetween(1, 99), @@ -38,9 +38,25 @@ public function definition(): array 'category' => $this->faker->randomElement(['Senior', 'Junior', 'Youth']), 'pool' => $this->faker->randomElement(['A', 'B', 'C', 'D']), 'division' => $this->faker->randomElement(['Men', 'Women']), + 'status' => MatchPhase::Setup, ]; } + public function configure(): static + { + return $this->afterCreating(function (Game $game): void { + $game->homeTeam->forceFill([ + 'game_id' => $game->getKey(), + 'side' => TeamSide::Home, + ])->save(); + + $game->awayTeam->forceFill([ + 'game_id' => $game->getKey(), + 'side' => TeamSide::Away, + ])->save(); + }); + } + /** * Set the teams for the match. */ @@ -92,4 +108,43 @@ public function scheduledAt(DateTimeInterface $dateTime): static 'date_time' => $dateTime, ]); } + + /** + * Leave match details blank so setup must complete them. + */ + public function withoutMatchDetails(): static + { + return $this->state(fn (array $attributes) => [ + 'number' => 1, + 'country_code' => '', + 'city' => '', + 'hall' => '', + 'date_time' => now(), + 'category' => '', + 'pool' => '', + 'division' => '', + ]); + } + + public function configuredSetup(): static + { + $code = 'ITA'; + + return $this->withMatchNumber(1) + ->withCountryCode($code) + ->at('Bologna', 'PalaDozza') + ->scheduledAt(now()->addDay()->setTime(20, 30)) + ->state(fn (array $attributes) => [ + 'category' => 'Senior', + 'pool' => 'A', + 'division' => 'Men', + ]); + } + + public function withStatus(MatchPhase $status): static + { + return $this->state(fn (array $attributes) => [ + 'status' => $status, + ]); + } } diff --git a/database/factories/OfficialFactory.php b/database/factories/OfficialFactory.php index af6afb5..90d44e8 100644 --- a/database/factories/OfficialFactory.php +++ b/database/factories/OfficialFactory.php @@ -4,6 +4,8 @@ namespace Database\Factories; +use App\Enums\OfficialRole; +use App\Models\Game; use App\Models\Official; use Illuminate\Database\Eloquent\Factories\Factory; @@ -29,4 +31,33 @@ public function definition(): array 'country_code' => $code, ]; } + + public function named(string $firstName, string $lastName): static + { + return $this->state(fn (array $attributes) => [ + 'first_name' => $firstName, + 'last_name' => $lastName, + ]); + } + + public function withCountryCode(string $code): static + { + return $this->state(fn (array $attributes) => [ + 'country_code' => $code, + ]); + } + + public function forMatch(Game $game): static + { + return $this->state(fn (array $attributes) => [ + 'game_id' => $game->getKey(), + ]); + } + + public function withRole(OfficialRole $role): static + { + return $this->state(fn (array $attributes) => [ + 'role' => $role, + ]); + } } diff --git a/database/factories/PlayerFactory.php b/database/factories/PlayerFactory.php index 7ff7ee0..24a910b 100644 --- a/database/factories/PlayerFactory.php +++ b/database/factories/PlayerFactory.php @@ -4,6 +4,7 @@ namespace Database\Factories; +use App\Models\Game; use App\Models\Player; use App\Models\Team; use Illuminate\Database\Eloquent\Factories\Factory; @@ -52,4 +53,39 @@ public function named(string $firstName, string $lastName): static 'last_name' => $lastName, ]); } + + public function forMatch(Game $game): static + { + return $this->state(fn (array $attributes) => [ + 'game_id' => $game->getKey(), + ]); + } + + public function rostered(): static + { + return $this->state(fn (array $attributes) => [ + 'is_rostered' => true, + ]); + } + + public function withNumber(int $number): static + { + return $this->state(fn (array $attributes) => [ + 'number' => $number, + ]); + } + + public function asCaptain(): static + { + return $this->state(fn (array $attributes) => [ + 'is_captain' => true, + ]); + } + + public function asLibero(): static + { + return $this->state(fn (array $attributes) => [ + 'is_libero' => true, + ]); + } } diff --git a/database/factories/StaffFactory.php b/database/factories/StaffFactory.php index ad84a08..db94d63 100644 --- a/database/factories/StaffFactory.php +++ b/database/factories/StaffFactory.php @@ -5,6 +5,7 @@ namespace Database\Factories; use App\Enums\StaffRole; +use App\Models\Game; use App\Models\Staff; use App\Models\Team; use Illuminate\Database\Eloquent\Factories\Factory; @@ -81,4 +82,18 @@ public function asTherapist(): static { return $this->withRole(StaffRole::Therapist); } + + public function forMatch(Game $game): static + { + return $this->state(fn (array $attributes) => [ + 'game_id' => $game->getKey(), + ]); + } + + public function rostered(): static + { + return $this->state(fn (array $attributes) => [ + 'is_rostered' => true, + ]); + } } diff --git a/database/factories/TeamFactory.php b/database/factories/TeamFactory.php index 9da4caf..6604a1f 100644 --- a/database/factories/TeamFactory.php +++ b/database/factories/TeamFactory.php @@ -4,6 +4,8 @@ namespace Database\Factories; +use App\Enums\TeamSide; +use App\Models\Game; use App\Models\Team; use Illuminate\Database\Eloquent\Factories\Factory; @@ -28,4 +30,31 @@ public function definition(): array 'country_code' => $code, ]; } + + public function named(string $name): static + { + return $this->state(fn (array $attributes) => [ + 'name' => $name, + ]); + } + + public function withCountryCode(string $code): static + { + return $this->state(fn (array $attributes) => [ + 'country_code' => $code, + ]); + } + + public function identifiedAs(string $name, string $countryCode): static + { + return $this->named($name)->withCountryCode($countryCode); + } + + public function forMatch(Game $game, TeamSide $side): static + { + return $this->state(fn (array $attributes) => [ + 'game_id' => $game->getKey(), + 'side' => $side, + ]); + } } diff --git a/database/factories/WithLocales.php b/database/factories/WithLocales.php index 9c967e1..441a1db 100644 --- a/database/factories/WithLocales.php +++ b/database/factories/WithLocales.php @@ -20,7 +20,7 @@ trait WithLocales private function randomCountryCode(): string { - return $this->faker->unique()->randomElement(array_keys($this->countries)); + return $this->faker->randomElement(array_keys($this->countries)); } private function getLocaleForCountry(string $countryCode): string diff --git a/database/migrations/2026_06_08_171247_refactor_schema_for_single_match_lifecycle.php b/database/migrations/2026_06_08_171247_refactor_schema_for_single_match_lifecycle.php new file mode 100644 index 0000000..32ea1f8 --- /dev/null +++ b/database/migrations/2026_06_08_171247_refactor_schema_for_single_match_lifecycle.php @@ -0,0 +1,186 @@ +foreignId('game_id')->nullable()->after('id')->constrained()->cascadeOnDelete(); + $table->string('side')->nullable()->after('game_id'); + $table->unique(['game_id', 'side']); + }); + + Schema::table('players', function (Blueprint $table): void { + $table->foreignId('game_id')->nullable()->after('id')->constrained()->cascadeOnDelete(); + $table->unsignedTinyInteger('number')->nullable()->after('last_name'); + $table->boolean('is_captain')->default(false)->after('number'); + $table->boolean('is_libero')->default(false)->after('is_captain'); + $table->boolean('is_rostered')->default(false)->after('is_libero'); + $table->unique(['team_id', 'number']); + }); + + Schema::table('staff', function (Blueprint $table): void { + $table->foreignId('game_id')->nullable()->after('id')->constrained()->cascadeOnDelete(); + $table->boolean('is_rostered')->default(false)->after('role'); + }); + + Schema::table('officials', function (Blueprint $table): void { + $table->foreignId('game_id')->nullable()->after('id')->constrained()->cascadeOnDelete(); + $table->string('role')->nullable()->after('country_code'); + $table->unique(['game_id', 'role']); + }); + + DB::table('games') + ->select(['id', 'home_team_id', 'away_team_id']) + ->orderBy('id') + ->get() + ->each(function (object $game): void { + DB::table('teams') + ->where('id', $game->home_team_id) + ->update([ + 'game_id' => $game->id, + 'side' => 'home', + ]); + + DB::table('teams') + ->where('id', $game->away_team_id) + ->update([ + 'game_id' => $game->id, + 'side' => 'away', + ]); + }); + + DB::table('game_player') + ->select(['game_id', 'player_id', 'number', 'is_captain', 'is_libero']) + ->orderBy('id') + ->get() + ->each(function (object $rosterPlayer): void { + DB::table('players') + ->where('id', $rosterPlayer->player_id) + ->update([ + 'game_id' => $rosterPlayer->game_id, + 'number' => $rosterPlayer->number, + 'is_captain' => $rosterPlayer->is_captain, + 'is_libero' => $rosterPlayer->is_libero, + 'is_rostered' => true, + ]); + }); + + DB::table('game_staff') + ->select(['game_id', 'staff_id', 'role']) + ->orderBy('id') + ->get() + ->each(function (object $rosterStaff): void { + DB::table('staff') + ->where('id', $rosterStaff->staff_id) + ->update([ + 'game_id' => $rosterStaff->game_id, + 'role' => $rosterStaff->role, + 'is_rostered' => true, + ]); + }); + + DB::table('game_official') + ->select(['game_id', 'official_id', 'role']) + ->orderBy('id') + ->get() + ->each(function (object $gameOfficial): void { + DB::table('officials') + ->where('id', $gameOfficial->official_id) + ->update([ + 'game_id' => $gameOfficial->game_id, + 'role' => $gameOfficial->role, + ]); + }); + + Schema::table('games', function (Blueprint $table): void { + $table->dropForeign(['championship_id']); + $table->dropColumn('championship_id'); + }); + + Schema::dropIfExists('game_player'); + Schema::dropIfExists('game_staff'); + Schema::dropIfExists('game_official'); + Schema::dropIfExists('championships'); + } + + public function down(): void + { + Schema::create('championships', function (Blueprint $table): void { + $table->id(); + $table->string('name'); + $table->timestamps(); + }); + + Schema::table('games', function (Blueprint $table): void { + $table->foreignId('championship_id')->nullable()->after('id')->constrained()->cascadeOnDelete(); + }); + + Schema::create('game_official', function (Blueprint $table): void { + $table->id(); + $table->foreignId('game_id')->constrained()->cascadeOnDelete(); + $table->foreignId('official_id')->constrained()->cascadeOnDelete(); + $table->string('role'); + $table->timestamps(); + }); + + Schema::create('game_player', function (Blueprint $table): void { + $table->id(); + $table->foreignId('game_id')->constrained()->cascadeOnDelete(); + $table->foreignId('player_id')->constrained()->cascadeOnDelete(); + $table->foreignId('team_id')->constrained()->cascadeOnDelete(); + $table->unsignedTinyInteger('number'); + $table->boolean('is_captain')->default(false); + $table->boolean('is_libero')->default(false); + $table->timestamps(); + }); + + Schema::create('game_staff', function (Blueprint $table): void { + $table->id(); + $table->foreignId('game_id')->constrained()->cascadeOnDelete(); + $table->foreignId('staff_id')->constrained()->cascadeOnDelete(); + $table->foreignId('team_id')->constrained()->cascadeOnDelete(); + $table->string('role'); + $table->timestamps(); + }); + + Schema::table('officials', function (Blueprint $table): void { + $table->dropUnique(['game_id', 'role']); + }); + + Schema::table('players', function (Blueprint $table): void { + $table->dropUnique(['team_id', 'number']); + }); + + Schema::table('teams', function (Blueprint $table): void { + $table->dropUnique(['game_id', 'side']); + }); + + Schema::table('officials', function (Blueprint $table): void { + $table->dropConstrainedForeignId('game_id'); + $table->dropColumn('role'); + }); + + Schema::table('staff', function (Blueprint $table): void { + $table->dropConstrainedForeignId('game_id'); + $table->dropColumn('is_rostered'); + }); + + Schema::table('players', function (Blueprint $table): void { + $table->dropConstrainedForeignId('game_id'); + $table->dropColumn(['number', 'is_captain', 'is_libero', 'is_rostered']); + }); + + Schema::table('teams', function (Blueprint $table): void { + $table->dropConstrainedForeignId('game_id'); + $table->dropColumn('side'); + }); + } +}; diff --git a/database/migrations/2026_06_08_174119_add_status_to_games_table.php b/database/migrations/2026_06_08_174119_add_status_to_games_table.php new file mode 100644 index 0000000..b1ff70a --- /dev/null +++ b/database/migrations/2026_06_08_174119_add_status_to_games_table.php @@ -0,0 +1,123 @@ +string('status')->default(MatchPhase::Setup->value)->after('rosters_submitted'); + }); + + $requiredOfficialRoleCount = count(OfficialRole::cases()); + + collect(DB::table('games')->select('id')->orderBy('id')->get()) + ->each(function (object $game) use ($requiredOfficialRoleCount): void { + $gameId = (int) $game->id; + $details = DB::table('games') + ->join('teams as home_teams', 'home_teams.id', '=', 'games.home_team_id') + ->join('teams as away_teams', 'away_teams.id', '=', 'games.away_team_id') + ->where('games.id', $gameId) + ->select([ + 'games.number', + 'games.country_code', + 'games.city', + 'games.hall', + 'games.division', + 'games.pool', + 'games.category', + 'games.rosters_submitted', + 'home_teams.id as home_team_id', + 'home_teams.name as home_team_name', + 'home_teams.country_code as home_team_country_code', + 'away_teams.id as away_team_id', + 'away_teams.name as away_team_name', + 'away_teams.country_code as away_team_country_code', + ]) + ->first(); + + if ($details === null) { + return; + } + + $hasRecordedEvents = DB::table('game_events') + ->where('game_id', $gameId) + ->exists(); + + $hasCompletedGame = DB::table('game_events') + ->where('game_id', $gameId) + ->where('type', GameEventType::GameEnded->value) + ->exists(); + + $status = MatchPhase::Setup; + + if ($hasCompletedGame) { + $status = MatchPhase::Completed; + } elseif ($hasRecordedEvents) { + $status = MatchPhase::InProgress; + } else { + $hasCompleteMatchDetails = (int) $details->number > 0 + && $details->country_code !== '' + && $details->city !== '' + && $details->hall !== '' + && $details->division !== '' + && $details->pool !== '' + && $details->category !== '' + && $details->home_team_name !== '' + && $details->home_team_country_code !== '' + && $details->away_team_name !== '' + && $details->away_team_country_code !== ''; + + $hasSubmittedInitialRosters = (bool) $details->rosters_submitted + && DB::table('players') + ->where('game_id', $gameId) + ->where('team_id', (int) $details->home_team_id) + ->where('is_rostered', true) + ->exists() + && DB::table('players') + ->where('game_id', $gameId) + ->where('team_id', (int) $details->away_team_id) + ->where('is_rostered', true) + ->exists(); + + $assignedOfficialRoleCount = DB::table('officials') + ->where('game_id', $gameId) + ->whereNotNull('role') + ->distinct() + ->count('role'); + + if ($hasCompleteMatchDetails + && $hasSubmittedInitialRosters + && $assignedOfficialRoleCount === $requiredOfficialRoleCount) { + $status = MatchPhase::Ready; + } + } + + DB::table('games') + ->where('id', $gameId) + ->update(['status' => $status->value]); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('games', function (Blueprint $table): void { + $table->dropColumn('status'); + }); + } +}; diff --git a/database/migrations/2026_06_20_151042_enforce_single_game_row_in_games_table.php b/database/migrations/2026_06_20_151042_enforce_single_game_row_in_games_table.php new file mode 100644 index 0000000..c5d8dcf --- /dev/null +++ b/database/migrations/2026_06_20_151042_enforce_single_game_row_in_games_table.php @@ -0,0 +1,38 @@ +orderByDesc('updated_at') + ->orderByDesc('id') + ->pluck('id') + ->skip(1) + ->all(); + + if ($gameIdsToRemove !== []) { + DB::table('games')->whereIn('id', $gameIdsToRemove)->delete(); + } + + DB::statement('CREATE UNIQUE INDEX games_singleton_row ON games ((1))'); + } + + public function down(): void + { + if (DB::getDriverName() !== 'sqlite') { + return; + } + + DB::statement('DROP INDEX IF EXISTS games_singleton_row'); + } +}; diff --git a/database/seeders/ControlledGameSeeder.php b/database/seeders/ControlledGameSeeder.php index 6ce9f4a..441a318 100644 --- a/database/seeders/ControlledGameSeeder.php +++ b/database/seeders/ControlledGameSeeder.php @@ -4,16 +4,16 @@ namespace Database\Seeders; +use App\Enums\MatchPhase; use App\Enums\OfficialRole; +use App\Enums\StaffRole; use App\Enums\TeamAB; use App\Enums\TeamSide; -use App\Models\Championship; use App\Models\Game; use App\Models\Official; use App\Models\Player; use App\Models\Staff; use App\Models\Team; -use Carbon\CarbonImmutable; use Illuminate\Database\Seeder; use Illuminate\Support\Facades\Config; @@ -21,8 +21,12 @@ class ControlledGameSeeder extends Seeder { private const string HOME_TEAM_NAME = 'Dev Home Team'; + private const string HOME_TEAM_COUNTRY = 'ITA'; + private const string AWAY_TEAM_NAME = 'Dev Away Team'; + private const string AWAY_TEAM_COUNTRY = 'FRA'; + /** * Run the database seeds. */ @@ -40,104 +44,153 @@ public function run(): void private function seedControlledGame(): void { - $championship = Championship::factory()->named('Dev Controlled Championship')->create(); - - $homeTeam = Team::factory()->create([ - 'name' => self::HOME_TEAM_NAME, - 'country_code' => 'ITA', - ]); - - $awayTeam = Team::factory()->create([ - 'name' => self::AWAY_TEAM_NAME, - 'country_code' => 'FRA', - ]); - - Player::factory() - ->for($homeTeam) - ->forCountry($homeTeam->country_code) - ->count(13) - ->create(); - - Player::factory() - ->for($awayTeam) - ->forCountry($awayTeam->country_code) - ->count(12) - ->create(); - - $homeStaff = [ - Staff::factory()->for($homeTeam)->forCountry($homeTeam->country_code)->asCoach()->create(), - Staff::factory()->for($homeTeam)->forCountry($homeTeam->country_code)->asAssistantCoach()->create(), - Staff::factory()->for($homeTeam)->forCountry($homeTeam->country_code)->asAssistantCoach()->create(), - Staff::factory()->for($homeTeam)->forCountry($homeTeam->country_code)->asDoctor()->create(), - Staff::factory()->for($homeTeam)->forCountry($homeTeam->country_code)->asTherapist()->create(), - ]; - - $awayStaff = [ - Staff::factory()->for($awayTeam)->forCountry($awayTeam->country_code)->asCoach()->create(), - Staff::factory()->for($awayTeam)->forCountry($awayTeam->country_code)->asAssistantCoach()->create(), - Staff::factory()->for($awayTeam)->forCountry($awayTeam->country_code)->asDoctor()->create(), - ]; - - $game = Game::factory() - ->for($championship, 'championship') - ->betweenTeams($homeTeam, $awayTeam) - ->withMatchNumber(1) - ->withCountryCode($homeTeam->country_code) - ->at('Bologna', 'PalaDozza') - ->scheduledAt(CarbonImmutable::now()->addDay()->setTime(20, 30)) - ->create(); - - foreach (OfficialRole::cases() as $role) { - $game->addOfficial(Official::factory()->create(), $role); - } - - $homeNumbers = range(1, 13); - $awayNumbers = range(20, 31); - - foreach ($homeTeam->players()->orderBy('id')->get() as $index => $player) { - $number = $homeNumbers[$index]; - - $game->addPlayer( - player: $player, - number: $number, - isCaptain: $index === 0, - isLibero: $number === 13, - ); - } - - foreach ($awayTeam->players()->orderBy('id')->get() as $index => $player) { - $game->addPlayer( - player: $player, - number: $awayNumbers[$index], - isCaptain: $index === 0, - isLibero: false, - ); + $game = Game::ensureSingleton( + gameAttributes: [ + 'number' => 1, + 'country_code' => self::HOME_TEAM_COUNTRY, + 'city' => 'Bologna', + 'hall' => 'PalaDozza', + 'date_time' => now()->addDay()->setTime(20, 30), + 'division' => 'Men', + 'pool' => 'A', + 'category' => 'Senior', + 'status' => MatchPhase::Setup, + ], + homeTeamAttributes: [ + 'name' => self::HOME_TEAM_NAME, + 'country_code' => self::HOME_TEAM_COUNTRY, + ], + awayTeamAttributes: [ + 'name' => self::AWAY_TEAM_NAME, + 'country_code' => self::AWAY_TEAM_COUNTRY, + ], + ); + + $game->resetForSetup(); + $game->load(['homeTeam', 'awayTeam', 'officials']); + + $this->seedRoster( + game: $game, + team: $game->homeTeam, + numbers: range(1, 12), + liberoNumber: 13, + staffRoles: [ + StaffRole::Coach, + StaffRole::AssistantCoach, + StaffRole::AssistantCoach, + StaffRole::Doctor, + StaffRole::Therapist, + ], + ); + + $this->seedRoster( + game: $game, + team: $game->awayTeam, + numbers: range(20, 30), + liberoNumber: 31, + staffRoles: [ + StaffRole::Coach, + StaffRole::AssistantCoach, + StaffRole::Doctor, + ], + ); + + foreach ($this->officialAssignments() as $assignment) { + Official::factory() + ->named($assignment['first_name'], $assignment['last_name']) + ->withCountryCode($assignment['country_code']) + ->forMatch($game) + ->withRole($assignment['role']) + ->create(); } - foreach ($homeStaff as $staff) { - $game->addStaff($staff); - } - - foreach ($awayStaff as $staff) { - $game->addStaff($staff); - } + $game->markRostersSubmitted(); $game->recordToss(TeamSide::Home, TeamAB::TeamA); $homeLineup = [1 => 1, 2 => 2, 3 => 3, 4 => 4, 5 => 5, 6 => 6]; $awayLineup = [1 => 20, 2 => 21, 3 => 22, 4 => 23, 5 => 24, 6 => 25]; - $setWinners = [TeamAB::TeamA, TeamAB::TeamB, TeamAB::TeamA, TeamAB::TeamB]; + $setWinners = [ + ['winner' => TeamAB::TeamA, 'winner_points' => 25, 'loser_points' => 18], + ['winner' => TeamAB::TeamB, 'winner_points' => 25, 'loser_points' => 22], + ['winner' => TeamAB::TeamA, 'winner_points' => 25, 'loser_points' => 19], + ['winner' => TeamAB::TeamA, 'winner_points' => 25, 'loser_points' => 21], + ]; - foreach ($setWinners as $index => $winner) { + foreach ($setWinners as $index => $set) { $setNumber = $index + 1; $game->recordLineup($setNumber, TeamAB::TeamA, $homeLineup); $game->recordLineup($setNumber, TeamAB::TeamB, $awayLineup); $game->recordSetStarted(); - for ($i = 0; $i < 25; $i++) { - $game->recordRallyWinner($winner); + $loser = $set['winner'] === TeamAB::TeamA ? TeamAB::TeamB : TeamAB::TeamA; + + foreach (range(1, $set['loser_points']) as $rally) { + $game->recordRallyWinner($loser); + } + + foreach (range(1, $set['winner_points']) as $rally) { + $game->recordRallyWinner($set['winner']); + } + } + } + + /** + * @param array $numbers + * @param array $staffRoles + */ + private function seedRoster(Game $game, Team $team, array $numbers, int $liberoNumber, array $staffRoles): void + { + foreach ($numbers as $index => $number) { + $playerFactory = Player::factory() + ->for($team) + ->forMatch($game) + ->forCountry($team->country_code) + ->withNumber($number) + ->rostered(); + + if ($index === 0) { + $playerFactory = $playerFactory->asCaptain(); } + + $playerFactory->create(); + } + + Player::factory() + ->for($team) + ->forMatch($game) + ->forCountry($team->country_code) + ->withNumber($liberoNumber) + ->asLibero() + ->rostered() + ->create(); + + foreach ($staffRoles as $role) { + Staff::factory() + ->for($team) + ->forMatch($game) + ->forCountry($team->country_code) + ->withRole($role) + ->rostered() + ->create(); } } + + /** + * @return array + */ + private function officialAssignments(): array + { + return [ + ['role' => OfficialRole::FirstReferee, 'first_name' => 'Marta', 'last_name' => 'Silva', 'country_code' => 'POR'], + ['role' => OfficialRole::SecondReferee, 'first_name' => 'Lukas', 'last_name' => 'Meyer', 'country_code' => 'GER'], + ['role' => OfficialRole::Scorer, 'first_name' => 'Ana', 'last_name' => 'Lopez', 'country_code' => 'ESP'], + ['role' => OfficialRole::AssistantScorer, 'first_name' => 'Sophie', 'last_name' => 'Martin', 'country_code' => 'BEL'], + ['role' => OfficialRole::LineJudge1, 'first_name' => 'Klara', 'last_name' => 'Novak', 'country_code' => 'CZE'], + ['role' => OfficialRole::LineJudge2, 'first_name' => 'Milan', 'last_name' => 'Kovac', 'country_code' => 'SRB'], + ['role' => OfficialRole::LineJudge3, 'first_name' => 'Elin', 'last_name' => 'Berg', 'country_code' => 'SWE'], + ['role' => OfficialRole::LineJudge4, 'first_name' => 'Noah', 'last_name' => 'Van Dijk', 'country_code' => 'NED'], + ]; + } } diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 0c2a565..7ed1640 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -4,8 +4,9 @@ namespace Database\Seeders; +use App\Enums\MatchPhase; use App\Enums\OfficialRole; -use App\Models\Championship; +use App\Enums\StaffRole; use App\Models\Game; use App\Models\Official; use App\Models\Player; @@ -14,61 +15,156 @@ use App\Models\User; use Illuminate\Database\Console\Seeds\WithoutModelEvents; use Illuminate\Database\Seeder; -use Illuminate\Support\Collection; class DatabaseSeeder extends Seeder { use WithoutModelEvents; + private const string HOME_TEAM_NAME = 'Bologna VC'; + + private const string HOME_TEAM_COUNTRY = 'ITA'; + + private const string AWAY_TEAM_NAME = 'Paris Volley'; + + private const string AWAY_TEAM_COUNTRY = 'FRA'; + /** * Seed the application's database. */ public function run(): void { - /** @var Championship $championship */ - $championship = Championship::factory()->create(); + $this->seedConfiguredMatch(); + + User::query()->firstOrCreate( + ['email' => 'test@example.com'], + User::factory()->testUser()->make()->getAttributes(), + ); + } + + private function seedConfiguredMatch(): Game + { + $game = Game::ensureSingleton( + gameAttributes: [ + 'number' => 1, + 'country_code' => self::HOME_TEAM_COUNTRY, + 'city' => 'Bologna', + 'hall' => 'PalaDozza', + 'date_time' => now()->addDay()->setTime(20, 30), + 'division' => 'Men', + 'pool' => 'A', + 'category' => 'Senior', + 'status' => MatchPhase::Setup, + ], + homeTeamAttributes: [ + 'name' => self::HOME_TEAM_NAME, + 'country_code' => self::HOME_TEAM_COUNTRY, + ], + awayTeamAttributes: [ + 'name' => self::AWAY_TEAM_NAME, + 'country_code' => self::AWAY_TEAM_COUNTRY, + ], + ); + + $game->resetForSetup(); + $game->load(['homeTeam', 'awayTeam', 'officials']); + + $this->seedRoster( + game: $game, + team: $game->homeTeam, + numbers: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], + liberoNumber: 13, + staffRoles: [ + StaffRole::Coach, + StaffRole::AssistantCoach, + StaffRole::AssistantCoach, + StaffRole::Doctor, + StaffRole::Therapist, + ], + ); + + $this->seedRoster( + game: $game, + team: $game->awayTeam, + numbers: [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 14], + liberoNumber: 15, + staffRoles: [ + StaffRole::Coach, + StaffRole::AssistantCoach, + StaffRole::AssistantCoach, + StaffRole::Doctor, + StaffRole::Therapist, + ], + ); + + foreach ($this->officialAssignments() as $assignment) { + Official::factory() + ->named($assignment['first_name'], $assignment['last_name']) + ->withCountryCode($assignment['country_code']) + ->forMatch($game) + ->withRole($assignment['role']) + ->create(); + } + + $game->markRostersSubmitted(); + + return $game; + } - // Create teams and players - /** @var Team[] $teams */ - $teams = Team::factory()->count(2)->create()->each(function (Team $team) { - Player::factory() + /** + * @param array $numbers + * @param array $staffRoles + */ + private function seedRoster(Game $game, Team $team, array $numbers, int $liberoNumber, array $staffRoles): void + { + foreach ($numbers as $index => $number) { + $playerFactory = Player::factory() ->for($team) + ->forMatch($game) ->forCountry($team->country_code) - ->count(14) - ->create(); + ->withNumber($number) + ->rostered(); - Staff::factory()->for($team)->forCountry($team->country_code)->asCoach()->create(); - Staff::factory()->for($team)->forCountry($team->country_code)->asAssistantCoach()->create(); - Staff::factory()->for($team)->forCountry($team->country_code)->asAssistantCoach()->create(); - Staff::factory()->for($team)->forCountry($team->country_code)->asTherapist()->create(); - Staff::factory()->for($team)->forCountry($team->country_code)->asDoctor()->create(); - }); - - /** @var Collection $officials */ - $officials = Official::factory()->count(8)->create(); - - /** @var Game $game */ - $game = Game::factory() - ->for($championship, 'championship') - ->betweenTeams($teams[0], $teams[1]) - ->scheduledAt(now()->addDays(1)) - ->create(); + if ($index === 0) { + $playerFactory = $playerFactory->asCaptain(); + } - // Assign officials to the match. Leave rosters empty so the UI starts at roster submission. - $roles = OfficialRole::cases(); - /** @var Collection $shuffledOfficials */ - $shuffledOfficials = $officials - ->reject(fn (Official $official) => $official->country_code === $game->homeTeam->country_code) - ->reject(fn (Official $official) => $official->country_code === $game->awayTeam->country_code) - ->shuffle(); - foreach ($roles as $index => $role) { - /** @var Official $official */ - $official = $shuffledOfficials[$index]; - $game->addOfficial($official, $role); + $playerFactory->create(); } - User::factory() - ->testUser() + Player::factory() + ->for($team) + ->forMatch($game) + ->forCountry($team->country_code) + ->withNumber($liberoNumber) + ->asLibero() + ->rostered() ->create(); + + foreach ($staffRoles as $role) { + Staff::factory() + ->for($team) + ->forMatch($game) + ->forCountry($team->country_code) + ->withRole($role) + ->rostered() + ->create(); + } + } + + /** + * @return array + */ + private function officialAssignments(): array + { + return [ + ['role' => OfficialRole::FirstReferee, 'first_name' => 'Marta', 'last_name' => 'Silva', 'country_code' => 'POR'], + ['role' => OfficialRole::SecondReferee, 'first_name' => 'Lukas', 'last_name' => 'Meyer', 'country_code' => 'GER'], + ['role' => OfficialRole::Scorer, 'first_name' => 'Ana', 'last_name' => 'Lopez', 'country_code' => 'ESP'], + ['role' => OfficialRole::AssistantScorer, 'first_name' => 'Sophie', 'last_name' => 'Martin', 'country_code' => 'BEL'], + ['role' => OfficialRole::LineJudge1, 'first_name' => 'Klara', 'last_name' => 'Novak', 'country_code' => 'CZE'], + ['role' => OfficialRole::LineJudge2, 'first_name' => 'Milan', 'last_name' => 'Kovac', 'country_code' => 'SRB'], + ['role' => OfficialRole::LineJudge3, 'first_name' => 'Elin', 'last_name' => 'Berg', 'country_code' => 'SWE'], + ['role' => OfficialRole::LineJudge4, 'first_name' => 'Noah', 'last_name' => 'Van Dijk', 'country_code' => 'NED'], + ]; } } diff --git a/resources/views/livewire/court.blade.php b/resources/views/livewire/court.blade.php index 4818cd9..66adaf1 100644 --- a/resources/views/livewire/court.blade.php +++ b/resources/views/livewire/court.blade.php @@ -356,13 +356,11 @@ class="size-12"
@unless ($isBeforeInitialToss) - + @endunless - - - - + + +
diff --git a/resources/views/livewire/match-setup.blade.php b/resources/views/livewire/match-setup.blade.php new file mode 100644 index 0000000..47ad4b0 --- /dev/null +++ b/resources/views/livewire/match-setup.blade.php @@ -0,0 +1,415 @@ +
+
+ +
+
+ Match Setup +
+ Prepare the current match before play starts + + Competition metadata comes from app config. Enter the match details, both team rosters, and all officials here before recording tosses, lineups, or rallies. + +
+
+ +
+
Competition
+
{{ $competitionName }}
+
+
+ + @error('setup') + {{ $message }} + @enderror +
+ + @if ($step === 'missing-match') + +
+
+ Create the current match + + The database does not have a current match yet. Create the singleton match record, then complete the setup flow. + +
+ +
+ + Create current match + +
+
+
+ @elseif ($game !== null) + @php + $stepOrder = ['match-details', 'rosters', 'officials', 'ready']; + @endphp + +
+ +
+ @foreach ($setupSteps as $stepKey => $stepLabel) + @php + $requiredIndex = array_search($currentRequiredStep, $stepOrder, true); + $stepIndex = array_search($stepKey, $stepOrder, true); + $isComplete = $isSetupComplete ? $stepKey !== 'ready' : ($requiredIndex !== false && $stepIndex !== false && $stepIndex < $requiredIndex); + $isCurrent = $step === $stepKey; + @endphp + + + @endforeach +
+ +
+
Current Match
+
+ {{ $homeTeamName !== '' ? $homeTeamName : 'Home team' }} + vs + {{ $awayTeamName !== '' ? $awayTeamName : 'Away team' }} +
+
+ {{ $city !== '' ? $city : 'City' }}{{ $hall !== '' ? ', '.$hall : '' }} +
+
+
+ +
+ @if ($isSetupLocked) + + Setup is locked + + Match details, rosters, and officials are read-only after the first match event is recorded. Review the summary below and continue scoring from the current match page. + + + @elseif ($step === 'match-details') + +
+
+ Match details + + Enter the static metadata for the one current match, including the home and away team identity. + +
+ +
+
+ + @error('matchNumber') + {{ $message }} + @enderror +
+
+ + @error('matchCountryCode') + {{ $message }} + @enderror +
+
+ + @error('matchDateTime') + {{ $message }} + @enderror +
+
+ + @error('city') + {{ $message }} + @enderror +
+
+ + @error('hall') + {{ $message }} + @enderror +
+
+ + @error('pool') + {{ $message }} + @enderror +
+
+ +
+ + + + + + + + + + +
+ + @error('division') + {{ $message }} + @enderror + @error('category') + {{ $message }} + @enderror + +
+
+ Home team +
+ + @error('homeTeamName') + {{ $message }} + @enderror +
+
+ + @error('homeTeamCountryCode') + {{ $message }} + @enderror +
+
+ +
+ Away team +
+ + @error('awayTeamName') + {{ $message }} + @enderror +
+
+ + @error('awayTeamCountryCode') + {{ $message }} + @enderror +
+
+
+ +
+ Save match details +
+
+
+ @elseif ($step === 'rosters') + +
+
+ Home and away rosters + + Enter the match roster directly for each team. Every listed player becomes part of the current match roster. + +
+ +
+ @foreach ([ + 'home' => ['label' => $homeTeamName !== '' ? $homeTeamName : 'Home team', 'players' => $homePlayerRows, 'staff' => $homeStaffRows], + 'away' => ['label' => $awayTeamName !== '' ? $awayTeamName : 'Away team', 'players' => $awayPlayerRows, 'staff' => $awayStaffRows], + ] as $side => $team) +
+
+
+ {{ $team['label'] }} + Players, captain, libero selection, and bench staff. +
+ + Add player + +
+ + @error($side.'PlayerRows') + {{ $message }} + @enderror + +
+
+ First name + Last name + Number + Captain + Libero + Row +
+ + @foreach ($team['players'] as $index => $playerRow) +
+
+ + @error($side.'PlayerRows.'.$index.'.first_name') + {{ $message }} + @enderror +
+
+ + @error($side.'PlayerRows.'.$index.'.last_name') + {{ $message }} + @enderror +
+
+ + @error($side.'PlayerRows.'.$index.'.number') + {{ $message }} + @enderror +
+
+ +
+
+ +
+
+ + Remove + +
+
+ @endforeach +
+ +
+ Bench staff + + @foreach ($team['staff'] as $index => $staffRow) +
+
+ {{ $staffRow['role'] }} +
+
+ + @error($side.'StaffRows.'.$index.'.first_name') + {{ $message }} + @enderror +
+
+ + @error($side.'StaffRows.'.$index.'.last_name') + {{ $message }} + @enderror +
+
+ @endforeach +
+
+ @endforeach +
+ +
+ Save rosters +
+
+
+ @elseif ($step === 'officials') + +
+
+ Officials + + Enter the assigned officials for the scoresheet before moving into gameplay. + +
+ +
+ @foreach ($officialRows as $index => $officialRow) +
+ {{ $officialRow['role'] }} + +
+
+ + @error('officialRows.'.$index.'.first_name') + {{ $message }} + @enderror +
+
+ + @error('officialRows.'.$index.'.last_name') + {{ $message }} + @enderror +
+
+ +
+ + @error('officialRows.'.$index.'.country_code') + {{ $message }} + @enderror +
+
+ @endforeach +
+ +
+ Save officials +
+
+
+ @endif + + @if ($step === 'ready' || $isSetupLocked) + +
+
+ Match ready + + Static setup is complete. Use the scoring screen for tosses, lineups, and all event recording. + +
+ +
+
+
Fixture
+
{{ $homeTeamName }} vs {{ $awayTeamName }}
+
+
+
Venue
+
{{ $city }}, {{ $hall }}
+
+
+
Rosters
+
{{ collect($homePlayerRows)->filter(fn ($row) => trim($row['number']) !== '')->count() }} / {{ collect($awayPlayerRows)->filter(fn ($row) => trim($row['number']) !== '')->count() }}
+
+
+
Officials
+
{{ collect($officialRows)->filter(fn ($row) => trim($row['first_name']) !== '' && trim($row['last_name']) !== '')->count() }} assigned
+
+
+ +
+ @if ($isSetupLocked) + Setup changes are disabled because match events already exist. + @else + Setup remains editable here until the first match event is recorded. After that point, rosters and officials are locked for the rest of the match. + @endif +
+ + +
+
+ @endif +
+
+ @endif +
+
diff --git a/resources/views/welcome.blade.php b/resources/views/welcome.blade.php deleted file mode 100644 index b7355d7..0000000 --- a/resources/views/welcome.blade.php +++ /dev/null @@ -1,277 +0,0 @@ - - - - - - - {{ config('app.name', 'Laravel') }} - - - - - - - @if (file_exists(public_path('build/manifest.json')) || file_exists(public_path('hot'))) - @vite(['resources/css/app.css', 'resources/js/app.js']) - @else - - @endif - - -
- @if (Route::has('login')) - - @endif -
-
-
-
-

Let's get started

-

Laravel has an incredibly rich ecosystem.
We suggest starting with the following.

- - -
-
- {{-- Laravel Logo --}} - - - - - - - - - - - {{-- Light Mode 12 SVG --}} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {{-- Dark Mode 12 SVG --}} - -
-
-
-
- - @if (Route::has('login')) - - @endif - - diff --git a/routes/web.php b/routes/web.php index 8eb0fe3..b8585ef 100644 --- a/routes/web.php +++ b/routes/web.php @@ -3,7 +3,10 @@ declare(strict_types=1); use App\Livewire\Game; +use App\Livewire\MatchSetup; +use App\Services\CurrentMatchResolver; use Illuminate\Support\Facades\Route; -Route::get('/', fn () => view('welcome')); -Route::livewire('/game/{game}', Game::class)->name('game'); +Route::get('/', fn (CurrentMatchResolver $currentMatchResolver) => redirect()->route($currentMatchResolver->landingRouteName()))->name('home'); +Route::livewire('/setup', MatchSetup::class)->name('match.setup'); +Route::livewire('/game', Game::class)->name('game'); diff --git a/tests/Feature/ControlledGameSeederTest.php b/tests/Feature/ControlledGameSeederTest.php index 1eb7d6e..10b8396 100644 --- a/tests/Feature/ControlledGameSeederTest.php +++ b/tests/Feature/ControlledGameSeederTest.php @@ -5,19 +5,25 @@ use App\Enums\OfficialRole; use App\Enums\StaffRole; use App\Models\Game; +use App\Models\GameStateSnapshot; use App\Models\Player; use App\Models\Staff; +use App\Models\Team; use Database\Seeders\ControlledGameSeeder; use Illuminate\Foundation\Testing\RefreshDatabase; uses(RefreshDatabase::class); -test('controlled game seeder creates predictable teams and game rosters', function (): void { +test('controlled game seeder creates one deterministic completed match', function (): void { config()->set('game.between_sets_duration', 180); $this->seed(ControlledGameSeeder::class); $game = Game::query()->with(['homeTeam', 'awayTeam', 'players', 'staff', 'officials'])->sole(); + $latestSnapshot = GameStateSnapshot::query() + ->where('game_id', $game->getKey()) + ->latest('game_event_id') + ->firstOrFail(); $homePlayers = $game->players ->filter(fn (Player $player): bool => $player->roster->team_id === $game->home_team_id) ->values(); @@ -31,10 +37,16 @@ ->filter(fn (Staff $staff): bool => $staff->roster->team_id === $game->away_team_id) ->values(); - expect($game->homeTeam->name)->toBe('Dev Home Team') + expect(Game::query()->count())->toBe(1) + ->and(Team::query()->count())->toBe(2) + ->and($game->homeTeam->name)->toBe('Dev Home Team') ->and($game->awayTeam->name)->toBe('Dev Away Team') ->and($homePlayers)->toHaveCount(13) - ->and($awayPlayers)->toHaveCount(12); + ->and($awayPlayers)->toHaveCount(12) + ->and($game->officials)->toHaveCount(count(OfficialRole::cases())) + ->and($latestSnapshot->game_ended)->toBeTrue() + ->and($latestSnapshot->sets_won_team_a)->toBe(3) + ->and($latestSnapshot->sets_won_team_b)->toBe(1); $homeRosterNumbers = $homePlayers ->sortBy(fn (Player $player): int => $player->roster->number) @@ -51,7 +63,7 @@ expect($homeRosterNumbers)->toBe(range(1, 13)) ->and($awayRosterNumbers)->toBe(range(20, 31)) ->and($homePlayers->filter(fn (Player $player): bool => $player->roster->is_libero)->count())->toBe(1) - ->and($awayPlayers->filter(fn (Player $player): bool => $player->roster->is_libero)->count())->toBe(0); + ->and($awayPlayers->filter(fn (Player $player): bool => $player->roster->is_libero)->count())->toBe(1); expect($homeStaff)->toHaveCount(5) ->and($awayStaff)->toHaveCount(3); @@ -73,8 +85,6 @@ ->and($awayRoleCounts[StaffRole::AssistantCoach->value] ?? 0)->toBe(1) ->and($awayRoleCounts[StaffRole::Doctor->value] ?? 0)->toBe(1) ->and($awayRoleCounts[StaffRole::Therapist->value] ?? 0)->toBe(0); - - expect($game->officials)->toHaveCount(count(OfficialRole::cases())); }); test('controlled game seeder restores the configured between-sets interval after seeding', function (): void { @@ -84,3 +94,27 @@ expect(config('game.between_sets_duration'))->toBe(180); }); + +test('controlled game seeder reuses the singleton match when run again', function (): void { + $this->seed(ControlledGameSeeder::class); + + $originalGame = Game::query()->sole(); + + $this->seed(ControlledGameSeeder::class); + + $game = Game::query()->with(['homeTeam', 'awayTeam', 'players', 'staff', 'officials'])->sole(); + $latestSnapshot = GameStateSnapshot::query() + ->where('game_id', $game->getKey()) + ->latest('game_event_id') + ->firstOrFail(); + + expect(Game::query()->count())->toBe(1) + ->and($game->getKey())->toBe($originalGame->getKey()) + ->and(Team::query()->count())->toBe(2) + ->and($game->players)->toHaveCount(25) + ->and($game->staff)->toHaveCount(8) + ->and($game->officials)->toHaveCount(count(OfficialRole::cases())) + ->and($latestSnapshot->game_ended)->toBeTrue() + ->and($latestSnapshot->sets_won_team_a)->toBe(3) + ->and($latestSnapshot->sets_won_team_b)->toBe(1); +}); diff --git a/tests/Feature/CourtTest.php b/tests/Feature/CourtTest.php index ea12fc9..06124cb 100644 --- a/tests/Feature/CourtTest.php +++ b/tests/Feature/CourtTest.php @@ -31,7 +31,7 @@ test('court does not show player lists before toss is submitted', function (): void { $game = gameWithNumberedRosters(); - Livewire::test(Court::class, ['gameId' => $game->getKey(), 'gameState' => gameState(['set_number' => 0])]) + Livewire::test(Court::class, ['gameState' => gameState(['set_number' => 0])]) ->assertDontSee('Submit Lineup') ->assertDontSeeHtml('data-team-roster-number="3"') ->assertDontSeeHtml('data-team-roster-number="12"') @@ -53,7 +53,7 @@ $game = gameWithNumberedRosters(); $game->recordToss(TeamSide::Home, TeamAB::TeamA); - Livewire::test(Court::class, ['gameId' => $game->getKey(), 'gameState' => gameStateWithSubmittedLineups(['set_number' => 1])]) + Livewire::test(Court::class, ['gameState' => gameStateWithSubmittedLineups(['set_number' => 1])]) ->assertSeeInOrder([ '3', '12', @@ -74,7 +74,6 @@ $game->recordToss(TeamSide::Home, TeamAB::TeamA); Livewire::test(Court::class, [ - 'gameId' => $game->getKey(), 'gameState' => gameState([ 'set_number' => 1, 'rotation_team_a' => [1 => 3], @@ -91,7 +90,7 @@ $game = gameWithNumberedRosters(); $game->recordToss(TeamSide::Home, TeamAB::TeamA); - Livewire::test(Court::class, ['gameId' => $game->getKey(), 'gameState' => gameStateWithSubmittedLineups(['set_number' => 2, 'sets_won_team_a' => 1])]) + Livewire::test(Court::class, ['gameState' => gameStateWithSubmittedLineups(['set_number' => 2, 'sets_won_team_a' => 1])]) ->assertSeeInOrder([ '2', '9', @@ -99,7 +98,7 @@ '12', ]); - Livewire::test(Court::class, ['gameId' => $game->getKey(), 'gameState' => gameStateWithSubmittedLineups(['set_number' => 3, 'sets_won_team_a' => 1, 'sets_won_team_b' => 1])]) + Livewire::test(Court::class, ['gameState' => gameStateWithSubmittedLineups(['set_number' => 3, 'sets_won_team_a' => 1, 'sets_won_team_b' => 1])]) ->assertSeeInOrder([ '3', '12', @@ -107,7 +106,7 @@ '9', ]); - Livewire::test(Court::class, ['gameId' => $game->getKey(), 'gameState' => gameStateWithSubmittedLineups(['set_number' => 4, 'sets_won_team_a' => 2, 'sets_won_team_b' => 1])]) + Livewire::test(Court::class, ['gameState' => gameStateWithSubmittedLineups(['set_number' => 4, 'sets_won_team_a' => 2, 'sets_won_team_b' => 1])]) ->assertSeeInOrder([ '2', '9', @@ -149,7 +148,6 @@ foreach ($setExpectations as $state) { Livewire::test(Court::class, [ - 'gameId' => $game->getKey(), 'gameState' => gameStateWithSubmittedLineups([ 'set_number' => $state['set_number'], 'sets_won_team_a' => $state['sets_won_team_a'], @@ -163,7 +161,7 @@ $game = gameWithNumberedRosters(); $game->recordToss(TeamSide::Away, TeamAB::TeamA); - Livewire::test(Court::class, ['gameId' => $game->getKey(), 'gameState' => gameStateWithSubmittedLineups(['set_number' => 1])]) + Livewire::test(Court::class, ['gameState' => gameStateWithSubmittedLineups(['set_number' => 1])]) ->assertSeeInOrder([ '2', '9', @@ -171,7 +169,7 @@ '12', ]); - Livewire::test(Court::class, ['gameId' => $game->getKey(), 'gameState' => gameStateWithSubmittedLineups(['set_number' => 5, 'sets_won_team_a' => 2, 'sets_won_team_b' => 2])]) + Livewire::test(Court::class, ['gameState' => gameStateWithSubmittedLineups(['set_number' => 5, 'sets_won_team_a' => 2, 'sets_won_team_b' => 2])]) ->assertSeeInOrder([ '2', '9', @@ -185,7 +183,6 @@ $game->recordToss(TeamSide::Home, TeamAB::TeamA); Livewire::test(Court::class, [ - 'gameId' => $game->getKey(), 'gameState' => gameState([ 'set_number' => 1, 'rotation_team_a' => [1 => 12], @@ -203,7 +200,6 @@ $game->recordToss(TeamSide::Home, TeamAB::TeamA); Livewire::test(Court::class, [ - 'gameId' => $game->getKey(), 'gameState' => gameState([ 'set_number' => 2, 'sets_won_team_a' => 1, @@ -222,7 +218,6 @@ $game->recordToss(TeamSide::Home, TeamAB::TeamA); Livewire::test(Court::class, [ - 'gameId' => $game->getKey(), 'gameState' => gameState([ 'set_number' => 1, 'serving_team' => TeamAB::TeamA->value, @@ -242,7 +237,6 @@ $game->recordToss(TeamSide::Home, TeamAB::TeamA); Livewire::test(Court::class, [ - 'gameId' => $game->getKey(), 'gameState' => gameState([ 'set_number' => 2, 'sets_won_team_a' => 1, @@ -270,7 +264,6 @@ $game->recordSetStarted(); Livewire::test(Court::class, [ - 'gameId' => $game->getKey(), 'gameState' => $game->stateAt(), ]) ->assertSeeHtml('data-court-marker="left-team_b-1"') @@ -292,7 +285,6 @@ $game->recordSetStarted(); Livewire::test(Court::class, [ - 'gameId' => $game->getKey(), 'gameState' => $game->stateAt(), ]) ->assertSeeHtml('data-court-marker="left-team_b-1"') @@ -307,7 +299,6 @@ $game->recordToss(TeamSide::Home, TeamAB::TeamA); Livewire::test(Court::class, [ - 'gameId' => $game->getKey(), 'gameState' => gameStateWithSubmittedLineups([ 'set_number' => 1, 'sets_won_team_a' => 1, @@ -327,7 +318,6 @@ $game->recordToss(TeamSide::Home, TeamAB::TeamA); Livewire::test(Court::class, [ - 'gameId' => $game->getKey(), 'gameState' => gameState([ 'set_number' => 1, 'set_in_progress' => false, @@ -343,7 +333,6 @@ $game->recordToss(TeamSide::Home, TeamAB::TeamA); Livewire::test(Court::class, [ - 'gameId' => $game->getKey(), 'gameState' => gameState([ 'set_number' => 1, 'sets_won_team_a' => 1, @@ -360,7 +349,6 @@ $game->recordToss(TeamSide::Home, TeamAB::TeamA); Livewire::test(Court::class, [ - 'gameId' => $game->getKey(), 'gameState' => gameState([ 'set_number' => 4, 'sets_won_team_a' => 2, @@ -387,7 +375,6 @@ $game->recordToss(TeamSide::Home, TeamAB::TeamA); Livewire::test(Court::class, [ - 'gameId' => $game->getKey(), 'gameState' => gameState([ 'set_number' => 5, 'sets_won_team_a' => 2, @@ -410,7 +397,6 @@ $game = Game::factory()->create(); Livewire::test(Court::class, [ - 'gameId' => $game->getKey(), 'gameState' => gameState(['set_number' => 1, 'set_in_progress' => false]), ]) ->assertDontSee('Winner') @@ -418,7 +404,6 @@ ->assertDontSeeHtml('data-rally-winner-button="team_b"'); Livewire::test(Court::class, [ - 'gameId' => $game->getKey(), 'gameState' => gameState(['set_number' => 1, 'set_in_progress' => true]), ]) ->assertSee('Winner') @@ -431,7 +416,6 @@ $game->recordToss(TeamSide::Home, TeamAB::TeamA); Livewire::test(Court::class, [ - 'gameId' => $game->getKey(), 'gameState' => gameStateWithSubmittedLineups(['set_number' => 1]), ]) ->assertSeeHtml('data-misconduct-controls="left"') @@ -468,7 +452,6 @@ $game = Game::factory()->create(); Livewire::test(RallyWinnerControls::class, [ - 'gameId' => $game->getKey(), 'gameState' => gameState(['set_number' => 1, 'set_in_progress' => false]), 'side' => 'left', ]) @@ -476,7 +459,6 @@ ->assertDontSeeHtml('data-rally-winner-button="team_a"'); Livewire::test(RallyWinnerControls::class, [ - 'gameId' => $game->getKey(), 'gameState' => gameState(['set_number' => 1, 'set_in_progress' => true]), 'side' => 'left', ]) @@ -484,7 +466,6 @@ ->assertSeeHtml('data-rally-winner-button="team_a"'); Livewire::test(RallyWinnerControls::class, [ - 'gameId' => $game->getKey(), 'gameState' => gameState(['set_number' => 1, 'set_in_progress' => true]), 'side' => 'right', ]) @@ -492,7 +473,6 @@ ->assertSeeHtml('data-rally-winner-button="team_b"'); Livewire::test(RallyWinnerControls::class, [ - 'gameId' => $game->getKey(), 'gameState' => gameState(['set_number' => 5, 'set_in_progress' => true, 'game_ended' => true]), 'side' => 'left', ]) @@ -510,14 +490,12 @@ ]); Livewire::test(RallyWinnerControls::class, [ - 'gameId' => $game->getKey(), 'gameState' => $state, 'side' => 'left', ]) ->assertSeeHtml('data-rally-winner-side-team="left-team_b"'); Livewire::test(RallyWinnerControls::class, [ - 'gameId' => $game->getKey(), 'gameState' => $state, 'side' => 'right', ]) @@ -528,7 +506,6 @@ $game = gameWithStartedSet(); Livewire::test(RallyWinnerControls::class, [ - 'gameId' => $game->getKey(), 'gameState' => gameState(['set_number' => 1, 'set_in_progress' => true]), ]) ->assertSeeHtml('data-rally-winner-button="team_a"') @@ -549,7 +526,6 @@ $game = gameWithStartedSet(); Livewire::test(Court::class, [ - 'gameId' => $game->getKey(), 'gameState' => $game->stateAt(), ]) ->call('requestDelayWarning', TeamAB::TeamA->value) @@ -583,7 +559,6 @@ expect($betweenSetsState->setInProgress)->toBeFalse(); Livewire::test(Court::class, [ - 'gameId' => $game->getKey(), 'gameState' => $betweenSetsState, ]) ->call('requestDelayWarning', TeamAB::TeamB->value) @@ -606,7 +581,6 @@ $game->recordDelayWarning(TeamAB::TeamA); $component = Livewire::test(Court::class, [ - 'gameId' => $game->getKey(), 'gameState' => $game->stateAt(), ]); @@ -621,7 +595,6 @@ $game->recordDelayWarning(TeamAB::TeamA); Livewire::test(Court::class, [ - 'gameId' => $game->getKey(), 'gameState' => $game->stateAt(), ]) ->call('requestDelayPenalty', TeamAB::TeamA->value) @@ -646,7 +619,6 @@ ->and($state->delayPenaltiesTeamA)->toBe(1); Livewire::test(Court::class, [ - 'gameId' => $game->getKey(), 'gameState' => $game->stateAt(), ]) ->call('requestDelayPenalty', TeamAB::TeamA->value) @@ -657,7 +629,6 @@ expect($game->fresh()->stateAt()->delayPenaltiesTeamA)->toBe(2); $component = Livewire::test(Court::class, [ - 'gameId' => $game->getKey(), 'gameState' => $game->stateAt(), ]); @@ -670,7 +641,6 @@ $game = gameWithStartedSet(); $component = Livewire::test(Court::class, [ - 'gameId' => $game->getKey(), 'gameState' => $game->stateAt(), ]) ->assertViewHas('leftDelayPenaltyDisabled', true); @@ -686,10 +656,9 @@ $staff = Staff::factory()->for($game->homeTeam)->create(); $game->addStaff($staff, StaffRole::Coach); - $player = $game->homePlayers()->wherePivot('number', 1)->firstOrFail(); + $player = $game->homePlayers()->where('number', 1)->firstOrFail(); Livewire::test(Court::class, [ - 'gameId' => $game->getKey(), 'gameState' => $game->stateAt(), ]) ->call('requestMisconduct', TeamAB::TeamA->value, MisconductSanction::Penalty->value) @@ -718,10 +687,9 @@ test('court misconduct flow records a sanction against a player after confirmation', function (): void { $game = gameWithStartedSet(); - $player = $game->homePlayers()->wherePivot('number', 1)->firstOrFail(); + $player = $game->homePlayers()->where('number', 1)->firstOrFail(); Livewire::test(Court::class, [ - 'gameId' => $game->getKey(), 'gameState' => $game->stateAt(), ]) ->call('requestMisconduct', TeamAB::TeamA->value, MisconductSanction::Penalty->value) @@ -751,7 +719,6 @@ $game->addStaff($staff, StaffRole::Coach); Livewire::test(Court::class, [ - 'gameId' => $game->getKey(), 'gameState' => $game->stateAt(), ]) ->call('requestMisconduct', TeamAB::TeamA->value, MisconductSanction::Expulsion->value) @@ -777,7 +744,7 @@ test('court disables minor misconduct with a check once it has been recorded for the team', function (): void { $game = gameWithStartedSet(); - $player = $game->homePlayers()->wherePivot('number', 1)->firstOrFail(); + $player = $game->homePlayers()->where('number', 1)->firstOrFail(); $game->recordMisconduct( team: TeamAB::TeamA, @@ -787,7 +754,6 @@ ); $component = Livewire::test(Court::class, [ - 'gameId' => $game->getKey(), 'gameState' => $game->fresh()->stateAt(), ]) ->assertViewHas('leftMinorMisconductDisabled', true); @@ -800,7 +766,7 @@ test('court misconduct picker marks people unavailable for same or lower sanctions', function (): void { $game = gameWithStartedSet(); - $player = $game->homePlayers()->wherePivot('number', 1)->firstOrFail(); + $player = $game->homePlayers()->where('number', 1)->firstOrFail(); $staff = Staff::factory()->for($game->homeTeam)->create(); $game->addStaff($staff, StaffRole::Coach); @@ -818,7 +784,6 @@ ); $component = Livewire::test(Court::class, [ - 'gameId' => $game->getKey(), 'gameState' => $game->fresh()->stateAt(), ]) ->call('requestMisconduct', TeamAB::TeamA->value, MisconductSanction::Penalty->value) @@ -842,7 +807,6 @@ ->not->toContain('x-mark'); Livewire::test(Court::class, [ - 'gameId' => $game->getKey(), 'gameState' => $game->fresh()->stateAt(), ]) ->call('requestMisconduct', TeamAB::TeamA->value, MisconductSanction::Expulsion->value) diff --git a/tests/Feature/DatabaseSeederTest.php b/tests/Feature/DatabaseSeederTest.php index 4cd4b15..e004ed9 100644 --- a/tests/Feature/DatabaseSeederTest.php +++ b/tests/Feature/DatabaseSeederTest.php @@ -4,18 +4,63 @@ use App\Enums\OfficialRole; use App\Models\Game; +use App\Models\Official; +use App\Models\Player; +use App\Models\Staff; +use App\Models\Team; use Database\Seeders\DatabaseSeeder; use Illuminate\Foundation\Testing\RefreshDatabase; uses(RefreshDatabase::class); -test('database seeder leaves the game at the initial roster submission step', function (): void { +test('database seeder creates exactly one configured match with setup data', function (): void { $this->seed(DatabaseSeeder::class); - $game = Game::query()->with(['players', 'staff', 'officials'])->sole(); + $game = Game::query()->with(['homeTeam', 'awayTeam', 'players', 'staff', 'officials'])->sole(); + $homePlayers = $game->players + ->filter(fn (Player $player): bool => $player->roster->team_id === $game->home_team_id) + ->values(); + $awayPlayers = $game->players + ->filter(fn (Player $player): bool => $player->roster->team_id === $game->away_team_id) + ->values(); + $homeStaff = $game->staff + ->filter(fn (Staff $staff): bool => $staff->roster->team_id === $game->home_team_id) + ->values(); + $awayStaff = $game->staff + ->filter(fn (Staff $staff): bool => $staff->roster->team_id === $game->away_team_id) + ->values(); - expect($game->rosters_submitted)->toBeFalse() - ->and($game->players)->toHaveCount(0) - ->and($game->staff)->toHaveCount(0) + expect(Game::query()->count())->toBe(1) + ->and(Team::query()->count())->toBe(2) + ->and(Player::query()->count())->toBe(26) + ->and(Staff::query()->count())->toBe(10) + ->and(Official::query()->count())->toBe(count(OfficialRole::cases())) + ->and($game->homeTeam->name)->toBe('Bologna VC') + ->and($game->awayTeam->name)->toBe('Paris Volley') + ->and($game->rosters_submitted)->toBeTrue() + ->and($homePlayers)->toHaveCount(13) + ->and($awayPlayers)->toHaveCount(13) + ->and($homeStaff)->toHaveCount(5) + ->and($awayStaff)->toHaveCount(5) ->and($game->officials)->toHaveCount(count(OfficialRole::cases())); }); + +test('database seeder reuses the singleton match when run again', function (): void { + $this->seed(DatabaseSeeder::class); + + $originalGame = Game::query()->sole(); + + $this->seed(DatabaseSeeder::class); + + $game = Game::query()->with(['homeTeam', 'awayTeam', 'players', 'staff', 'officials'])->sole(); + + expect(Game::query()->count())->toBe(1) + ->and($game->getKey())->toBe($originalGame->getKey()) + ->and(Team::query()->count())->toBe(2) + ->and(Player::query()->count())->toBe(26) + ->and(Staff::query()->count())->toBe(10) + ->and(Official::query()->count())->toBe(count(OfficialRole::cases())) + ->and($game->homeTeam->name)->toBe('Bologna VC') + ->and($game->awayTeam->name)->toBe('Paris Volley') + ->and($game->rosters_submitted)->toBeTrue(); +}); diff --git a/tests/Feature/GameComponentStateTest.php b/tests/Feature/GameComponentStateTest.php index 1324e37..ae35988 100644 --- a/tests/Feature/GameComponentStateTest.php +++ b/tests/Feature/GameComponentStateTest.php @@ -7,7 +7,6 @@ use App\Enums\TeamSide; use App\Livewire\Game; use App\Models\Game as GameModel; -use App\Models\Player; use Illuminate\Foundation\Testing\RefreshDatabase; use Livewire\Livewire; @@ -17,32 +16,19 @@ config()->set('game.between_sets_duration', 0); }); -test('game component hydrates state from the passed game', function (): void { - $game = GameModel::factory()->create(); +test('game component hydrates state from the current singleton match', function (): void { + $game = makeReadyCurrentMatch(); $game->recordToss(TeamSide::Away, TeamAB::TeamB); - Livewire::test(Game::class, ['game' => $game]) - ->assertSet('gameId', $game->getKey()) + Livewire::test(Game::class) ->assertSet('gameState', fn (GameState $gameState): bool => $gameState->servingTeam === TeamAB::TeamB && $gameState->setNumber === 0 && $gameState->rotationTeamA === [] && $gameState->rotationTeamB === []); }); -test('game component uses the passed game id instead of the latest game', function (): void { - $targetGame = GameModel::factory()->create(); - $targetGame->recordToss(TeamSide::Home, TeamAB::TeamA); - - $latestGame = GameModel::factory()->create(); - $latestGame->recordToss(TeamSide::Away, TeamAB::TeamB); - - Livewire::test(Game::class, ['game' => $targetGame]) - ->assertSet('gameId', $targetGame->getKey()) - ->assertSet('gameState', fn (GameState $gameState): bool => $gameState->servingTeam === TeamAB::TeamA); -}); - test('game component renders sets and current set points for both teams', function (): void { - $game = GameModel::factory()->create(); + $game = makeReadyCurrentMatch(); $game->recordToss(TeamSide::Home, TeamAB::TeamA); ensureLineupsForSet($game, 1); $game->recordSetStarted(); @@ -62,7 +48,7 @@ $game->recordRallyWinner(TeamAB::TeamB); } - Livewire::test(Game::class, ['game' => $game]) + Livewire::test(Game::class) ->assertSet('gameState', fn (GameState $gameState): bool => $gameState->setsWonTeamA === 1 && $gameState->setsWonTeamB === 0 && $gameState->scoreTeamA === 7 @@ -81,33 +67,6 @@ function ensureLineupsForSet(GameModel $game, int $set): void { - if ($game->players()->count() === 0) { - $homePlayers = Player::factory()->for($game->homeTeam)->count(6)->create(); - foreach ($homePlayers as $index => $player) { - $game->addPlayer($player, number: $index + 1); - } - - $awayPlayers = Player::factory()->for($game->awayTeam)->count(6)->create(); - foreach ($awayPlayers as $index => $player) { - $game->addPlayer($player, number: $index + 11); - } - } - - $game->recordLineup($set, TeamAB::TeamA, lineupPositions(1)); - $game->recordLineup($set, TeamAB::TeamB, lineupPositions(11)); -} - -/** - * @return array - */ -function lineupPositions(int $start): array -{ - return [ - 1 => $start, - 2 => $start + 1, - 3 => $start + 2, - 4 => $start + 3, - 5 => $start + 4, - 6 => $start + 5, - ]; + $game->recordLineup($set, TeamAB::TeamA, standardLineup()); + $game->recordLineup($set, TeamAB::TeamB, standardLineup(11)); } diff --git a/tests/Feature/GameEventTest.php b/tests/Feature/GameEventTest.php index 13cd008..229c540 100644 --- a/tests/Feature/GameEventTest.php +++ b/tests/Feature/GameEventTest.php @@ -1,33 +1,15 @@ set('game.between_sets_duration', 0); }); -function prepareActiveSet(Game $game): void -{ - $game->recordToss(TeamSide::Home, TeamAB::TeamA); - ensureStartingLineupRoster($game); - submitLineupsForSet($game, 1); - $game->recordSetStarted(); -} - -function winSet(Game $game, TeamAB $winner): void -{ - for ($i = 0; $i < 25; $i++) { - $game->recordRallyWinner($winner); - } -} - -/** - * @return array - */ -function lineupWithRosterNumbers(int $start = 1): array -{ - return [ - 1 => $start, - 2 => $start + 1, - 3 => $start + 2, - 4 => $start + 3, - 5 => $start + 4, - 6 => $start + 5, - ]; -} - -function ensureStartingLineupRoster(Game $game): void -{ - if ($game->players()->count() > 0) { - return; - } - - $homePlayers = Player::factory()->for($game->homeTeam)->count(6)->create(); - foreach ($homePlayers as $index => $player) { - $game->addPlayer($player, number: $index + 1); - } - - $awayPlayers = Player::factory()->for($game->awayTeam)->count(6)->create(); - foreach ($awayPlayers as $index => $player) { - $game->addPlayer($player, number: $index + 11); - } -} - -function submitLineupsForSet(Game $game, int $set): void -{ - $game->recordLineup($set, TeamAB::TeamA, lineupWithRosterNumbers(1)); - $game->recordLineup($set, TeamAB::TeamB, lineupWithRosterNumbers(11)); -} +test('singleton match records the opening toss on the current game', function (): void { + makeReadyCurrentMatch(); -test('a toss can be recorded with the correct type and payload', function (): void { - $homeTeam = Team::factory()->create(); - $awayTeam = Team::factory()->create(); - $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); + Game::current()->recordToss(TeamSide::Home, TeamAB::TeamA); - $game->recordToss(TeamSide::Home, TeamAB::TeamA); - - expect($game->events)->toHaveCount(1); + $game = Game::current()->fresh(); + $event = $game->events->sole(); - $event = $game->events->first(); - expect($event->type)->toBe(GameEventType::TossCompleted) + expect($game->status)->toBe(MatchPhase::InProgress) + ->and($event->type)->toBe(GameEventType::TossCompleted) ->and($event->payload)->toBeInstanceOf(TossCompletedPayload::class) ->and($event->payload->teamA)->toBe(TeamSide::Home) - ->and($event->payload->leftTeam)->toBe(TeamAB::TeamA) ->and($event->payload->serving)->toBe(TeamAB::TeamA); }); -test('a fifth set toss keeps team a and team b fixed while changing the left side', function (): void { - $game = Game::factory()->create(); +test('singleton match records lineup submission set start and rally progression in order', function (): void { + $game = makeReadyCurrentMatch(); - ensureStartingLineupRoster($game); $game->recordToss(TeamSide::Home, TeamAB::TeamA); - - foreach ([TeamAB::TeamA, TeamAB::TeamB, TeamAB::TeamA, TeamAB::TeamB] as $setWinner) { - submitLineupsForSet($game, $game->stateAt()->setNumber + 1); - $game->recordSetStarted(); - winSet($game, $setWinner); - } - - $game->recordToss(TeamSide::Home, TeamAB::TeamB, TeamAB::TeamB); - - $event = $game->events->last(); - - expect($event->payload->teamA)->toBe(TeamSide::Home) - ->and($event->payload->leftTeam)->toBe(TeamAB::TeamB) - ->and($event->payload->serving)->toBe(TeamAB::TeamB); -}); - -test('court sides can be swapped once a team reaches 8 points in the fifth set', function (): void { - $game = Game::factory()->create(); - - ensureStartingLineupRoster($game); - $game->recordToss(TeamSide::Home, TeamAB::TeamA); - - foreach ([TeamAB::TeamA, TeamAB::TeamB, TeamAB::TeamA, TeamAB::TeamB] as $setWinner) { - submitLineupsForSet($game, $game->stateAt()->setNumber + 1); - $game->recordSetStarted(); - winSet($game, $setWinner); - } - - $game->recordToss(TeamSide::Home, TeamAB::TeamB, TeamAB::TeamA); - submitLineupsForSet($game, 5); + $game->recordLineup(1, TeamAB::TeamA, standardLineup()); + $game->recordLineup(1, TeamAB::TeamB, standardLineup(11)); $game->recordSetStarted(); - - for ($index = 0; $index < 8; $index++) { - $game->recordRallyWinner(TeamAB::TeamA); - } - - $game->recordCourtSidesSwapped(); - - $event = $game->fresh()->events->last(); - $state = $game->fresh()->stateAt(); - - expect($event->type)->toBe(GameEventType::CourtSidesSwapped) - ->and($event->payload)->toBeInstanceOf(CourtSidesSwappedPayload::class) - ->and($state->fifthSetLeftTeam)->toBe(TeamAB::TeamB) - ->and($state->fifthSetSideSwapped)->toBeTrue() - ->and($state->servingTeam)->toBe(TeamAB::TeamA); -}); - -test('court sides cannot be swapped before a team reaches 8 points in the fifth set', function (): void { - $game = Game::factory()->create(); - - ensureStartingLineupRoster($game); - $game->recordToss(TeamSide::Home, TeamAB::TeamA); - - foreach ([TeamAB::TeamA, TeamAB::TeamB, TeamAB::TeamA, TeamAB::TeamB] as $setWinner) { - submitLineupsForSet($game, $game->stateAt()->setNumber + 1); - $game->recordSetStarted(); - winSet($game, $setWinner); - } - - $game->recordToss(TeamSide::Home, TeamAB::TeamB, TeamAB::TeamA); - submitLineupsForSet($game, 5); - $game->recordSetStarted(); - - for ($index = 0; $index < 7; $index++) { - $game->recordRallyWinner(TeamAB::TeamA); - } - - expect(fn () => $game->recordCourtSidesSwapped()) - ->toThrow(InvalidGameEventTransition::class, 'Court sides can only be swapped once a team reaches 8 points in the fifth set.'); -}); - -test('team b is derived as the other team when team a is the away team', function (): void { - $homeTeam = Team::factory()->create(); - $awayTeam = Team::factory()->create(); - $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); - - $game->recordToss(TeamSide::Away, TeamAB::TeamA); - - $event = $game->events->first(); - expect($event->payload->teamA)->toBe(TeamSide::Away); -}); - -test('a lineup can be recorded for a set with correct type, set number, team, and positions', function (): void { - $homeTeam = Team::factory()->create(); - $awayTeam = Team::factory()->create(); - $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); - - $positions = lineupWithRosterNumbers(); - $players = Player::factory()->for($homeTeam)->count(6)->create(); - - $game->recordToss(TeamSide::Home, TeamAB::TeamA); - foreach ($players as $i => $player) { - $game->addPlayer($player, number: $i + 1); - } - - $game->recordLineup(1, TeamAB::TeamA, $positions); - - expect($game->events)->toHaveCount(2); - - $event = $game->events->last(); - expect($event->type)->toBe(GameEventType::LineupSubmitted) - ->and($event->payload)->toBeInstanceOf(LineupSubmittedPayload::class) - ->and($event->payload->set)->toBe(1) - ->and($event->payload->team)->toBe(TeamAB::TeamA) - ->and($event->payload->positions)->toBe($positions); -}); - -test('events are returned in chronological insertion order', function (): void { - $homeTeam = Team::factory()->create(); - $awayTeam = Team::factory()->create(); - $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); - - $positions = lineupWithRosterNumbers(); - $players = Player::factory()->for($homeTeam)->count(6)->create(); - - $game->recordToss(TeamSide::Home, TeamAB::TeamA); - foreach ($players as $i => $player) { - $game->addPlayer($player, number: $i + 1); - } - $game->recordLineup(1, TeamAB::TeamA, $positions); - - expect($game->events->first()->type)->toBe(GameEventType::TossCompleted) - ->and($game->events->last()->type)->toBe(GameEventType::LineupSubmitted); -}); - -test('a game event cannot be modified after creation', function (): void { - $homeTeam = Team::factory()->create(); - $awayTeam = Team::factory()->create(); - $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); - - $game->recordToss(TeamSide::Home, TeamAB::TeamA); - - $event = $game->events->first(); - $event->payload = new TossCompletedPayload(teamA: TeamSide::Away, serving: TeamAB::TeamB); - - expect(fn () => $event->save())->toThrow(LogicException::class); -}); - -test('the type attribute is cast to a GameEventType enum', function (): void { - $homeTeam = Team::factory()->create(); - $awayTeam = Team::factory()->create(); - $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); - - $game->recordToss(TeamSide::Home, TeamAB::TeamA); - - $event = GameEvent::first(); - expect($event->type)->toBeInstanceOf(GameEventType::class) - ->and($event->type)->toBe(GameEventType::TossCompleted); -}); - -test('events are isolated per game', function (): void { - $homeTeam = Team::factory()->create(); - $awayTeam = Team::factory()->create(); - - $game1 = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); - $game2 = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); - - $game1->recordToss(TeamSide::Home, TeamAB::TeamA); - - expect($game1->events)->toHaveCount(1) - ->and($game2->events)->toHaveCount(0); -}); - -test('a lineup with fewer than 6 positions throws an InvalidArgumentException', function (): void { - $homeTeam = Team::factory()->create(); - $awayTeam = Team::factory()->create(); - $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); - - $game->recordToss(TeamSide::Home, TeamAB::TeamA); - - $players = Player::factory()->for($homeTeam)->count(5)->create(); - foreach ($players as $i => $player) { - $game->addPlayer($player, number: $i + 1); - } - - $positions = [ - 1 => 1, - 2 => 2, - 3 => 3, - 4 => 4, - 5 => 5, - ]; - - expect(fn () => $game->recordLineup(1, TeamAB::TeamA, $positions)) - ->toThrow(InvalidArgumentException::class, 'A lineup must have exactly 6 positions.'); -}); - -test('a lineup with 0-based keys throws an InvalidArgumentException', function (): void { - $homeTeam = Team::factory()->create(); - $awayTeam = Team::factory()->create(); - $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); - - $game->recordToss(TeamSide::Home, TeamAB::TeamA); - - $players = Player::factory()->for($homeTeam)->count(6)->create(); - foreach ($players as $i => $player) { - $game->addPlayer($player, number: $i + 1); - } - - $positions = [ - 0 => 1, - 1 => 2, - 2 => 3, - 3 => 4, - 4 => 5, - 5 => 6, - ]; - - expect(fn () => $game->recordLineup(1, TeamAB::TeamA, $positions)) - ->toThrow(InvalidArgumentException::class, 'Lineup positions must be keyed 1 through 6.'); -}); - -test('a lineup with a duplicate roster number throws an InvalidArgumentException', function (): void { - $homeTeam = Team::factory()->create(); - $awayTeam = Team::factory()->create(); - $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); - - $game->recordToss(TeamSide::Home, TeamAB::TeamA); - - $players = Player::factory()->for($homeTeam)->count(6)->create(); - foreach ($players as $i => $player) { - $game->addPlayer($player, number: $i + 1); - } - - $positions = [1 => 1, 2 => 1, 3 => 3, 4 => 4, 5 => 5, 6 => 6]; - - expect(fn () => $game->recordLineup(1, TeamAB::TeamA, $positions)) - ->toThrow(InvalidArgumentException::class, 'All 6 lineup positions must have different roster numbers.'); -}); - -test('a lineup submitted before the toss is rejected', function (): void { - $homeTeam = Team::factory()->create(); - $awayTeam = Team::factory()->create(); - $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); - - $positions = lineupWithRosterNumbers(); - - expect(fn () => $game->recordLineup(1, TeamAB::TeamA, $positions)) - ->toThrow(InvalidGameEventTransition::class, 'A lineup cannot be submitted before the toss has been recorded.'); -}); - -test('a lineup cannot be submitted after the set has started', function (): void { - $homeTeam = Team::factory()->create(); - $awayTeam = Team::factory()->create(); - $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); - - $homePlayers = Player::factory()->for($homeTeam)->count(6)->create(); - foreach ($homePlayers as $i => $player) { - $game->addPlayer($player, number: $i + 1); - } - - $awayPlayers = Player::factory()->for($awayTeam)->count(6)->create(); - foreach ($awayPlayers as $i => $player) { - $game->addPlayer($player, number: $i + 11); - } - - $game->recordToss(TeamSide::Home, TeamAB::TeamA); - submitLineupsForSet($game, 1); - $game->recordSetStarted(); - - $positions = lineupWithRosterNumbers(); - - expect(fn () => $game->recordLineup(1, TeamAB::TeamA, $positions)) - ->toThrow(InvalidGameEventTransition::class, 'A lineup can only be submitted before the set starts.'); -}); - -test('a lineup for the next set can be submitted after the previous set ends', function (): void { - $homeTeam = Team::factory()->create(); - $awayTeam = Team::factory()->create(); - $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); - - $homePlayers = Player::factory()->for($homeTeam)->count(6)->create(); - foreach ($homePlayers as $i => $player) { - $game->addPlayer($player, number: $i + 1); - } - - $awayPlayers = Player::factory()->for($awayTeam)->count(6)->create(); - foreach ($awayPlayers as $i => $player) { - $game->addPlayer($player, number: $i + 11); - } - - $game->recordToss(TeamSide::Home, TeamAB::TeamA); - submitLineupsForSet($game, 1); - $game->recordSetStarted(); - winSet($game, TeamAB::TeamA); - - $positions = lineupWithRosterNumbers(); - $game->recordLineup(2, TeamAB::TeamA, $positions); - - $event = $game->events->last(); - expect($event->type)->toBe(GameEventType::LineupSubmitted) - ->and($event->payload->set)->toBe(2); -}); - -test('a rally ended event can be recorded with the correct type and payload', function (): void { - $homeTeam = Team::factory()->create(); - $awayTeam = Team::factory()->create(); - $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); - - prepareActiveSet($game); $game->recordRallyWinner(TeamAB::TeamA); - $event = $game->events->last(); - expect($event->type)->toBe(GameEventType::RallyEnded) - ->and($event->payload)->toBeInstanceOf(RallyEndedPayload::class) - ->and($event->payload->team)->toBe(TeamAB::TeamA); -}); - -test('rally ended event stores the winning team', function (TeamAB $team): void { - $homeTeam = Team::factory()->create(); - $awayTeam = Team::factory()->create(); - $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); - - prepareActiveSet($game); - $game->recordRallyWinner($team); - - $event = $game->events->last(); - expect($event->payload->team)->toBe($team); -})->with([ - 'team A' => [TeamAB::TeamA], - 'team B' => [TeamAB::TeamB], -]); - -test('a lineup with a roster number not on the team roster throws an InvalidArgumentException', function (): void { - $homeTeam = Team::factory()->create(); - $awayTeam = Team::factory()->create(); - $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); - - $game->recordToss(TeamSide::Home, TeamAB::TeamA); - - $homePlayers = Player::factory()->for($homeTeam)->count(6)->create(); - foreach ($homePlayers as $i => $player) { - $game->addPlayer($player, number: $i + 1); - } - - $awayPlayers = Player::factory()->for($awayTeam)->count(6)->create(); - foreach ($awayPlayers as $i => $player) { - $game->addPlayer($player, number: $i + 11); - } + $freshGame = $game->fresh(); + $events = $freshGame->events->values(); + $lineupEvent = $events[1]; - $positions = lineupWithRosterNumbers(start: 11); - - expect(fn () => $game->recordLineup(1, TeamAB::TeamA, $positions)) - ->toThrow(InvalidArgumentException::class, 'is not on the non-libero roster for the specified team.'); + expect($events->pluck('type')->all())->toBe([ + GameEventType::TossCompleted, + GameEventType::LineupSubmitted, + GameEventType::LineupSubmitted, + GameEventType::SetStarted, + GameEventType::RallyEnded, + ]) + ->and($lineupEvent->payload)->toBeInstanceOf(LineupSubmittedPayload::class) + ->and($lineupEvent->payload->positions)->toBe(standardLineup()) + ->and($freshGame->stateAt()->setInProgress)->toBeTrue() + ->and($freshGame->stateAt()->scoreTeamA)->toBe(1) + ->and($freshGame->stateAt()->scoreTeamB)->toBe(0); }); -test('a lineup with non-positive roster numbers throws an InvalidArgumentException', function (): void { - $homeTeam = Team::factory()->create(); - $awayTeam = Team::factory()->create(); - $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); - - $players = Player::factory()->for($homeTeam)->count(6)->create(); - foreach ($players as $i => $player) { - $game->addPlayer($player, number: $i + 1); - } +test('singleton match rejects lineup submission before the opening toss', function (): void { + $game = makeReadyCurrentMatch(); - $game->recordToss(TeamSide::Home, TeamAB::TeamA); - - $positions = lineupWithRosterNumbers(); - $positions[1] = 0; - - expect(fn () => $game->recordLineup(1, TeamAB::TeamA, $positions)) - ->toThrow(InvalidArgumentException::class, 'must contain a positive roster number.'); + expect(fn () => $game->recordLineup(1, TeamAB::TeamA, standardLineup())) + ->toThrow(InvalidGameEventTransition::class); }); - -test('a substitution can be recorded with the correct type and payload', function (): void { - $homeTeam = Team::factory()->create(); - $awayTeam = Team::factory()->create(); - $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); - - prepareActiveSet($game); - $game->recordSubstitution(TeamAB::TeamA, playerOut: 5, playerIn: 12); - - $event = $game->events->last(); - expect($event->type)->toBe(GameEventType::SubstitutionCompleted) - ->and($event->payload)->toBeInstanceOf(SubstitutionCompletedPayload::class) - ->and($event->payload->team)->toBe(TeamAB::TeamA) - ->and($event->payload->playerOut)->toBe(5) - ->and($event->payload->playerIn)->toBe(12); -}); - -test('substitution event stores the correct team', function (TeamAB $team): void { - $homeTeam = Team::factory()->create(); - $awayTeam = Team::factory()->create(); - $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); - - prepareActiveSet($game); - $game->recordSubstitution($team, playerOut: 3, playerIn: 9); - - $event = $game->events->last(); - expect($event->payload->team)->toBe($team); -})->with([ - 'team A' => [TeamAB::TeamA], - 'team B' => [TeamAB::TeamB], -]); - -test('a time-out request can be recorded with the correct type and payload', function (): void { - $homeTeam = Team::factory()->create(); - $awayTeam = Team::factory()->create(); - $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); - - prepareActiveSet($game); - $game->recordTimeOut(TeamAB::TeamA); - - $event = $game->events->last(); - expect($event->type)->toBe(GameEventType::TimeOutRequested) - ->and($event->payload)->toBeInstanceOf(TimeOutRequestedPayload::class) - ->and($event->payload->team)->toBe(TeamAB::TeamA); -}); - -test('time-out requested event stores the requesting team', function (TeamAB $team): void { - $homeTeam = Team::factory()->create(); - $awayTeam = Team::factory()->create(); - $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); - - prepareActiveSet($game); - $game->recordTimeOut($team); - - $event = $game->events->last(); - expect($event->payload->team)->toBe($team); -})->with([ - 'team A' => [TeamAB::TeamA], - 'team B' => [TeamAB::TeamB], -]); - -test('an improper request can be recorded with the correct type and payload', function (): void { - $homeTeam = Team::factory()->create(); - $awayTeam = Team::factory()->create(); - $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); - - prepareActiveSet($game); - $game->recordImproperRequest(TeamAB::TeamA, ImproperRequestType::Timeout); - - $event = $game->events->last(); - expect($event->type)->toBe(GameEventType::ImproperRequestRecorded) - ->and($event->payload)->toBeInstanceOf(ImproperRequestRecordedPayload::class) - ->and($event->payload->team)->toBe(TeamAB::TeamA) - ->and($event->payload->requestType)->toBe(ImproperRequestType::Timeout); -}); - -test('an improper substitution request stores the request type', function (): void { - $homeTeam = Team::factory()->create(); - $awayTeam = Team::factory()->create(); - $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); - - prepareActiveSet($game); - $game->recordImproperRequest(TeamAB::TeamA, ImproperRequestType::Substitution); - - $event = $game->events->last(); - expect($event->type)->toBe(GameEventType::ImproperRequestRecorded) - ->and($event->payload)->toBeInstanceOf(ImproperRequestRecordedPayload::class) - ->and($event->payload->team)->toBe(TeamAB::TeamA) - ->and($event->payload->requestType)->toBe(ImproperRequestType::Substitution); -}); - -test('a second improper request records a delay warning', function (): void { - $homeTeam = Team::factory()->create(); - $awayTeam = Team::factory()->create(); - $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); - - prepareActiveSet($game); - $game->recordImproperRequest(TeamAB::TeamA, ImproperRequestType::Timeout); - $game->recordImproperRequest(TeamAB::TeamA, ImproperRequestType::Substitution); - - $event = $game->events->last(); - expect($event->type)->toBe(GameEventType::DelayWarningRecorded) - ->and($event->payload)->toBeInstanceOf(DelayWarningRecordedPayload::class) - ->and($event->payload->team)->toBe(TeamAB::TeamA) - ->and($event->payload->requestType)->toBe(ImproperRequestType::Substitution) - ->and($game->fresh()->stateAt()->improperRequestsTeamA)->toBe(1) - ->and($game->fresh()->stateAt()->delayWarningsTeamA)->toBe(1); -}); - -test('a third improper request records a delay penalty and awards the rally to the opponent', function (): void { - $homeTeam = Team::factory()->create(); - $awayTeam = Team::factory()->create(); - $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); - - prepareActiveSet($game); - $game->recordImproperRequest(TeamAB::TeamA, ImproperRequestType::Timeout); - $game->recordImproperRequest(TeamAB::TeamA, ImproperRequestType::Substitution); - $game->recordImproperRequest(TeamAB::TeamA, ImproperRequestType::Timeout); - - $event = $game->events->last(); - $state = $game->fresh()->stateAt(); - - expect($event->type)->toBe(GameEventType::DelayPenaltyRecorded) - ->and($event->payload)->toBeInstanceOf(DelayPenaltyRecordedPayload::class) - ->and($event->payload->team)->toBe(TeamAB::TeamA) - ->and($event->payload->awardedTeam)->toBe(TeamAB::TeamB) - ->and($event->payload->requestType)->toBe(ImproperRequestType::Timeout) - ->and($state->scoreTeamB)->toBe(1) - ->and($state->servingTeam)->toBe(TeamAB::TeamB) - ->and($state->rotationTeamB[1])->toBe(12) - ->and($state->delayPenaltiesTeamA)->toBe(1); -}); - -test('a delay penalty awards only a point when the opponent is already serving', function (): void { - $homeTeam = Team::factory()->create(); - $awayTeam = Team::factory()->create(); - $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); - - prepareActiveSet($game); - $game->recordImproperRequest(TeamAB::TeamB, ImproperRequestType::Timeout); - $game->recordImproperRequest(TeamAB::TeamB, ImproperRequestType::Substitution); - $game->recordImproperRequest(TeamAB::TeamB, ImproperRequestType::Timeout); - - $state = $game->fresh()->stateAt(); - - expect($state->scoreTeamA)->toBe(1) - ->and($state->servingTeam)->toBe(TeamAB::TeamA) - ->and($state->rotationTeamA[1])->toBe(1) - ->and($state->delayPenaltiesTeamB)->toBe(1); -}); - -test('a manual delay penalty can be recorded repeatedly and awards the opponent', function (): void { - $homeTeam = Team::factory()->create(); - $awayTeam = Team::factory()->create(); - $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); - - prepareActiveSet($game); - $game->recordDelayPenalty(TeamAB::TeamA); - $game->recordDelayPenalty(TeamAB::TeamA); - - $event = $game->events->last(); - $state = $game->fresh()->stateAt(); - - expect($event->type)->toBe(GameEventType::DelayPenaltyRecorded) - ->and($event->payload)->toBeInstanceOf(DelayPenaltyRecordedPayload::class) - ->and($event->payload->team)->toBe(TeamAB::TeamA) - ->and($event->payload->awardedTeam)->toBe(TeamAB::TeamB) - ->and($event->payload->requestType)->toBeNull() - ->and($state->scoreTeamB)->toBe(2) - ->and($state->servingTeam)->toBe(TeamAB::TeamB) - ->and($state->delayPenaltiesTeamA)->toBe(2); -}); - -test('improper requests do not reset when a set ends', function (): void { - $homeTeam = Team::factory()->create(); - $awayTeam = Team::factory()->create(); - $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); - - prepareActiveSet($game); - $game->recordImproperRequest(TeamAB::TeamA, ImproperRequestType::Timeout); - - winSet($game, TeamAB::TeamA); - - $state = $game->fresh()->stateAt(); - expect($state->improperRequestsTeamA)->toBe(1) - ->and($state->delayWarningsTeamA)->toBe(0); -}); - -test('misconduct can be recorded for a player with the correct type and payload', function (MisconductSanction $sanction): void { - $homeTeam = Team::factory()->create(); - $awayTeam = Team::factory()->create(); - $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); - - prepareActiveSet($game); - $player = $game->homePlayers()->firstOrFail(); - - $game->recordMisconduct( - team: TeamAB::TeamA, - subjectType: MisconductSubjectType::Player, - subjectId: $player->getKey(), - sanction: $sanction, - ); - - $event = $game->events->last(); - expect($event->type)->toBe(GameEventType::MisconductRecorded) - ->and($event->payload)->toBeInstanceOf(MisconductRecordedPayload::class) - ->and($event->payload->team)->toBe(TeamAB::TeamA) - ->and($event->payload->subjectType)->toBe(MisconductSubjectType::Player) - ->and($event->payload->subjectId)->toBe($player->getKey()) - ->and($event->payload->sanction)->toBe($sanction); -})->with([ - 'warning' => [MisconductSanction::Warning], - 'penalty' => [MisconductSanction::Penalty], - 'expulsion' => [MisconductSanction::Expulsion], - 'disqualification' => [MisconductSanction::Disqualification], -]); - -test('misconduct can be recorded for staff', function (): void { - $homeTeam = Team::factory()->create(); - $awayTeam = Team::factory()->create(); - $staff = Staff::factory()->for($homeTeam)->create(); - $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); - - prepareActiveSet($game); - $game->addStaff($staff, StaffRole::Coach); - - $game->recordMisconduct( - team: TeamAB::TeamA, - subjectType: MisconductSubjectType::Staff, - subjectId: $staff->getKey(), - sanction: MisconductSanction::Expulsion, - ); - - $event = $game->events->last(); - expect($event->type)->toBe(GameEventType::MisconductRecorded) - ->and($event->payload)->toBeInstanceOf(MisconductRecordedPayload::class) - ->and($event->payload->subjectType)->toBe(MisconductSubjectType::Staff) - ->and($event->payload->subjectId)->toBe($staff->getKey()) - ->and($event->payload->sanction)->toBe(MisconductSanction::Expulsion); -}); - -test('a misconduct penalty awards a point and service to the other team', function (): void { - $homeTeam = Team::factory()->create(); - $awayTeam = Team::factory()->create(); - $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); - - prepareActiveSet($game); - $player = $game->homePlayers()->firstOrFail(); - - $game->recordMisconduct( - team: TeamAB::TeamA, - subjectType: MisconductSubjectType::Player, - subjectId: $player->getKey(), - sanction: MisconductSanction::Penalty, - ); - - $state = $game->fresh()->stateAt(); - - expect($state->misconductPenaltiesTeamA)->toBe(1) - ->and($state->scoreTeamB)->toBe(1) - ->and($state->servingTeam)->toBe(TeamAB::TeamB) - ->and($state->rotationTeamB[1])->toBe(12); -}); - -test('misconduct sanctions other than penalty do not award a point', function (MisconductSanction $sanction): void { - $homeTeam = Team::factory()->create(); - $awayTeam = Team::factory()->create(); - $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); - - prepareActiveSet($game); - $player = $game->homePlayers()->firstOrFail(); - - $game->recordMisconduct( - team: TeamAB::TeamA, - subjectType: MisconductSubjectType::Player, - subjectId: $player->getKey(), - sanction: $sanction, - ); - - $state = $game->fresh()->stateAt(); - - expect($state->scoreTeamA)->toBe(0) - ->and($state->scoreTeamB)->toBe(0) - ->and($state->servingTeam)->toBe(TeamAB::TeamA) - ->and($state->rotationTeamB[1])->toBe(11); -})->with([ - 'warning' => [MisconductSanction::Warning], - 'expulsion' => [MisconductSanction::Expulsion], - 'disqualification' => [MisconductSanction::Disqualification], -]); - -test('a misconduct penalty can end a set', function (): void { - $homeTeam = Team::factory()->create(); - $awayTeam = Team::factory()->create(); - $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); - - prepareActiveSet($game); - $player = $game->homePlayers()->firstOrFail(); - - for ($index = 0; $index < 24; $index++) { - $game->recordRallyWinner(TeamAB::TeamB); - } - - $game->recordMisconduct( - team: TeamAB::TeamA, - subjectType: MisconductSubjectType::Player, - subjectId: $player->getKey(), - sanction: MisconductSanction::Penalty, - ); - - $state = $game->fresh()->stateAt(); - - expect($state->setsWonTeamB)->toBe(1) - ->and($state->setInProgress)->toBeFalse() - ->and($state->scoreTeamA)->toBe(0) - ->and($state->scoreTeamB)->toBe(0); -}); - -test('misconduct can be recorded before the match starts', function (): void { - $homeTeam = Team::factory()->create(); - $awayTeam = Team::factory()->create(); - $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); - $player = Player::factory()->for($homeTeam)->create(); - - $game->addPlayer($player, number: 7); - $game->recordMisconduct( - team: TeamAB::TeamA, - subjectType: MisconductSubjectType::Player, - subjectId: $player->getKey(), - sanction: MisconductSanction::Warning, - ); - - $event = $game->events->last(); - - expect($event->type)->toBe(GameEventType::MisconductRecorded) - ->and($event->payload)->toBeInstanceOf(MisconductRecordedPayload::class) - ->and($event->payload->team)->toBe(TeamAB::TeamA) - ->and($event->payload->subjectType)->toBe(MisconductSubjectType::Player) - ->and($event->payload->subjectId)->toBe($player->getKey()) - ->and($event->payload->sanction)->toBe(MisconductSanction::Warning); -}); - -test('minor misconduct can only be recorded once per team in a game', function (): void { - $homeTeam = Team::factory()->create(); - $awayTeam = Team::factory()->create(); - $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); - $firstPlayer = Player::factory()->for($homeTeam)->create(); - $secondPlayer = Player::factory()->for($homeTeam)->create(); - - $game->addPlayer($firstPlayer, number: 7); - $game->addPlayer($secondPlayer, number: 8); - - $game->recordMisconduct( - team: TeamAB::TeamA, - subjectType: MisconductSubjectType::Player, - subjectId: $firstPlayer->getKey(), - sanction: MisconductSanction::Warning, - ); - - expect(fn () => $game->recordMisconduct( - team: TeamAB::TeamA, - subjectType: MisconductSubjectType::Player, - subjectId: $secondPlayer->getKey(), - sanction: MisconductSanction::Warning, - ))->toThrow(InvalidGameEventTransition::class, 'This team already has a minor misconduct warning.'); -}); - -test('a person cannot receive the same or a lower misconduct sanction', function (): void { - $homeTeam = Team::factory()->create(); - $awayTeam = Team::factory()->create(); - $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); - $player = Player::factory()->for($homeTeam)->create(); - $staff = Staff::factory()->for($homeTeam)->create(); - - $game->addPlayer($player, number: 7); - $game->addStaff($staff, StaffRole::Coach); - - $game->recordMisconduct( - team: TeamAB::TeamA, - subjectType: MisconductSubjectType::Player, - subjectId: $player->getKey(), - sanction: MisconductSanction::Penalty, - ); - $game->recordMisconduct( - team: TeamAB::TeamA, - subjectType: MisconductSubjectType::Staff, - subjectId: $staff->getKey(), - sanction: MisconductSanction::Expulsion, - ); - - expect(fn () => $game->recordMisconduct( - team: TeamAB::TeamA, - subjectType: MisconductSubjectType::Player, - subjectId: $player->getKey(), - sanction: MisconductSanction::Penalty, - ))->toThrow(InvalidGameEventTransition::class, 'This person already has the same or a higher misconduct sanction.'); - - expect(fn () => $game->recordMisconduct( - team: TeamAB::TeamA, - subjectType: MisconductSubjectType::Staff, - subjectId: $staff->getKey(), - sanction: MisconductSanction::Penalty, - ))->toThrow(InvalidGameEventTransition::class, 'This person already has the same or a higher misconduct sanction.'); - - $game->recordMisconduct( - team: TeamAB::TeamA, - subjectType: MisconductSubjectType::Player, - subjectId: $player->getKey(), - sanction: MisconductSanction::Expulsion, - ); - - expect($game->fresh()->stateAt()->misconductExpulsionsTeamA)->toBe(2); -}); - -test('misconduct counts do not reset when a set ends', function (): void { - $homeTeam = Team::factory()->create(); - $awayTeam = Team::factory()->create(); - $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); - - prepareActiveSet($game); - $player = $game->homePlayers()->firstOrFail(); - - $game->recordMisconduct(TeamAB::TeamA, MisconductSubjectType::Player, $player->getKey(), MisconductSanction::Warning); - $game->recordMisconduct(TeamAB::TeamA, MisconductSubjectType::Player, $player->getKey(), MisconductSanction::Penalty); - $game->recordMisconduct(TeamAB::TeamA, MisconductSubjectType::Player, $player->getKey(), MisconductSanction::Expulsion); - $game->recordMisconduct(TeamAB::TeamA, MisconductSubjectType::Player, $player->getKey(), MisconductSanction::Disqualification); - - winSet($game, TeamAB::TeamA); - - $state = $game->fresh()->stateAt(); - expect($state->misconductWarningsTeamA)->toBe(1) - ->and($state->misconductPenaltiesTeamA)->toBe(1) - ->and($state->misconductExpulsionsTeamA)->toBe(1) - ->and($state->misconductDisqualificationsTeamA)->toBe(1); -}); - -test('timeout counts reset to zero when a set ends', function (): void { - $homeTeam = Team::factory()->create(); - $awayTeam = Team::factory()->create(); - $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); - - prepareActiveSet($game); - $game->recordTimeOut(TeamAB::TeamA); - $game->recordTimeOut(TeamAB::TeamB); - - winSet($game, TeamAB::TeamA); - - $state = $game->fresh()->stateAt(); - expect($state->timeoutsTeamA)->toBe(0) - ->and($state->timeoutsTeamB)->toBe(0); -}); - -test('a set started event can be recorded with the correct type and empty payload', function (): void { - $homeTeam = Team::factory()->create(); - $awayTeam = Team::factory()->create(); - $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); - - $game->recordToss(TeamSide::Home, TeamAB::TeamA); - ensureStartingLineupRoster($game); - submitLineupsForSet($game, 1); - $game->recordSetStarted(); - - expect($game->events)->toHaveCount(4); - - $event = $game->events->last(); - expect($event->type)->toBe(GameEventType::SetStarted) - ->and($event->payload)->toBeInstanceOf(SetStartedPayload::class) - ->and($event->payload->toArray())->toBe([]); -}); - -test('a set ended event can be recorded with the correct type and empty payload', function (): void { - $homeTeam = Team::factory()->create(); - $awayTeam = Team::factory()->create(); - $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); - - prepareActiveSet($game); - winSet($game, TeamAB::TeamA); - - $event = $game->events->last(); - expect($event->type)->toBe(GameEventType::SetEnded) - ->and($event->payload)->toBeInstanceOf(SetEndedPayload::class) - ->and($event->payload->toArray())->toBe([]); -}); - -test('a game ended event can be recorded with the correct type and empty payload', function (): void { - $homeTeam = Team::factory()->create(); - $awayTeam = Team::factory()->create(); - $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); - - $game->recordToss(TeamSide::Home, TeamAB::TeamA); - ensureStartingLineupRoster($game); - for ($set = 0; $set < 3; $set++) { - submitLineupsForSet($game, $set + 1); - $game->recordSetStarted(); - winSet($game, TeamAB::TeamA); - } - - $event = $game->events->last(); - expect($event->type)->toBe(GameEventType::GameEnded) - ->and($event->payload)->toBeInstanceOf(GameEndedPayload::class) - ->and($event->payload->toArray())->toBe([]); -}); - -test('a set cannot start before the toss', function (): void { - $homeTeam = Team::factory()->create(); - $awayTeam = Team::factory()->create(); - $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); - - expect(fn () => $game->recordSetStarted()) - ->toThrow(InvalidGameEventTransition::class, 'A set cannot start before the toss has been recorded.'); -}); - -test('a set cannot start until both lineups are submitted for the upcoming set', function (): void { - $homeTeam = Team::factory()->create(); - $awayTeam = Team::factory()->create(); - $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); - - $game->recordToss(TeamSide::Home, TeamAB::TeamA); - ensureStartingLineupRoster($game); - $game->recordLineup(1, TeamAB::TeamA, lineupWithRosterNumbers(1)); - - expect(fn () => $game->recordSetStarted()) - ->toThrow(InvalidGameEventTransition::class, 'Both team lineups must be submitted before starting the set.'); -}); - -test('a set cannot end before score reaches 25 with a two-point advantage', function (): void { - $homeTeam = Team::factory()->create(); - $awayTeam = Team::factory()->create(); - $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); - - prepareActiveSet($game); - for ($i = 0; $i < 24; $i++) { - $game->recordRallyWinner(TeamAB::TeamA); - } - - expect(fn () => $game->recordSetEnded()) - ->toThrow(InvalidGameEventTransition::class, 'A set can only end when a team has at least 25 points with a 2-point advantage.'); -}); - -test('the deciding set cannot end before 15 points with a two-point advantage', function (): void { - $game = Game::factory()->create(); - createSnapshotForSetScore($game, setNumber: 5, scoreTeamA: 14, scoreTeamB: 12); - - expect(fn () => app(GameEventRuleValidator::class)->assertCanRecordSetEnded($game)) - ->toThrow(InvalidGameEventTransition::class, 'A set can only end when a team has at least 15 points with a 2-point advantage.'); -}); - -test('the deciding set can end at 15 points with a two-point advantage', function (): void { - $game = Game::factory()->create(); - createSnapshotForSetScore($game, setNumber: 5, scoreTeamA: 15, scoreTeamB: 13); - - app(GameEventRuleValidator::class)->assertCanRecordSetEnded($game); - - expect(true)->toBeTrue(); -}); - -test('a game cannot end before one team has won three sets', function (): void { - $homeTeam = Team::factory()->create(); - $awayTeam = Team::factory()->create(); - $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); - - $game->recordToss(TeamSide::Home, TeamAB::TeamA); - ensureStartingLineupRoster($game); - submitLineupsForSet($game, 1); - $game->recordSetStarted(); - winSet($game, TeamAB::TeamA); - - expect(fn () => $game->recordGameEnded()) - ->toThrow(InvalidGameEventTransition::class, 'A game can only end after one team has won three sets.'); -}); - -function createSnapshotForSetScore(Game $game, int $setNumber, int $scoreTeamA, int $scoreTeamB): void -{ - $event = GameEvent::withoutEvents(fn (): GameEvent => GameEvent::query()->create([ - 'game_id' => $game->getKey(), - 'type' => GameEventType::SetStarted, - 'payload' => new SetStartedPayload, - 'created_at' => now(), - ])); - - GameStateSnapshot::query()->create([ - 'game_id' => $game->getKey(), - 'game_event_id' => $event->getKey(), - 'set_number' => $setNumber, - 'score_team_a' => $scoreTeamA, - 'score_team_b' => $scoreTeamB, - 'sets_won_team_a' => 0, - 'sets_won_team_b' => 0, - 'timeouts_team_a' => 0, - 'timeouts_team_b' => 0, - 'substitutions_team_a' => 0, - 'substitutions_team_b' => 0, - 'serving_team' => TeamAB::TeamA->value, - 'rotation_team_a' => [], - 'rotation_team_b' => [], - 'set_in_progress' => true, - 'game_ended' => false, - 'created_at' => now(), - ]); -} diff --git a/tests/Feature/GamePageTest.php b/tests/Feature/GamePageTest.php index ac1b872..db656a7 100644 --- a/tests/Feature/GamePageTest.php +++ b/tests/Feature/GamePageTest.php @@ -5,9 +5,6 @@ use App\Enums\TeamAB; use App\Enums\TeamSide; use App\Livewire\Game; -use App\Models\Game as GameModel; -use App\Models\Player; -use App\Models\Team; use Illuminate\Foundation\Testing\RefreshDatabase; use Livewire\Livewire; @@ -17,77 +14,43 @@ config()->set('game.between_sets_duration', 0); }); -test('the game page returns a successful response and renders the fixed canvas with the court', function (): void { - $game = GameModel::factory()->create(); +test('the game page redirects to setup until the static match setup is complete', function (): void { + createCurrentMatch(); - $response = $this->get(route('game', ['game' => $game])); - - $response->assertSuccessful() - ->assertSee('id="game-canvas"', false) - ->assertSee('bg-sky-100') - ->assertSee('id="volleyball-court"', false) - ->assertSee('Submit rosters') - ->assertDontSee('Submit Toss Result') - ->assertDontSee('data-scoreboard', false) - ->assertDontSee('Submit Lineup'); + $this->get(route('game')) + ->assertRedirect(route('match.setup')); }); test('the game livewire component renders the court component', function (): void { - $game = GameModel::factory()->create(); + makeReadyCurrentMatch(); - Livewire::test(Game::class, ['game' => $game]) + Livewire::test(Game::class) ->assertSeeHtml('id="game-canvas"') ->assertSeeHtml('id="volleyball-court"') - ->assertSee('Submit rosters') + ->assertSee('Submit Toss Result') ->assertDontSeeHtml('data-scoreboard'); }); test('the game page renders the start set button when both lineups are submitted for the upcoming set', function (): void { - $homeTeam = Team::factory()->create(); - $awayTeam = Team::factory()->create(); - $game = GameModel::factory()->betweenTeams($homeTeam, $awayTeam)->create(); - - $homePlayers = Player::factory()->for($homeTeam)->count(6)->create(); - foreach ($homePlayers as $index => $player) { - $game->addPlayer($player, number: $index + 1); - } - - $awayPlayers = Player::factory()->for($awayTeam)->count(6)->create(); - foreach ($awayPlayers as $index => $player) { - $game->addPlayer($player, number: $index + 11); - } - + $game = makeReadyCurrentMatch(); $game->recordToss(TeamSide::Home, TeamAB::TeamA); - $game->recordLineup(1, TeamAB::TeamA, [1 => 1, 2 => 2, 3 => 3, 4 => 4, 5 => 5, 6 => 6]); - $game->recordLineup(1, TeamAB::TeamB, [1 => 11, 2 => 12, 3 => 13, 4 => 14, 5 => 15, 6 => 16]); + $game->recordLineup(1, TeamAB::TeamA, standardLineup()); + $game->recordLineup(1, TeamAB::TeamB, standardLineup(11)); - $response = $this->get(route('game', ['game' => $game])); + $response = $this->get(route('game')); $response->assertSuccessful() ->assertSee('Start Game'); }); test('the game component prompts for the fifth set side change at 8 points and swaps sides when dismissed', function (): void { - $homeTeam = Team::factory()->create(); - $awayTeam = Team::factory()->create(); - $game = GameModel::factory()->betweenTeams($homeTeam, $awayTeam)->create(); - - $homePlayers = Player::factory()->for($homeTeam)->count(6)->create(); - foreach ($homePlayers as $index => $player) { - $game->addPlayer($player, number: $index + 1); - } - - $awayPlayers = Player::factory()->for($awayTeam)->count(6)->create(); - foreach ($awayPlayers as $index => $player) { - $game->addPlayer($player, number: $index + 11); - } - + $game = makeReadyCurrentMatch(); $game->recordToss(TeamSide::Home, TeamAB::TeamA); foreach ([TeamAB::TeamA, TeamAB::TeamB, TeamAB::TeamA, TeamAB::TeamB] as $winner) { $set = $game->stateAt()->setNumber + 1; - $game->recordLineup($set, TeamAB::TeamA, [1 => 1, 2 => 2, 3 => 3, 4 => 4, 5 => 5, 6 => 6]); - $game->recordLineup($set, TeamAB::TeamB, [1 => 11, 2 => 12, 3 => 13, 4 => 14, 5 => 15, 6 => 16]); + $game->recordLineup($set, TeamAB::TeamA, standardLineup()); + $game->recordLineup($set, TeamAB::TeamB, standardLineup(11)); $game->recordSetStarted(); for ($index = 0; $index < 25; $index++) { @@ -96,15 +59,15 @@ } $game->recordToss(TeamSide::Home, TeamAB::TeamA, TeamAB::TeamA); - $game->recordLineup(5, TeamAB::TeamA, [1 => 1, 2 => 2, 3 => 3, 4 => 4, 5 => 5, 6 => 6]); - $game->recordLineup(5, TeamAB::TeamB, [1 => 11, 2 => 12, 3 => 13, 4 => 14, 5 => 15, 6 => 16]); + $game->recordLineup(5, TeamAB::TeamA, standardLineup()); + $game->recordLineup(5, TeamAB::TeamB, standardLineup(11)); $game->recordSetStarted(); for ($index = 0; $index < 8; $index++) { $game->recordRallyWinner(TeamAB::TeamA); } - Livewire::test(Game::class, ['game' => $game->fresh()]) + Livewire::test(Game::class) ->assertSet('showFifthSetSideChangePrompt', true) ->assertSee('Teams to change court') ->assertSeeHtml('x-on:keydown.escape.window.capture.prevent.stop=""') diff --git a/tests/Feature/GameStateProjectionTest.php b/tests/Feature/GameStateProjectionTest.php index fddf717..5adb378 100644 --- a/tests/Feature/GameStateProjectionTest.php +++ b/tests/Feature/GameStateProjectionTest.php @@ -111,7 +111,6 @@ Carbon::setTestNow(); dispatch_sync(new RecalculateGameStateSnapshots( - gameId: $game->getKey(), upTo: '2026-03-07 10:02:30', )); diff --git a/tests/Feature/GameTest.php b/tests/Feature/GameTest.php index 914ac02..3375b51 100644 --- a/tests/Feature/GameTest.php +++ b/tests/Feature/GameTest.php @@ -2,6 +2,7 @@ use App\Models\Game; use App\Models\Team; +use Illuminate\Database\QueryException; use Illuminate\Foundation\Testing\RefreshDatabase; uses(RefreshDatabase::class); @@ -27,6 +28,39 @@ ->and($game->awayTeam)->toBeInstanceOf(Team::class); }); +test('only one game row may exist in the database', function (): void { + Game::factory()->create(); + + expect(fn (): Game => Game::factory()->create()) + ->toThrow(QueryException::class) + ->and(Game::query()->count())->toBe(1); +}); + +test('ensuring the singleton reuses the existing game record', function (): void { + $game = Game::ensureSingleton( + homeTeamAttributes: [ + 'name' => 'Italy', + 'country_code' => 'ITA', + ], + awayTeamAttributes: [ + 'name' => 'Brazil', + 'country_code' => 'BRA', + ], + ); + + $reusedGame = Game::ensureSingleton( + gameAttributes: [ + 'city' => 'Rome', + ], + ); + + expect(Game::query()->count())->toBe(1) + ->and($reusedGame->getKey())->toBe($game->getKey()) + ->and($reusedGame->city)->toBe('Rome') + ->and($reusedGame->homeTeam->game_id)->toBe($game->getKey()) + ->and($reusedGame->awayTeam->game_id)->toBe($game->getKey()); +}); + test('a game has a match date time', function (): void { $dateTime = now()->addDays(1)->startOfSecond(); $game = Game::factory()->scheduledAt($dateTime)->create(); diff --git a/tests/Feature/LineupSubmissionTest.php b/tests/Feature/LineupSubmissionTest.php index 3da0a9f..bff1fe9 100644 --- a/tests/Feature/LineupSubmissionTest.php +++ b/tests/Feature/LineupSubmissionTest.php @@ -12,7 +12,6 @@ use App\Models\GameEvent; use App\Models\GameStateSnapshot; use App\Models\Player; -use App\Models\Team; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Carbon; use Illuminate\View\ViewException; @@ -45,44 +44,26 @@ function validLineupInput(): array */ function validLineupPositions(): array { - return [ - 1 => 1, - 2 => 2, - 3 => 3, - 4 => 4, - 5 => 5, - 6 => 6, - ]; + return standardLineup(); } function prepareGameForLineupSubmission(): Game { - $homeTeam = Team::factory()->create(); - $awayTeam = Team::factory()->create(); - $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); + $game = createCurrentMatch(); + submitInitialRosters($game); - $homePlayers = Player::factory()->for($homeTeam)->count(6)->create(); - foreach ($homePlayers as $index => $player) { - $game->addPlayer($player, number: $index + 1); - } - - $homeLibero = Player::factory()->for($homeTeam)->create(); + $homeLibero = Player::factory()->for($game->homeTeam)->create(); $game->addPlayer($homeLibero, number: 99, isLibero: true); - $awayPlayers = Player::factory()->for($awayTeam)->count(6)->create(); - foreach ($awayPlayers as $index => $player) { - $game->addPlayer($player, number: $index + 11); - } - $game->recordToss(TeamSide::Home, TeamAB::TeamA); return $game; } test('lineup submission is hidden before toss is submitted', function (): void { - $game = Game::factory()->create(); + $game = createCurrentMatch(); - Livewire::test(LineupSubmission::class, ['team' => TeamAB::TeamA, 'gameId' => $game->getKey()]) + Livewire::test(LineupSubmission::class, ['team' => TeamAB::TeamA]) ->assertDontSee('Submit Lineup') ->assertDontSee('Team A Lineup'); }); @@ -90,7 +71,7 @@ function prepareGameForLineupSubmission(): Game test('lineup submission renders team a button and modal after toss is submitted', function (): void { $game = prepareGameForLineupSubmission(); - Livewire::test(LineupSubmission::class, ['team' => TeamAB::TeamA, 'gameId' => $game->getKey()]) + Livewire::test(LineupSubmission::class, ['team' => TeamAB::TeamA]) ->assertSee('Submit Lineup') ->assertSee('Team A Lineup') ->assertSeeHtml('submit-lineup-team_a-left') @@ -108,7 +89,7 @@ function prepareGameForLineupSubmission(): Game test('lineup submission renders team b button and modal after toss is submitted', function (): void { $game = prepareGameForLineupSubmission(); - Livewire::test(LineupSubmission::class, ['team' => TeamAB::TeamB, 'gameId' => $game->getKey()]) + Livewire::test(LineupSubmission::class, ['team' => TeamAB::TeamB]) ->assertSee('Submit Lineup') ->assertSee('Team B Lineup') ->assertSeeHtml('submit-lineup-team_b-left') @@ -123,16 +104,16 @@ function prepareGameForLineupSubmission(): Game }); test('lineup submission rejects unsupported team value', function (): void { - $game = Game::factory()->create(); + $game = createCurrentMatch(); - expect(fn (): Testable => Livewire::test(LineupSubmission::class, ['team' => 'invalid', 'gameId' => $game->getKey()])) + expect(fn (): Testable => Livewire::test(LineupSubmission::class, ['team' => 'invalid'])) ->toThrow(ViewException::class); }); test('lineup submission modal name includes the court side to avoid stale modal reuse', function (): void { $game = prepareGameForLineupSubmission(); - Livewire::test(LineupSubmission::class, ['team' => TeamAB::TeamB, 'gameId' => $game->getKey(), 'courtSide' => 'right']) + Livewire::test(LineupSubmission::class, ['team' => TeamAB::TeamB, 'courtSide' => 'right']) ->assertSeeHtml('submit-lineup-team_b-right') ->assertDontSeeHtml('submit-lineup-team_b-left'); }); @@ -140,7 +121,7 @@ function prepareGameForLineupSubmission(): Game test('lineup submission records an event and dispatches a refresh event', function (): void { $game = prepareGameForLineupSubmission(); - Livewire::test(LineupSubmission::class, ['team' => TeamAB::TeamA, 'gameId' => $game->getKey()]) + Livewire::test(LineupSubmission::class, ['team' => TeamAB::TeamA]) ->set('lineup', validLineupInput()) ->call('submit') ->assertHasNoErrors() @@ -166,7 +147,7 @@ function prepareGameForLineupSubmission(): Game $lineup = validLineupInput(); $lineup[1] = '0'; - Livewire::test(LineupSubmission::class, ['team' => TeamAB::TeamA, 'gameId' => $game->getKey()]) + Livewire::test(LineupSubmission::class, ['team' => TeamAB::TeamA]) ->set('lineup', $lineup) ->call('submit') ->assertHasErrors(['submit']) @@ -178,7 +159,7 @@ function prepareGameForLineupSubmission(): Game $lineup = validLineupInput(); $lineup[2] = '1'; - Livewire::test(LineupSubmission::class, ['team' => TeamAB::TeamA, 'gameId' => $game->getKey()]) + Livewire::test(LineupSubmission::class, ['team' => TeamAB::TeamA]) ->set('lineup', $lineup) ->call('submit') ->assertHasErrors(['submit']) @@ -192,7 +173,7 @@ function prepareGameForLineupSubmission(): Game $lineup = validLineupInput(); $lineup[1] = $invalidRosterNumber; - Livewire::test(LineupSubmission::class, ['team' => TeamAB::TeamA, 'gameId' => $game->getKey()]) + Livewire::test(LineupSubmission::class, ['team' => TeamAB::TeamA]) ->set('lineup', $lineup) ->call('submit') ->assertHasErrors(['submit']) @@ -204,18 +185,16 @@ function prepareGameForLineupSubmission(): Game 'libero roster number' => ['99'], ]); -test('lineup submission is aware of the injected game context', function (): void { +test('lineup submission uses the singleton match while respecting injected game state', function (): void { $game = Game::factory()->create(); Livewire::test(LineupSubmission::class, [ 'team' => TeamAB::TeamA, - 'gameId' => $game->getKey(), 'gameState' => GameState::fromAttributes([ 'set_number' => 2, 'serving_team' => TeamAB::TeamB->value, ]), ]) - ->assertSet('gameId', $game->getKey()) ->assertSet('gameState', fn (GameState $gameState): bool => $gameState->setNumber === 2 && $gameState->servingTeam === TeamAB::TeamB); }); @@ -226,7 +205,6 @@ function prepareGameForLineupSubmission(): Game Livewire::test(LineupSubmission::class, [ 'team' => TeamAB::TeamA, - 'gameId' => $game->getKey(), 'gameState' => $game->stateAt(), ]) ->assertDontSee('Submit Lineup') @@ -239,7 +217,6 @@ function prepareGameForLineupSubmission(): Game Livewire::test(LineupSubmission::class, [ 'team' => TeamAB::TeamB, - 'gameId' => $game->getKey(), 'gameState' => $game->stateAt(), ]) ->assertSee('Submit Lineup') @@ -251,7 +228,6 @@ function prepareGameForLineupSubmission(): Game Livewire::test(LineupSubmission::class, [ 'team' => TeamAB::TeamA, - 'gameId' => $game->getKey(), 'gameState' => $game->stateAt(), ]) ->assertDontSee('Submit Lineup') @@ -291,13 +267,11 @@ function prepareGameForLineupSubmission(): Game Livewire::test(LineupSubmission::class, [ 'team' => TeamAB::TeamA, - 'gameId' => $game->getKey(), ]) ->assertDontSee('Submit Lineup'); Livewire::test(LineupSubmission::class, [ 'team' => TeamAB::TeamB, - 'gameId' => $game->getKey(), ]) ->assertSee('Submit Lineup') ->assertSee('Team B Lineup'); @@ -310,14 +284,7 @@ function tiedLineupGameReadyForFifthSet(): Game foreach ([TeamAB::TeamA, TeamAB::TeamB, TeamAB::TeamA, TeamAB::TeamB] as $setWinner) { $set = $game->stateAt()->setNumber + 1; $game->recordLineup($set, TeamAB::TeamA, validLineupPositions()); - $game->recordLineup($set, TeamAB::TeamB, [ - 1 => 11, - 2 => 12, - 3 => 13, - 4 => 14, - 5 => 15, - 6 => 16, - ]); + $game->recordLineup($set, TeamAB::TeamB, standardLineup(11)); $game->recordSetStarted(); for ($index = 0; $index < 25; $index++) { diff --git a/tests/Feature/MatchLifecyclePhaseTest.php b/tests/Feature/MatchLifecyclePhaseTest.php new file mode 100644 index 0000000..202c585 --- /dev/null +++ b/tests/Feature/MatchLifecyclePhaseTest.php @@ -0,0 +1,65 @@ +set('game.between_sets_duration', 0); + fakeScoresheetPdfFactory(); +}); + +test('match status progresses from setup to ready to in progress to completed and pdf generated', function (): void { + $game = makeReadyCurrentMatch(); + + expect($game->fresh()->status)->toBe(MatchPhase::Ready); + + $game->recordToss(TeamSide::Home, TeamAB::TeamA); + + expect($game->fresh()->status)->toBe(MatchPhase::InProgress); + + foreach (range(1, 3) as $setNumber) { + $game->recordLineup($setNumber, TeamAB::TeamA, standardLineup()); + $game->recordLineup($setNumber, TeamAB::TeamB, standardLineup(11)); + $game->recordSetStarted(); + + foreach (range(1, 25) as $rally) { + $game->recordRallyWinner(TeamAB::TeamA); + } + } + + expect($game->fresh()->status)->toBe(MatchPhase::Completed); + + app(ScoresheetGenerator::class)->generate($game->fresh()); + + expect($game->fresh()->status)->toBe(MatchPhase::PdfGenerated); + + app(ScoresheetGenerator::class)->generate($game->fresh()); + + expect($game->fresh()->status)->toBe(MatchPhase::PdfGenerated); +}); + +test('setup edits are blocked after gameplay begins in the domain', function (): void { + $game = makeReadyCurrentMatch(); + $game->recordToss(TeamSide::Home, TeamAB::TeamA); + + expect(fn () => $game->fresh()->replaceOfficials([])) + ->toThrow(InvalidGameEventTransition::class, 'Match setup cannot be edited after gameplay has begun.'); +}); + +test('match setup ui rejects edits after gameplay begins', function (): void { + makeReadyCurrentMatch()->recordToss(TeamSide::Home, TeamAB::TeamA); + + Livewire::test(MatchSetup::class) + ->call('saveOfficials') + ->assertHasErrors(['setup']); +}); diff --git a/tests/Feature/MatchSetupRoutingTest.php b/tests/Feature/MatchSetupRoutingTest.php new file mode 100644 index 0000000..8902741 --- /dev/null +++ b/tests/Feature/MatchSetupRoutingTest.php @@ -0,0 +1,135 @@ +set('game.between_sets_duration', 0); +}); + +test('first load with no match configured redirects to setup and shows the singleton prompt', function (): void { + $this->get(route('home')) + ->assertRedirect(route('match.setup')); + + $this->get(route('game')) + ->assertRedirect(route('match.setup')); + + $this->get(route('match.setup')) + ->assertSuccessful() + ->assertSee('Create the current match') + ->assertSee('Create current match'); +}); + +test('game redirects to setup when current match setup is incomplete', function (): void { + createCurrentMatchWithoutDetails(); + + $this->get(route('game')) + ->assertRedirect(route('match.setup')); +}); + +test('setup page shows the next required step for an incomplete current match', function (): void { + createCurrentMatchWithoutDetails(); + + $this->get(route('match.setup')) + ->assertSuccessful() + ->assertSee('Match details') + ->assertDontSee('Open current match'); +}); + +test('home redirects to the match when setup is ready', function (): void { + makeReadyCurrentMatch(); + + $this->get(route('home')) + ->assertRedirect(route('game')); +}); + +test('setup can create the current match when none exists', function (): void { + Livewire::test(MatchSetup::class) + ->call('createMatch') + ->assertSet('step', 'match-details') + ->assertSee('Match details'); + + expect(Game::query()->count())->toBe(1); +}); + +test('setup reuses the current match instead of inserting another one', function (): void { + $game = createCurrentMatchWithoutDetails(); + + Livewire::test(MatchSetup::class) + ->call('createMatch') + ->assertSet('step', 'match-details'); + + expect(Game::query()->count())->toBe(1) + ->and(Game::query()->sole()->getKey())->toBe($game->getKey()); +}); + +test('setup createMatch reuses a singleton created after the component mounts', function (): void { + $component = Livewire::test(MatchSetup::class); + + $game = createCurrentMatchWithoutDetails(); + + $component + ->call('createMatch') + ->assertSet('step', 'match-details'); + + expect(Game::query()->count())->toBe(1) + ->and(Game::query()->sole()->getKey())->toBe($game->getKey()); +}); + +test('setup flow can take a blank current match to a playable ready state', function (): void { + createCurrentMatchWithoutDetails(); + + $component = Livewire::test(MatchSetup::class) + ->set('matchNumber', '7') + ->set('matchCountryCode', 'ITA') + ->set('city', 'Rome') + ->set('hall', 'Forum') + ->set('matchDateTime', '2026-06-07T19:30') + ->set('division', 'Men') + ->set('pool', 'A') + ->set('category', 'Senior') + ->set('homeTeamName', 'Italy') + ->set('homeTeamCountryCode', 'ITA') + ->set('awayTeamName', 'Brazil') + ->set('awayTeamCountryCode', 'BRA') + ->call('saveMatchDetails') + ->assertSet('step', 'rosters'); + + foreach (range(0, 5) as $index) { + $component + ->set("homePlayerRows.{$index}.first_name", 'Home'.$index) + ->set("homePlayerRows.{$index}.last_name", 'Player'.$index) + ->set("homePlayerRows.{$index}.number", (string) ($index + 1)) + ->set("awayPlayerRows.{$index}.first_name", 'Away'.$index) + ->set("awayPlayerRows.{$index}.last_name", 'Player'.$index) + ->set("awayPlayerRows.{$index}.number", (string) ($index + 11)); + } + + $component + ->set('homeCaptainSelection', '0') + ->set('awayCaptainSelection', '0') + ->call('saveRosters') + ->assertSet('step', 'officials'); + + foreach (OfficialRole::cases() as $index => $role) { + $component + ->set("officialRows.{$index}.first_name", 'Official'.$index) + ->set("officialRows.{$index}.last_name", 'Crew'.$index) + ->set("officialRows.{$index}.country_code", 'ITA'); + } + + $component + ->call('saveOfficials') + ->assertSet('step', 'ready') + ->assertSee('Match ready'); + + $this->get(route('game')) + ->assertSuccessful(); +}); diff --git a/tests/Feature/PdfGenerationTest.php b/tests/Feature/PdfGenerationTest.php new file mode 100644 index 0000000..18d3ddc --- /dev/null +++ b/tests/Feature/PdfGenerationTest.php @@ -0,0 +1,40 @@ +set('game.between_sets_duration', 0); + fakeScoresheetPdfFactory(); +}); + +test('pdf generation command writes the singleton match scoresheet after completion', function (): void { + $game = makeReadyCurrentMatch(); + recordStraightSetsWin($game); + + $outputPath = storage_path('app/public/scoresheet.pdf'); + + if (file_exists($outputPath)) { + unlink($outputPath); + } + + $this->artisan('app:generate-pdf') + ->assertSuccessful(); + + expect(file_exists($outputPath))->toBeTrue() + ->and(Game::current()->fresh()->status)->toBe(MatchPhase::PdfGenerated); +}); + +test('pdf generation is blocked until the singleton match is completed', function (): void { + $game = makeReadyCurrentMatch(); + + expect(fn () => app(ScoresheetGenerator::class)->generate($game)) + ->toThrow(InvalidGameEventTransition::class); +}); diff --git a/tests/Feature/RosterSubmissionTest.php b/tests/Feature/RosterSubmissionTest.php index 3b40114..69f3304 100644 --- a/tests/Feature/RosterSubmissionTest.php +++ b/tests/Feature/RosterSubmissionTest.php @@ -4,7 +4,6 @@ use App\Enums\StaffRole; use App\Livewire\RosterSubmission; -use App\Models\Game; use App\Models\Player; use App\Models\Staff; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -16,22 +15,22 @@ config()->set('game.between_sets_duration', 0); }); -test('initial roster submission is shown before the initial toss', function (): void { - $game = Game::factory()->create(); +test('initial roster submission is shown before the initial toss on the singleton match', function (): void { + createCurrentMatch(); - Livewire::test(RosterSubmission::class, ['gameId' => $game->getKey()]) + Livewire::test(RosterSubmission::class) ->assertSee('Submit rosters') ->assertSeeHtml('data-submit-roster-button'); }); -test('submit rosters modal shows home and away players and staff', function (): void { - $game = Game::factory()->create(); +test('submit rosters modal shows the singleton match player pool and staff', function (): void { + $game = createCurrentMatch(); Player::factory()->for($game->homeTeam)->named('Anna', 'Zephyr')->create(); Player::factory()->for($game->awayTeam)->named('Dora', 'Young')->create(); Staff::factory()->for($game->homeTeam)->asCoach()->named('Helen', 'Coach')->create(); Staff::factory()->for($game->awayTeam)->asDoctor()->named('Mila', 'Doctor')->create(); - Livewire::test(RosterSubmission::class, ['gameId' => $game->getKey()]) + Livewire::test(RosterSubmission::class) ->call('openRosterModal') ->assertSee('Anna Zephyr') ->assertSee('Dora Young') @@ -42,14 +41,15 @@ ->assertSee(StaffRole::Doctor->value); }); -test('submitting rosters opens confirmation and confirm saves selected players and checked staff for both teams', function (): void { - $game = Game::factory()->create(); - $homePlayers = Player::factory()->for($game->homeTeam)->count(7)->create(); - $awayPlayers = Player::factory()->for($game->awayTeam)->count(7)->create(); - $homeCoach = Staff::factory()->for($game->homeTeam)->asCoach()->create(); - $awayDoctor = Staff::factory()->for($game->awayTeam)->asDoctor()->create(); +test('roster submission records the selected roster on the singleton match', function (): void { + $game = createCurrentMatch(); + $rosterCandidates = seedRosterCandidates($game); + $homePlayers = $rosterCandidates['home_players']; + $awayPlayers = $rosterCandidates['away_players']; + $homeCoach = $rosterCandidates['home_staff'][0]; + $awayDoctor = $rosterCandidates['away_staff'][1]; - $component = Livewire::test(RosterSubmission::class, ['gameId' => $game->getKey()]); + $component = Livewire::test(RosterSubmission::class); foreach ($homePlayers as $index => $player) { $component->set("homeRosterInputs.{$player->getKey()}", $index === 6 ? '12' : (string) ($index + 1)); @@ -75,9 +75,6 @@ $component ->assertSee('Confirm rosters') - ->assertSee($homePlayers[0]->last_name.' '.mb_substr((string) $homePlayers[0]->first_name, 0, 1).'.') - ->assertSee((string) 12) - ->assertSee('Bench') ->call('confirmRosters') ->assertHasNoErrors() ->assertDontSee('Submit rosters'); @@ -86,302 +83,31 @@ expect($freshGame->homePlayers)->toHaveCount(7) ->and($freshGame->awayPlayers)->toHaveCount(7) - ->and($freshGame->homePlayers->firstWhere('id', $homePlayers[0]->getKey())?->roster->is_captain)->toBeTrue() - ->and($freshGame->homePlayers->firstWhere('id', $homePlayers[6]->getKey())?->roster->number)->toBe(12) - ->and($freshGame->homePlayers->firstWhere('id', $homePlayers[6]->getKey())?->roster->is_libero)->toBeTrue() - ->and($freshGame->awayPlayers->firstWhere('id', $awayPlayers[0]->getKey())?->roster->is_captain)->toBeTrue() - ->and($freshGame->awayPlayers->firstWhere('id', $awayPlayers[6]->getKey())?->roster->number)->toBe(22) - ->and($freshGame->awayPlayers->firstWhere('id', $awayPlayers[6]->getKey())?->roster->is_libero)->toBeTrue() + ->and($freshGame->homePlayers->firstWhere('id', $homePlayers[0]->getKey())?->is_captain)->toBeTrue() + ->and($freshGame->homePlayers->firstWhere('id', $homePlayers[6]->getKey())?->number)->toBe(12) + ->and($freshGame->homePlayers->firstWhere('id', $homePlayers[6]->getKey())?->is_libero)->toBeTrue() + ->and($freshGame->awayPlayers->firstWhere('id', $awayPlayers[0]->getKey())?->is_captain)->toBeTrue() + ->and($freshGame->awayPlayers->firstWhere('id', $awayPlayers[6]->getKey())?->number)->toBe(22) + ->and($freshGame->awayPlayers->firstWhere('id', $awayPlayers[6]->getKey())?->is_libero)->toBeTrue() ->and($freshGame->homeStaff->first()?->getKey())->toBe($homeCoach->getKey()) ->and($freshGame->awayStaff->first()?->getKey())->toBe($awayDoctor->getKey()) ->and($freshGame->rosters_submitted)->toBeTrue(); }); -test('confirming rosters only saves the checked staff members for each team', function (): void { - $game = Game::factory()->create(); - $homePlayers = Player::factory()->for($game->homeTeam)->count(6)->create(); - $awayPlayers = Player::factory()->for($game->awayTeam)->count(6)->create(); - $homeCoach = Staff::factory()->for($game->homeTeam)->asCoach()->create(); - $homeDoctor = Staff::factory()->for($game->homeTeam)->asDoctor()->create(); - $homeTherapist = Staff::factory()->for($game->homeTeam)->asTherapist()->create(); - $awayCoach = Staff::factory()->for($game->awayTeam)->asCoach()->create(); - $awayAssistantCoach = Staff::factory()->for($game->awayTeam)->asAssistantCoach()->create(); - $awayDoctor = Staff::factory()->for($game->awayTeam)->asDoctor()->create(); - - $component = Livewire::test(RosterSubmission::class, ['gameId' => $game->getKey()]); - - foreach ($homePlayers as $index => $player) { - $component->set("homeRosterInputs.{$player->getKey()}", (string) ($index + 1)); - } - - foreach ($awayPlayers as $index => $player) { - $component->set("awayRosterInputs.{$player->getKey()}", (string) ($index + 11)); - } - - $component - ->set('homeCaptainSelection', (string) $homePlayers[0]->getKey()) - ->set('awayCaptainSelection', (string) $awayPlayers[0]->getKey()) - ->set("homeStaffSelection.{$homeCoach->getKey()}", true) - ->set("homeStaffSelection.{$homeDoctor->getKey()}", true) - ->set("awayStaffSelection.{$awayAssistantCoach->getKey()}", true) - ->call('confirmRosters') - ->assertHasNoErrors(); - - $freshGame = $game->fresh(); - - expect($freshGame->homeStaff->modelKeys()) - ->toBe([$homeCoach->getKey(), $homeDoctor->getKey()]) - ->and($freshGame->homeStaff->contains(fn (Staff $staff): bool => $staff->getKey() === $homeTherapist->getKey()))->toBeFalse() - ->and($freshGame->awayStaff->modelKeys())->toBe([$awayAssistantCoach->getKey()]) - ->and($freshGame->awayStaff->contains(fn (Staff $staff): bool => $staff->getKey() === $awayCoach->getKey()))->toBeFalse() - ->and($freshGame->awayStaff->contains(fn (Staff $staff): bool => $staff->getKey() === $awayDoctor->getKey()))->toBeFalse(); -}); - -test('returning to roster from confirmation preserves entered selections', function (): void { - $game = Game::factory()->create(); - $homePlayers = Player::factory()->for($game->homeTeam)->count(6)->create(); - $awayPlayers = Player::factory()->for($game->awayTeam)->count(6)->create(); - - $component = Livewire::test(RosterSubmission::class, ['gameId' => $game->getKey()]); - - foreach ($homePlayers as $index => $player) { - $component->set("homeRosterInputs.{$player->getKey()}", (string) ($index + 1)); - } - - foreach ($awayPlayers as $index => $player) { - $component->set("awayRosterInputs.{$player->getKey()}", (string) ($index + 11)); - } - - $component - ->set('homeCaptainSelection', (string) $homePlayers[1]->getKey()) - ->set('awayCaptainSelection', (string) $awayPlayers[2]->getKey()) - ->set("homeLiberoSelection.{$homePlayers[5]->getKey()}", true) - ->set("awayLiberoSelection.{$awayPlayers[4]->getKey()}", true) - ->call('submitRosters') - ->assertSee('Confirm rosters') - ->call('returnToRoster') - ->assertSet("homeRosterInputs.{$homePlayers[0]->getKey()}", '1') - ->assertSet("awayRosterInputs.{$awayPlayers[0]->getKey()}", '11') - ->assertSet('homeCaptainSelection', (string) $homePlayers[1]->getKey()) - ->assertSet('awayCaptainSelection', (string) $awayPlayers[2]->getKey()) - ->assertSet("homeLiberoSelection.{$homePlayers[5]->getKey()}", true) - ->assertSet("awayLiberoSelection.{$awayPlayers[4]->getKey()}", true); -}); - -test('confirmation groups and orders roster details for each team', function (): void { - $game = Game::factory()->create(); - - $homePlayers = collect([ - Player::factory()->for($game->homeTeam)->named('Alice', 'Zulu')->create(), - Player::factory()->for($game->homeTeam)->named('Beth', 'Able')->create(), - Player::factory()->for($game->homeTeam)->named('Cara', 'Mason')->create(), - Player::factory()->for($game->homeTeam)->named('Dana', 'Nolan')->create(), - Player::factory()->for($game->homeTeam)->named('Eve', 'Olsen')->create(), - Player::factory()->for($game->homeTeam)->named('Faye', 'Piper')->create(), - Player::factory()->for($game->homeTeam)->named('Gia', 'Quill')->create(), - ]); - - $awayPlayers = Player::factory()->for($game->awayTeam)->count(6)->create(); - - $homeCoach = Staff::factory()->for($game->homeTeam)->asCoach()->named('Holly', 'Coach')->create(); - $assistantBravo = Staff::factory()->for($game->homeTeam)->asAssistantCoach()->named('Iris', 'Bravo')->create(); - $assistantAlpha = Staff::factory()->for($game->homeTeam)->asAssistantCoach()->named('Jade', 'Alpha')->create(); - $therapist = Staff::factory()->for($game->homeTeam)->asTherapist()->named('Kora', 'Therapist')->create(); - $doctor = Staff::factory()->for($game->homeTeam)->asDoctor()->named('Lina', 'Doctor')->create(); - - $component = Livewire::test(RosterSubmission::class, ['gameId' => $game->getKey()]); - - foreach ($homePlayers as $index => $player) { - $number = match ($index) { - 0 => '8', - 1 => '3', - 2 => '11', - 3 => '5', - 4 => '7', - 5 => '2', - default => '13', - }; - - $component->set("homeRosterInputs.{$player->getKey()}", $number); - } - - foreach ($awayPlayers as $index => $player) { - $component->set("awayRosterInputs.{$player->getKey()}", (string) ($index + 21)); - } - - $component - ->set('homeCaptainSelection', (string) $homePlayers[1]->getKey()) - ->set('awayCaptainSelection', (string) $awayPlayers[0]->getKey()) - ->set("homeLiberoSelection.{$homePlayers[6]->getKey()}", true) - ->set("homeStaffSelection.{$homeCoach->getKey()}", true) - ->set("homeStaffSelection.{$assistantBravo->getKey()}", true) - ->set("homeStaffSelection.{$assistantAlpha->getKey()}", true) - ->set("homeStaffSelection.{$therapist->getKey()}", true) - ->set("homeStaffSelection.{$doctor->getKey()}", true) - ->call('submitRosters') - ->assertSeeInOrder([ - 'Players', - '3', - 'Able B.', - '5', - 'Nolan D.', - '7', - 'Olsen E.', - '8', - 'Zulu A.', - '11', - 'Mason C.', - 'Liberos', - '13', - 'Quill G.', - 'Bench', - 'C', - 'Coach H.', - 'AC1', - 'Alpha J.', - 'AC2', - 'Bravo I.', - 'T', - 'Therapist K.', - 'D', - 'Doctor L.', - ]); -}); - test('submitting rosters requires six non-libero players per team', function (): void { - $game = Game::factory()->create(); - $homePlayers = Player::factory()->for($game->homeTeam)->count(6)->create(); - $awayPlayers = Player::factory()->for($game->awayTeam)->count(6)->create(); - - $component = Livewire::test(RosterSubmission::class, ['gameId' => $game->getKey()]); - - foreach ($homePlayers as $index => $player) { - $component->set("homeRosterInputs.{$player->getKey()}", $index === 5 ? '7' : (string) ($index + 1)); - } - - foreach ($awayPlayers as $index => $player) { - $component->set("awayRosterInputs.{$player->getKey()}", (string) ($index + 11)); - } - - $component - ->set('homeCaptainSelection', (string) $homePlayers[0]->getKey()) - ->set('awayCaptainSelection', (string) $awayPlayers[0]->getKey()) - ->set("homeLiberoSelection.{$homePlayers[5]->getKey()}", true) - ->call('confirmRosters') - ->assertHasErrors(['homeRosterInputs']); -}); - -test('submitting rosters requires player numbers to be between 1 and 99', function (): void { - $game = Game::factory()->create(); - $homePlayers = Player::factory()->for($game->homeTeam)->count(6)->create(); - $awayPlayers = Player::factory()->for($game->awayTeam)->count(6)->create(); - - $component = Livewire::test(RosterSubmission::class, ['gameId' => $game->getKey()]); - - foreach ($homePlayers as $index => $player) { - $component->set("homeRosterInputs.{$player->getKey()}", $index === 0 ? '0' : (string) ($index + 1)); - } - - foreach ($awayPlayers as $index => $player) { - $component->set("awayRosterInputs.{$player->getKey()}", $index === 0 ? '100' : (string) ($index + 11)); - } - - $component - ->set('homeCaptainSelection', (string) $homePlayers[0]->getKey()) - ->set('awayCaptainSelection', (string) $awayPlayers[0]->getKey()) - ->call('confirmRosters') - ->assertHasErrors([ - "homeRosterInputs.{$homePlayers[0]->getKey()}", - "awayRosterInputs.{$awayPlayers[0]->getKey()}", - ]); -}); - -test('submitting rosters requires unique player numbers within each team including liberos', function (): void { - $game = Game::factory()->create(); - $homePlayers = Player::factory()->for($game->homeTeam)->count(7)->create(); - $awayPlayers = Player::factory()->for($game->awayTeam)->count(6)->create(); - - $component = Livewire::test(RosterSubmission::class, ['gameId' => $game->getKey()]); - - foreach ($homePlayers as $index => $player) { - $component->set("homeRosterInputs.{$player->getKey()}", $index === 6 ? '1' : (string) ($index + 1)); - } - - foreach ($awayPlayers as $index => $player) { - $component->set("awayRosterInputs.{$player->getKey()}", (string) ($index + 11)); - } - - $component - ->set('homeCaptainSelection', (string) $homePlayers[0]->getKey()) - ->set('awayCaptainSelection', (string) $awayPlayers[0]->getKey()) - ->set("homeLiberoSelection.{$homePlayers[6]->getKey()}", true) - ->call('confirmRosters') - ->assertHasErrors(["homeRosterInputs.{$homePlayers[6]->getKey()}"]); -}); + $game = createCurrentMatch(); + $rosterCandidates = seedRosterCandidates($game, 6); + $homePlayers = $rosterCandidates['home_players']; + $awayPlayers = $rosterCandidates['away_players']; -test('submitting rosters allows at most two liberos per team', function (): void { - $game = Game::factory()->create(); - $homePlayers = Player::factory()->for($game->homeTeam)->count(8)->create(); - $awayPlayers = Player::factory()->for($game->awayTeam)->count(6)->create(); - - $component = Livewire::test(RosterSubmission::class, ['gameId' => $game->getKey()]); + $component = Livewire::test(RosterSubmission::class); foreach ($homePlayers as $index => $player) { $component->set("homeRosterInputs.{$player->getKey()}", (string) ($index + 1)); - } - - foreach ($awayPlayers as $index => $player) { - $component->set("awayRosterInputs.{$player->getKey()}", (string) ($index + 11)); - } - - $component - ->set('homeCaptainSelection', (string) $homePlayers[0]->getKey()) - ->set('awayCaptainSelection', (string) $awayPlayers[0]->getKey()) - ->set("homeLiberoSelection.{$homePlayers[5]->getKey()}", true) - ->set("homeLiberoSelection.{$homePlayers[6]->getKey()}", true) - ->set("homeLiberoSelection.{$homePlayers[7]->getKey()}", true) - ->call('confirmRosters') - ->assertHasErrors(['homeRosterInputs']); -}); - -test('submitting rosters allows at most twelve non-libero players per team', function (): void { - $game = Game::factory()->create(); - $homePlayers = Player::factory()->for($game->homeTeam)->count(14)->create(); - $awayPlayers = Player::factory()->for($game->awayTeam)->count(6)->create(); - - $component = Livewire::test(RosterSubmission::class, ['gameId' => $game->getKey()]); - - foreach ($homePlayers as $index => $player) { - $component->set("homeRosterInputs.{$player->getKey()}", (string) ($index + 1)); - } - - foreach ($awayPlayers as $index => $player) { - $component->set("awayRosterInputs.{$player->getKey()}", (string) ($index + 21)); - } - - $component - ->set('homeCaptainSelection', (string) $homePlayers[0]->getKey()) - ->set('awayCaptainSelection', (string) $awayPlayers[0]->getKey()) - ->set("homeLiberoSelection.{$homePlayers[12]->getKey()}", true) - ->set("homeLiberoSelection.{$homePlayers[13]->getKey()}", true) - ->call('confirmRosters') - ->assertHasNoErrors(['homeRosterInputs']); - - $component - ->set("homeLiberoSelection.{$homePlayers[12]->getKey()}", false) - ->call('confirmRosters') - ->assertHasErrors(['homeRosterInputs']); -}); - -test('submitting rosters requires a libero to be a rostered player', function (): void { - $game = Game::factory()->create(); - $homePlayers = Player::factory()->for($game->homeTeam)->count(6)->create(); - $awayPlayers = Player::factory()->for($game->awayTeam)->count(6)->create(); - $component = Livewire::test(RosterSubmission::class, ['gameId' => $game->getKey()]); - - foreach ($homePlayers as $index => $player) { - $component->set("homeRosterInputs.{$player->getKey()}", $index === 5 ? '' : (string) ($index + 1)); + if ($index === 5) { + $component->set("homeLiberoSelection.{$player->getKey()}", true); + } } foreach ($awayPlayers as $index => $player) { @@ -391,101 +117,7 @@ $component ->set('homeCaptainSelection', (string) $homePlayers[0]->getKey()) ->set('awayCaptainSelection', (string) $awayPlayers[0]->getKey()) - ->set("homeLiberoSelection.{$homePlayers[5]->getKey()}", true) - ->call('confirmRosters') - ->assertHasErrors(["homeRosterInputs.{$homePlayers[5]->getKey()}"]) - ->assertHasNoErrors(['homeCaptainSelection']); -}); - -test('submitting rosters requires one captain per team', function (): void { - $game = Game::factory()->create(); - $homePlayers = Player::factory()->for($game->homeTeam)->count(6)->create(); - $awayPlayers = Player::factory()->for($game->awayTeam)->count(6)->create(); - - $component = Livewire::test(RosterSubmission::class, ['gameId' => $game->getKey()]); - - foreach ($homePlayers as $index => $player) { - $component->set("homeRosterInputs.{$player->getKey()}", (string) ($index + 1)); - } - - foreach ($awayPlayers as $index => $player) { - $component->set("awayRosterInputs.{$player->getKey()}", (string) ($index + 11)); - } - - $component - ->set('awayCaptainSelection', (string) $awayPlayers[0]->getKey()) - ->call('confirmRosters') - ->assertHasErrors(['homeCaptainSelection']); -}); - -test('submitting rosters requires the captain to be one of the rostered players', function (): void { - $game = Game::factory()->create(); - $homePlayers = Player::factory()->for($game->homeTeam)->count(6)->create(); - $awayPlayers = Player::factory()->for($game->awayTeam)->count(6)->create(); - - $component = Livewire::test(RosterSubmission::class, ['gameId' => $game->getKey()]); - - foreach ($homePlayers as $index => $player) { - $component->set("homeRosterInputs.{$player->getKey()}", (string) ($index + 1)); - } - - foreach ($awayPlayers as $index => $player) { - $component->set("awayRosterInputs.{$player->getKey()}", (string) ($index + 11)); - } - - $component - ->set('homeCaptainSelection', (string) $homePlayers[0]->getKey()) - ->set('awayCaptainSelection', (string) $awayPlayers[5]->getKey()) - ->set("awayRosterInputs.{$awayPlayers[5]->getKey()}", '') - ->call('confirmRosters') - ->assertHasErrors(['awayCaptainSelection']) - ->assertHasNoErrors(["awayRosterInputs.{$awayPlayers[5]->getKey()}"]); -}); - -test('roster validation errors clear when the user fixes the input', function (): void { - $game = Game::factory()->create(); - $homePlayers = Player::factory()->for($game->homeTeam)->count(6)->create(); - $awayPlayers = Player::factory()->for($game->awayTeam)->count(6)->create(); - - $component = Livewire::test(RosterSubmission::class, ['gameId' => $game->getKey()]); - - foreach ($homePlayers as $index => $player) { - $component->set("homeRosterInputs.{$player->getKey()}", $index === 1 ? '1' : (string) ($index + 1)); - } - - foreach ($awayPlayers as $index => $player) { - $component->set("awayRosterInputs.{$player->getKey()}", (string) ($index + 11)); - } - - $component - ->set('homeCaptainSelection', (string) $homePlayers[0]->getKey()) - ->set('awayCaptainSelection', (string) $awayPlayers[0]->getKey()) - ->call('confirmRosters') - ->assertHasErrors(["homeRosterInputs.{$homePlayers[1]->getKey()}"]); - - $component - ->set("homeRosterInputs.{$homePlayers[1]->getKey()}", '2') - ->assertHasNoErrors(["homeRosterInputs.{$homePlayers[1]->getKey()}"]); + ->call('submitRosters') + ->assertHasErrors(['homeRosterInputs']) + ->assertHasNoErrors(['awayRosterInputs']); }); - -function seedRosterableTeams(Game $game): void -{ - $homePlayers = Player::factory()->for($game->homeTeam)->count(6)->create(); - $awayPlayers = Player::factory()->for($game->awayTeam)->count(6)->create(); - - $component = Livewire::test(RosterSubmission::class, ['gameId' => $game->getKey()]); - - foreach ($homePlayers as $index => $player) { - $component->set("homeRosterInputs.{$player->getKey()}", (string) ($index + 1)); - } - - foreach ($awayPlayers as $index => $player) { - $component->set("awayRosterInputs.{$player->getKey()}", (string) ($index + 11)); - } - - $component - ->set('homeCaptainSelection', (string) $homePlayers[0]->getKey()) - ->set('awayCaptainSelection', (string) $awayPlayers[0]->getKey()) - ->call('confirmRosters') - ->assertHasNoErrors(); -} diff --git a/tests/Feature/ScoreboardTest.php b/tests/Feature/ScoreboardTest.php index 8dc4c68..453c9e9 100644 --- a/tests/Feature/ScoreboardTest.php +++ b/tests/Feature/ScoreboardTest.php @@ -23,7 +23,6 @@ $teamBCode = $game->homeTeam->country_code; Livewire::test(Scoreboard::class, [ - 'gameId' => $game->getKey(), 'gameState' => GameState::fromAttributes([ 'sets_won_team_a' => 0, 'sets_won_team_b' => 0, @@ -57,7 +56,6 @@ $teamBCode = $game->awayTeam->country_code; Livewire::test(Scoreboard::class, [ - 'gameId' => $game->getKey(), 'gameState' => GameState::fromAttributes([ 'sets_won_team_a' => 2, 'sets_won_team_b' => 1, @@ -118,7 +116,6 @@ $teamBCode = $game->homeTeam->country_code; Livewire::test(Scoreboard::class, [ - 'gameId' => $game->getKey(), 'gameState' => GameState::fromAttributes([ 'sets_won_team_a' => 0, 'sets_won_team_b' => 0, @@ -152,7 +149,6 @@ $teamBCode = $game->awayTeam->country_code; Livewire::test(Scoreboard::class, [ - 'gameId' => $game->getKey(), 'gameState' => GameState::fromAttributes([ 'set_number' => 5, 'sets_won_team_a' => 2, diff --git a/tests/Feature/ScoresheetDataRepositoryTest.php b/tests/Feature/ScoresheetDataRepositoryTest.php index c727738..7ee54f6 100644 --- a/tests/Feature/ScoresheetDataRepositoryTest.php +++ b/tests/Feature/ScoresheetDataRepositoryTest.php @@ -3,17 +3,43 @@ declare(strict_types=1); use App\Enums\GameEventType; +use App\Enums\OfficialRole; +use App\Enums\StaffRole; use App\Enums\TeamAB; use App\Enums\TeamSide; use App\Events\Payloads\TossCompletedPayload; use App\Models\Game; +use App\Models\Official; use App\Models\Player; +use App\Models\Staff; use App\Models\Team; use App\Services\ScoresheetDataRepository; +use Carbon\CarbonImmutable; use Illuminate\Foundation\Testing\RefreshDatabase; uses(RefreshDatabase::class); +test('match info uses configured competition metadata and single match setup data', function (): void { + config()->set('competition.name', 'Nations League Finals'); + + $game = configuredScoresheetGame(); + $repository = app(ScoresheetDataRepository::class); + + $matchInfo = $repository->matchInfo($game); + + expect($matchInfo['competition_name'])->toBe('Nations League Finals') + ->and($matchInfo['city'])->toBe('Turin') + ->and($matchInfo['country_code'])->toBe('ITA') + ->and($matchInfo['hall'])->toBe('Pala Alpitour') + ->and($matchInfo['pool'])->toBe('A') + ->and($matchInfo['match_number'])->toBe(7) + ->and($matchInfo['scheduled_at']->format('Y-m-d H:i'))->toBe('2026-06-09 18:30') + ->and($matchInfo['division'])->toBe('Men') + ->and($matchInfo['category'])->toBe('Senior') + ->and($matchInfo['home_team_code'])->toBe('ITA') + ->and($matchInfo['away_team_code'])->toBe('BRA'); +}); + test('players for side returns non-libero players for each side', function (): void { $game = gameWithNumberedRostersForScoresheetDataRepository(); $repository = app(ScoresheetDataRepository::class); @@ -42,6 +68,30 @@ ->and(collect($playersAfterRosterChange)->pluck('player_key')->all())->toContain($newHomePlayer->getKey()); }); +test('team sheet groups players liberos and staff from the singleton match roster', function (): void { + $game = configuredScoresheetGame(); + $repository = app(ScoresheetDataRepository::class); + + $homeTeamSheet = $repository->teamSheet($game, TeamSide::Home); + + expect($homeTeamSheet['team_code'])->toBe('ITA') + ->and(collect($homeTeamSheet['players'])->pluck('number')->all())->toBe([3, 12]) + ->and(collect($homeTeamSheet['players'])->pluck('is_captain')->all())->toBe([false, true]) + ->and(collect($homeTeamSheet['liberos'])->pluck('number')->all())->toBe([1]) + ->and(collect($homeTeamSheet['staff'])->pluck('role')->all())->toBe([StaffRole::Coach, StaffRole::AssistantCoach]); +}); + +test('official assignments are returned in scoresheet order', function (): void { + $game = configuredScoresheetGame(); + $repository = app(ScoresheetDataRepository::class); + + $officials = $repository->officials($game); + + expect(collect($officials)->pluck('role')->all())->toBe(OfficialRole::cases()) + ->and($officials[0]['country_code'])->toBe('ITA') + ->and($officials[1]['country_code'])->toBe('BRA'); +}); + test('latest toss payload returns the recorded toss payload', function (): void { $game = gameWithNumberedRostersForScoresheetDataRepository(); $game->recordToss(TeamSide::Away, TeamAB::TeamB); @@ -78,6 +128,52 @@ ->and($payloadAfterRecordingToss?->serving)->toBe(TeamAB::TeamB); }); +test('results are derived from the recorded event log', function (): void { + $game = configuredScoresheetGame(withBenchPlayers: true); + recordCompletedMatch($game); + + $repository = app(ScoresheetDataRepository::class); + $results = $repository->results($game); + + expect($results['team_a_code'])->toBe('ITA') + ->and($results['team_b_code'])->toBe('BRA') + ->and($results['winner_team_code'])->toBe('ITA') + ->and($results['team_a_sets_won'])->toBe(3) + ->and($results['team_b_sets_won'])->toBe(1) + ->and($results['match_start_time']?->format('H:i'))->toBe('18:00') + ->and($results['match_end_time']?->format('H:i'))->toBe('19:58') + ->and($results['total_duration_minutes'])->toBe(118) + ->and($results['total_set_duration_minutes'])->toBe(92) + ->and($results['sets'])->toHaveCount(4) + ->and($results['sets'][0])->toMatchArray([ + 'set_number' => 1, + 'team_a_points' => 25, + 'team_b_points' => 18, + 'team_a_timeouts' => 1, + 'team_b_timeouts' => 0, + 'team_a_substitutions' => 1, + 'team_b_substitutions' => 0, + 'team_a_sets_won' => 1, + 'team_b_sets_won' => 0, + 'duration_minutes' => 20, + ]) + ->and($results['sets'][1])->toMatchArray([ + 'set_number' => 2, + 'team_a_points' => 20, + 'team_b_points' => 25, + 'team_a_sets_won' => 1, + 'team_b_sets_won' => 1, + ]) + ->and($results['sets'][3])->toMatchArray([ + 'set_number' => 4, + 'team_a_points' => 25, + 'team_b_points' => 21, + 'team_a_sets_won' => 3, + 'team_b_sets_won' => 1, + 'duration_minutes' => 24, + ]); +}); + function gameWithNumberedRostersForScoresheetDataRepository(): Game { $homeTeam = Team::factory()->create(); @@ -98,6 +194,206 @@ function gameWithNumberedRostersForScoresheetDataRepository(): Game $game->addPlayer($awayPlayerOne, number: 9); $game->addPlayer($awayPlayerTwo, number: 2); $game->addPlayer($awayLibero, number: 20, isLibero: true); + $game->markRostersSubmitted(); + + foreach (OfficialRole::cases() as $index => $role) { + $game->addOfficial( + Official::factory() + ->named('Official', 'Numbered'.($index + 1)) + ->withCountryCode($index % 2 === 0 ? 'ITA' : 'BRA') + ->create(), + $role, + ); + } + + return $game->fresh(); +} + +function configuredScoresheetGame(bool $withBenchPlayers = false): Game +{ + $homeTeam = Team::factory()->named('Italy')->withCountryCode('ITA')->create(); + $awayTeam = Team::factory()->named('Brazil')->withCountryCode('BRA')->create(); + $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); + $game->forceFill([ + 'number' => 7, + 'country_code' => 'ITA', + 'city' => 'Turin', + 'hall' => 'Pala Alpitour', + 'date_time' => CarbonImmutable::parse('2026-06-09 18:30:00'), + 'division' => 'Men', + 'pool' => 'A', + 'category' => 'Senior', + ])->save(); + + $homePlayerOne = Player::factory()->for($homeTeam)->named('Anna', 'Zephyr')->create(); + $homePlayerTwo = Player::factory()->for($homeTeam)->named('Beth', 'Anderson')->create(); + $homeLibero = Player::factory()->for($homeTeam)->named('Cara', 'Libero')->create(); + $awayPlayerOne = Player::factory()->for($awayTeam)->named('Dora', 'Young')->create(); + $awayPlayerTwo = Player::factory()->for($awayTeam)->named('Etta', 'Baker')->create(); + $awayLibero = Player::factory()->for($awayTeam)->named('Faye', 'Keeper')->create(); + + $game->addPlayer($homePlayerOne, number: 12, isCaptain: true); + $game->addPlayer($homePlayerTwo, number: 3); + $game->addPlayer($homeLibero, number: 1, isLibero: true); + $game->addPlayer($awayPlayerOne, number: 9, isCaptain: true); + $game->addPlayer($awayPlayerTwo, number: 2); + $game->addPlayer($awayLibero, number: 20, isLibero: true); + + if ($withBenchPlayers) { + foreach (range(4, 7) as $number) { + $game->addPlayer( + Player::factory()->for($homeTeam)->named('Home', 'Bench'.$number)->create(), + number: $number, + ); + } + + foreach (range(11, 17) as $number) { + $game->addPlayer( + Player::factory()->for($awayTeam)->named('Away', 'Bench'.$number)->create(), + number: $number, + ); + } + } + + $game->addStaff(Staff::factory()->for($homeTeam)->asCoach()->named('Hugo', 'Coach')->create()); + $game->addStaff(Staff::factory()->for($homeTeam)->asAssistantCoach()->named('Ivy', 'Assistant')->create()); + $game->addStaff(Staff::factory()->for($awayTeam)->asCoach()->named('Joao', 'Coach')->create()); + $game->addStaff(Staff::factory()->for($awayTeam)->asDoctor()->named('Lia', 'Doctor')->create()); + $game->markRostersSubmitted(); + + foreach (OfficialRole::cases() as $index => $role) { + $countryCode = $index % 2 === 0 ? 'ITA' : 'BRA'; + $game->addOfficial( + Official::factory() + ->named('Official', 'Role'.($index + 1)) + ->withCountryCode($countryCode) + ->create(), + $role, + ); + } + + return $game->fresh(); +} + +function recordCompletedMatch(Game $game): void +{ + CarbonImmutable::setTestNow('2026-06-09 17:55:00'); + $game->recordToss(TeamSide::Home, TeamAB::TeamA); + + recordCompletedSet( + game: $game, + setNumber: 1, + startedAt: '2026-06-09 18:00:00', + endedAt: '2026-06-09 18:20:00', + winner: TeamAB::TeamA, + winnerPoints: 25, + loserPoints: 18, + teamATimeouts: 1, + teamBTimeouts: 0, + teamASubstitutions: [[12, 4]], + teamBSubstitutions: [], + ); + + recordCompletedSet( + game: $game, + setNumber: 2, + startedAt: '2026-06-09 18:28:00', + endedAt: '2026-06-09 18:50:00', + winner: TeamAB::TeamB, + winnerPoints: 25, + loserPoints: 20, + teamATimeouts: 0, + teamBTimeouts: 1, + teamASubstitutions: [], + teamBSubstitutions: [[11, 17]], + ); + + recordCompletedSet( + game: $game, + setNumber: 3, + startedAt: '2026-06-09 19:00:00', + endedAt: '2026-06-09 19:26:00', + winner: TeamAB::TeamA, + winnerPoints: 25, + loserPoints: 23, + teamATimeouts: 1, + teamBTimeouts: 1, + teamASubstitutions: [], + teamBSubstitutions: [], + ); + + recordCompletedSet( + game: $game, + setNumber: 4, + startedAt: '2026-06-09 19:34:00', + endedAt: '2026-06-09 19:58:00', + winner: TeamAB::TeamA, + winnerPoints: 25, + loserPoints: 21, + teamATimeouts: 0, + teamBTimeouts: 2, + teamASubstitutions: [], + teamBSubstitutions: [], + ); + + CarbonImmutable::setTestNow(); +} + +/** + * @param array $teamASubstitutions + * @param array $teamBSubstitutions + */ +function recordCompletedSet( + Game $game, + int $setNumber, + string $startedAt, + string $endedAt, + TeamAB $winner, + int $winnerPoints, + int $loserPoints, + int $teamATimeouts, + int $teamBTimeouts, + array $teamASubstitutions, + array $teamBSubstitutions, +): void { + CarbonImmutable::setTestNow($startedAt); + $game->recordLineup($setNumber, TeamAB::TeamA, [1 => 12, 2 => 3, 3 => 4, 4 => 5, 5 => 6, 6 => 7]); + $game->recordLineup($setNumber, TeamAB::TeamB, [1 => 9, 2 => 2, 3 => 11, 4 => 12, 5 => 13, 6 => 14]); + $game->recordSetStarted(); + + if ($teamATimeouts > 0) { + foreach (range(1, $teamATimeouts) as $timeout) { + $game->recordTimeOut(TeamAB::TeamA); + } + } + + if ($teamBTimeouts > 0) { + foreach (range(1, $teamBTimeouts) as $timeout) { + $game->recordTimeOut(TeamAB::TeamB); + } + } + + foreach ($teamASubstitutions as [$playerOut, $playerIn]) { + $game->recordSubstitution(TeamAB::TeamA, $playerOut, $playerIn); + } + + foreach ($teamBSubstitutions as [$playerOut, $playerIn]) { + $game->recordSubstitution(TeamAB::TeamB, $playerOut, $playerIn); + } + + $loser = $winner === TeamAB::TeamA ? TeamAB::TeamB : TeamAB::TeamA; + + if ($loserPoints > 0) { + foreach (range(1, $loserPoints) as $rally) { + $game->recordRallyWinner($loser); + } + } + + CarbonImmutable::setTestNow($endedAt); - return $game; + if ($winnerPoints > 0) { + foreach (range(1, $winnerPoints) as $rally) { + $game->recordRallyWinner($winner); + } + } } diff --git a/tests/Feature/ScoresheetWriterTest.php b/tests/Feature/ScoresheetWriterTest.php new file mode 100644 index 0000000..6986763 --- /dev/null +++ b/tests/Feature/ScoresheetWriterTest.php @@ -0,0 +1,258 @@ +set('competition.name', 'Nations League Finals'); + + $game = configuredScoresheetWriterGame(); + $pdf = new RecordingScoresheetPdf; + + app(MatchInfoWriter::class)->write($pdf, $game); + + expect(collect($pdf->writes)->pluck('text')->all())->toContain('Nations League Finals') + ->and(collect($pdf->spacedPrints)->pluck('text')->all())->toContain( + 'Turin', + 'ITA', + 'Pala Alpitour', + 'A', + '7', + '090626', + '1830', + 'BRA', + ) + ->and($pdf->lines)->toHaveCount(4); +}); + +test('teams writer renders rosters captains liberos and staff', function (): void { + $game = configuredScoresheetWriterGame(); + $pdf = new RecordingScoresheetPdf; + + app(TeamsWriter::class)->write($pdf, $game); + + expect(collect($pdf->spacedPrints)->pluck('text')->all())->toContain('ITA', 'BRA') + ->and(collect($pdf->writes)->pluck('text')->all())->toContain( + '12', + '3', + '1', + 'ZEPHYR', + 'ANDERSON', + 'LIBERO', + 'COACH, H.', + 'ASSISTANT, I.', + ) + ->and($pdf->circles)->toHaveCount(2); +}); + +test('results writer renders final score from the recorded event log', function (): void { + $game = configuredScoresheetWriterGame(withBenchPlayers: true); + recordCompletedScoresheetWriterMatch($game); + $pdf = new RecordingScoresheetPdf; + + app(ResultsWriter::class)->write($pdf, $game->fresh()); + + expect(collect($pdf->spacedPrints)->pluck('text')->all())->toContain('ITA', 'BRA', '1800', '1958') + ->and(collect($pdf->writes)->pluck('text')->all())->toContain('25', '18', '20', '22', '26', '24', '92', '3 : 1') + ->and(last($pdf->spacedPrints)['text'])->toBe('ITA'); +}); +function configuredScoresheetWriterGame(bool $withBenchPlayers = false): Game +{ + $homeTeam = Team::factory()->named('Italy')->withCountryCode('ITA')->create(); + $awayTeam = Team::factory()->named('Brazil')->withCountryCode('BRA')->create(); + $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); + $game->forceFill([ + 'number' => 7, + 'country_code' => 'ITA', + 'city' => 'Turin', + 'hall' => 'Pala Alpitour', + 'date_time' => CarbonImmutable::parse('2026-06-09 18:30:00'), + 'division' => 'Men', + 'pool' => 'A', + 'category' => 'Senior', + ])->save(); + + $homePlayerOne = Player::factory()->for($homeTeam)->named('Anna', 'Zephyr')->create(); + $homePlayerTwo = Player::factory()->for($homeTeam)->named('Beth', 'Anderson')->create(); + $homeLibero = Player::factory()->for($homeTeam)->named('Cara', 'Libero')->create(); + $awayPlayerOne = Player::factory()->for($awayTeam)->named('Dora', 'Young')->create(); + $awayPlayerTwo = Player::factory()->for($awayTeam)->named('Etta', 'Baker')->create(); + $awayLibero = Player::factory()->for($awayTeam)->named('Faye', 'Keeper')->create(); + + $game->addPlayer($homePlayerOne, number: 12, isCaptain: true); + $game->addPlayer($homePlayerTwo, number: 3); + $game->addPlayer($homeLibero, number: 1, isLibero: true); + $game->addPlayer($awayPlayerOne, number: 9, isCaptain: true); + $game->addPlayer($awayPlayerTwo, number: 2); + $game->addPlayer($awayLibero, number: 20, isLibero: true); + + if ($withBenchPlayers) { + foreach (range(4, 7) as $number) { + $game->addPlayer( + Player::factory()->for($homeTeam)->named('Home', 'Bench'.$number)->create(), + number: $number, + ); + } + + foreach (range(11, 17) as $number) { + $game->addPlayer( + Player::factory()->for($awayTeam)->named('Away', 'Bench'.$number)->create(), + number: $number, + ); + } + } + + $game->addStaff(Staff::factory()->for($homeTeam)->asCoach()->named('Hugo', 'Coach')->create()); + $game->addStaff(Staff::factory()->for($homeTeam)->asAssistantCoach()->named('Ivy', 'Assistant')->create()); + $game->addStaff(Staff::factory()->for($awayTeam)->asCoach()->named('Joao', 'Coach')->create()); + $game->addStaff(Staff::factory()->for($awayTeam)->asDoctor()->named('Lia', 'Doctor')->create()); + $game->markRostersSubmitted(); + + foreach (OfficialRole::cases() as $index => $role) { + $countryCode = $index % 2 === 0 ? 'ITA' : 'BRA'; + $game->addOfficial( + Official::factory() + ->named('Official', 'Role'.($index + 1)) + ->withCountryCode($countryCode) + ->create(), + $role, + ); + } + + return $game->fresh(); +} + +function recordCompletedScoresheetWriterMatch(Game $game): void +{ + CarbonImmutable::setTestNow('2026-06-09 17:55:00'); + $game->recordToss(TeamSide::Home, TeamAB::TeamA); + + recordCompletedScoresheetWriterSet( + game: $game, + setNumber: 1, + startedAt: '2026-06-09 18:00:00', + endedAt: '2026-06-09 18:20:00', + winner: TeamAB::TeamA, + winnerPoints: 25, + loserPoints: 18, + teamATimeouts: 1, + teamBTimeouts: 0, + teamASubstitutions: [[12, 4]], + teamBSubstitutions: [], + ); + + recordCompletedScoresheetWriterSet( + game: $game, + setNumber: 2, + startedAt: '2026-06-09 18:28:00', + endedAt: '2026-06-09 18:50:00', + winner: TeamAB::TeamB, + winnerPoints: 25, + loserPoints: 20, + teamATimeouts: 0, + teamBTimeouts: 1, + teamASubstitutions: [], + teamBSubstitutions: [[11, 17]], + ); + + recordCompletedScoresheetWriterSet( + game: $game, + setNumber: 3, + startedAt: '2026-06-09 19:00:00', + endedAt: '2026-06-09 19:26:00', + winner: TeamAB::TeamA, + winnerPoints: 25, + loserPoints: 23, + teamATimeouts: 1, + teamBTimeouts: 1, + teamASubstitutions: [], + teamBSubstitutions: [], + ); + + recordCompletedScoresheetWriterSet( + game: $game, + setNumber: 4, + startedAt: '2026-06-09 19:34:00', + endedAt: '2026-06-09 19:58:00', + winner: TeamAB::TeamA, + winnerPoints: 25, + loserPoints: 21, + teamATimeouts: 0, + teamBTimeouts: 2, + teamASubstitutions: [], + teamBSubstitutions: [], + ); + + CarbonImmutable::setTestNow(); +} + +/** + * @param array $teamASubstitutions + * @param array $teamBSubstitutions + */ +function recordCompletedScoresheetWriterSet( + Game $game, + int $setNumber, + string $startedAt, + string $endedAt, + TeamAB $winner, + int $winnerPoints, + int $loserPoints, + int $teamATimeouts, + int $teamBTimeouts, + array $teamASubstitutions, + array $teamBSubstitutions, +): void { + CarbonImmutable::setTestNow($startedAt); + $game->recordLineup($setNumber, TeamAB::TeamA, [1 => 12, 2 => 3, 3 => 4, 4 => 5, 5 => 6, 6 => 7]); + $game->recordLineup($setNumber, TeamAB::TeamB, [1 => 9, 2 => 2, 3 => 11, 4 => 12, 5 => 13, 6 => 14]); + $game->recordSetStarted(); + + if ($teamATimeouts > 0) { + foreach (range(1, $teamATimeouts) as $timeout) { + $game->recordTimeOut(TeamAB::TeamA); + } + } + + if ($teamBTimeouts > 0) { + foreach (range(1, $teamBTimeouts) as $timeout) { + $game->recordTimeOut(TeamAB::TeamB); + } + } + + foreach ($teamASubstitutions as [$playerOut, $playerIn]) { + $game->recordSubstitution(TeamAB::TeamA, $playerOut, $playerIn); + } + + foreach ($teamBSubstitutions as [$playerOut, $playerIn]) { + $game->recordSubstitution(TeamAB::TeamB, $playerOut, $playerIn); + } + + $loser = $winner === TeamAB::TeamA ? TeamAB::TeamB : TeamAB::TeamA; + + if ($loserPoints > 0) { + foreach (range(1, $loserPoints) as $rally) { + $game->recordRallyWinner($loser); + } + } + + CarbonImmutable::setTestNow($endedAt); + + if ($winnerPoints > 0) { + foreach (range(1, $winnerPoints) as $rally) { + $game->recordRallyWinner($winner); + } + } +} diff --git a/tests/Feature/SingleMatchSchemaTest.php b/tests/Feature/SingleMatchSchemaTest.php new file mode 100644 index 0000000..74a3fb6 --- /dev/null +++ b/tests/Feature/SingleMatchSchemaTest.php @@ -0,0 +1,41 @@ +toBeTrue() + ->and(Schema::hasColumns('players', ['game_id', 'number', 'is_captain', 'is_libero', 'is_rostered']))->toBeTrue() + ->and(Schema::hasColumns('staff', ['game_id', 'is_rostered']))->toBeTrue() + ->and(Schema::hasColumns('officials', ['game_id', 'role']))->toBeTrue(); + + $player = Player::factory()->for($game->homeTeam)->create(); + $staff = Staff::factory()->for($game->homeTeam)->asCoach()->create(); + $official = Official::factory()->create(); + + $game->addPlayer($player, number: 4, isCaptain: true); + $game->addStaff($staff, StaffRole::Coach); + $game->addOfficial($official, OfficialRole::FirstReferee); + + $freshPlayer = $player->fresh(); + $freshStaff = $staff->fresh(); + $freshOfficial = $official->fresh(); + + expect($freshPlayer?->game_id)->toBe($game->getKey()) + ->and($freshPlayer?->number)->toBe(4) + ->and($freshPlayer?->is_captain)->toBeTrue() + ->and($freshPlayer?->is_rostered)->toBeTrue() + ->and($freshStaff?->game_id)->toBe($game->getKey()) + ->and($freshStaff?->role)->toBe(StaffRole::Coach) + ->and($freshStaff?->is_rostered)->toBeTrue() + ->and($freshOfficial?->game_id)->toBe($game->getKey()) + ->and($freshOfficial?->role)->toBe(OfficialRole::FirstReferee); +}); diff --git a/tests/Feature/SingletonCurrentMatchAccessGuardTest.php b/tests/Feature/SingletonCurrentMatchAccessGuardTest.php new file mode 100644 index 0000000..fcb55a1 --- /dev/null +++ b/tests/Feature/SingletonCurrentMatchAccessGuardTest.php @@ -0,0 +1,26 @@ +map(fn (SplFileInfo $file): string => $file->getPathname()) + ->reject(fn (string $path): bool => str_ends_with($path, 'MatchSetup.php')) + ->values(); + + $violations = $componentPaths + ->filter(function (string $path): bool { + $contents = File::get($path); + + return preg_match('/\bGame::(?!current\b)[A-Za-z_][A-Za-z0-9_]*\s*\(/', $contents) === 1; + }) + ->map(fn (string $path): string => str_replace(base_path().'/', '', $path)) + ->values() + ->all(); + + expect($violations)->toBe([]); +}); diff --git a/tests/Feature/SingletonRuntimeAccessGuardTest.php b/tests/Feature/SingletonRuntimeAccessGuardTest.php new file mode 100644 index 0000000..5ce0d1d --- /dev/null +++ b/tests/Feature/SingletonRuntimeAccessGuardTest.php @@ -0,0 +1,29 @@ +map(fn (SplFileInfo $file): string => $file->getPathname()) + ->reject(fn (string $path): bool => in_array($path, $allowedPaths, true)) + ->values(); + + $violations = $runtimePaths + ->filter(function (string $path): bool { + $contents = File::get($path); + + return preg_match('/\bGame::(query|create|forceCreate|first|firstOrFail|find|findOrFail|sole|all|get|where|latest|oldest|count|exists)\s*\(/', $contents) === 1; + }) + ->map(fn (string $path): string => str_replace(base_path().'/', '', $path)) + ->values() + ->all(); + + expect($violations)->toBe([]); +}); diff --git a/tests/Feature/StartSetSubmissionTest.php b/tests/Feature/StartSetSubmissionTest.php index 2c6c859..f345e93 100644 --- a/tests/Feature/StartSetSubmissionTest.php +++ b/tests/Feature/StartSetSubmissionTest.php @@ -10,8 +10,6 @@ use App\Models\Game; use App\Models\GameEvent; use App\Models\GameStateSnapshot; -use App\Models\Player; -use App\Models\Team; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Carbon; use Livewire\Livewire; @@ -27,12 +25,12 @@ $game->recordLineup(1, TeamAB::TeamA, lineupPositionsForNumbers(1)); - Livewire::test(StartSetSubmission::class, ['gameId' => $game->getKey(), 'gameState' => $game->stateAt()]) + Livewire::test(StartSetSubmission::class, ['gameState' => $game->stateAt()]) ->assertDontSee('Start Game'); $game->recordLineup(1, TeamAB::TeamB, lineupPositionsForNumbers(11)); - Livewire::test(StartSetSubmission::class, ['gameId' => $game->getKey(), 'gameState' => $game->stateAt()]) + Livewire::test(StartSetSubmission::class, ['gameState' => $game->stateAt()]) ->assertSee('Start Game'); }); @@ -53,7 +51,7 @@ $game->recordLineup(2, TeamAB::TeamA, lineupPositionsForNumbers(1)); $game->recordLineup(2, TeamAB::TeamB, lineupPositionsForNumbers(11)); - Livewire::test(StartSetSubmission::class, ['gameId' => $game->getKey(), 'gameState' => $game->stateAt()]) + Livewire::test(StartSetSubmission::class, ['gameState' => $game->stateAt()]) ->assertDontSee('Start Set 2') ->assertSee('Next set in') ->assertSee('03:00') @@ -68,7 +66,7 @@ $game->recordLineup(1, TeamAB::TeamA, lineupPositionsForNumbers(1)); $game->recordLineup(1, TeamAB::TeamB, lineupPositionsForNumbers(11)); - Livewire::test(StartSetSubmission::class, ['gameId' => $game->getKey(), 'gameState' => $game->stateAt()]) + Livewire::test(StartSetSubmission::class, ['gameState' => $game->stateAt()]) ->assertSee('Start Game') ->call('startSet') ->assertHasNoErrors() @@ -101,7 +99,7 @@ $game->recordLineup(2, TeamAB::TeamA, lineupPositionsForNumbers(1)); $game->recordLineup(2, TeamAB::TeamB, lineupPositionsForNumbers(11)); - Livewire::test(StartSetSubmission::class, ['gameId' => $game->getKey(), 'gameState' => $game->stateAt()]) + Livewire::test(StartSetSubmission::class, ['gameState' => $game->stateAt()]) ->call('startSet') ->assertHasErrors(['startSet']); @@ -125,7 +123,7 @@ $game->recordLineup(2, TeamAB::TeamA, lineupPositionsForNumbers(1)); $game->recordLineup(2, TeamAB::TeamB, lineupPositionsForNumbers(11)); - Livewire::test(StartSetSubmission::class, ['gameId' => $game->getKey(), 'gameState' => $game->stateAt()]) + Livewire::test(StartSetSubmission::class, ['gameState' => $game->stateAt()]) ->assertDontSee('Start Game') ->assertSeeHtml('x-init="$wire.startSet()"'); }); @@ -149,7 +147,7 @@ $this->travel(3)->minutes(); - Livewire::test(StartSetSubmission::class, ['gameId' => $game->getKey(), 'gameState' => $game->stateAt()]) + Livewire::test(StartSetSubmission::class, ['gameState' => $game->stateAt()]) ->assertDontSee('Start Game') ->assertDontSee('Next set in') ->assertSeeHtml('x-init="$wire.startSet()"'); @@ -189,7 +187,7 @@ 'created_at' => Carbon::now(), ]); - Livewire::test(StartSetSubmission::class, ['gameId' => $game->getKey()]) + Livewire::test(StartSetSubmission::class) ->assertSee('Start Game'); }); @@ -209,7 +207,7 @@ } } - Livewire::test(StartSetSubmission::class, ['gameId' => $game->getKey(), 'gameState' => $game->stateAt()]) + Livewire::test(StartSetSubmission::class, ['gameState' => $game->stateAt()]) ->assertDontSee('Start Set 5'); }); @@ -235,7 +233,7 @@ config()->set('game.between_sets_duration', 180); $this->freezeSecond(function () use ($game): void { - Livewire::test(StartSetSubmission::class, ['gameId' => $game->getKey(), 'gameState' => $game->stateAt()]) + Livewire::test(StartSetSubmission::class, ['gameState' => $game->stateAt()]) ->assertDontSee('Start Game') ->assertSee('Next set in') ->assertSeeHtml('data-set-break-countdown'); @@ -247,32 +245,12 @@ */ function lineupPositionsForNumbers(int $start): array { - return [ - 1 => $start, - 2 => $start + 1, - 3 => $start + 2, - 4 => $start + 3, - 5 => $start + 4, - 6 => $start + 5, - ]; + return standardLineup($start); } function gameReadyToStartSet(): Game { - $homeTeam = Team::factory()->create(); - $awayTeam = Team::factory()->create(); - $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); - - $homePlayers = Player::factory()->for($homeTeam)->count(6)->create(); - foreach ($homePlayers as $index => $player) { - $game->addPlayer($player, number: $index + 1); - } - - $awayPlayers = Player::factory()->for($awayTeam)->count(6)->create(); - foreach ($awayPlayers as $index => $player) { - $game->addPlayer($player, number: $index + 11); - } - + $game = makeReadyCurrentMatch(); $game->recordToss(TeamSide::Home, TeamAB::TeamA); return $game; diff --git a/tests/Feature/Support/FakeScoresheetPdfSupport.php b/tests/Feature/Support/FakeScoresheetPdfSupport.php new file mode 100644 index 0000000..04bb8dc --- /dev/null +++ b/tests/Feature/Support/FakeScoresheetPdfSupport.php @@ -0,0 +1,162 @@ + */ + public array $writes = []; + + /** @var array */ + public array $spacedPrints = []; + + /** @var array */ + public array $circles = []; + + /** @var array */ + public array $lines = []; + + /** @var array */ + public array $sourceFiles = []; + + /** @var array */ + public array $outputPaths = []; + + private float $cursorX = 0; + + private float $cursorY = 0; + + public function __construct() {} + + #[Override] + public function setSourceFile($file) + { + $this->sourceFiles[] = (string) $file; + + return 1; + } + + #[Override] + public function importPage($pageNumber, $box = '/CropBox', $groupXObject = true, $importExternalLinks = false) + { + return 1; + } + + #[Override] + public function AddPage($orientation = '', $size = '', $rotation = 0): void {} + + #[Override] + public function useTemplate($tpl, $x = null, $y = null, $width = 0, $height = 0, $adjustPageSize = false): array + { + return []; + } + + #[Override] + public function SetMargins($left, $top, $right = -1): void {} + + #[Override] + public function SetAutoPageBreak($auto, $margin = 0): void {} + + #[Override] + public function SetDisplayMode($zoom, $layout = 'continuous'): void {} + + #[Override] + public function SetFont($family, $style = '', $size = 0): void {} + + #[Override] + public function SetXY($x, $y): void + { + $this->cursorX = (float) $x; + $this->cursorY = (float) $y; + } + + #[Override] + public function SetX($x): void + { + $this->cursorX = (float) $x; + } + + #[Override] + public function Write(mixed $h, mixed $txt, mixed $link = ''): void + { + $this->writes[] = [ + 'x' => $this->cursorX, + 'y' => $this->cursorY, + 'text' => (string) $txt, + ]; + } + + #[Override] + public function spacedPrint(int $x, int $y, string $text): void + { + $this->spacedPrints[] = [ + 'x' => $x, + 'y' => $y, + 'text' => $text, + ]; + } + + #[Override] + public function circle(float $x, float $y, float $r, ?float $lineWidth = null): void + { + $this->circles[] = [ + 'x' => $x, + 'y' => $y, + 'r' => $r, + 'line_width' => $lineWidth, + ]; + } + + #[Override] + public function Line($x1, $y1, $x2, $y2): void + { + $this->lines[] = [ + 'x1' => (float) $x1, + 'y1' => (float) $y1, + 'x2' => (float) $x2, + 'y2' => (float) $y2, + ]; + } + + #[Override] + public function SetFontSize($size): void {} + + #[Override] + public function SetLineWidth($width): void {} + + #[Override] + public function Output($dest = '', $name = '', $isUTF8 = false): string + { + if ($dest === 'F' && is_string($name) && $name !== '') { + file_put_contents($name, 'fake pdf output'); + $this->outputPaths[] = $name; + } + + return 'fake pdf output'; + } +} + +class FakeScoresheetPdfFactory extends ScoresheetPdfFactory +{ + public function __construct( + private readonly RecordingScoresheetPdf $pdf + ) {} + + #[Override] + public function make(): ScoresheetPdf + { + return $this->pdf; + } +} + +function fakeScoresheetPdfFactory(): RecordingScoresheetPdf +{ + $pdf = new RecordingScoresheetPdf; + + app()->instance(ScoresheetPdfFactory::class, new FakeScoresheetPdfFactory($pdf)); + + return $pdf; +} diff --git a/tests/Feature/Support/SingleMatchTestSupport.php b/tests/Feature/Support/SingleMatchTestSupport.php new file mode 100644 index 0000000..05c81b0 --- /dev/null +++ b/tests/Feature/Support/SingleMatchTestSupport.php @@ -0,0 +1,179 @@ + 1, + 'country_code' => 'ITA', + 'city' => 'Bologna', + 'hall' => 'PalaDozza', + 'date_time' => now()->addDay()->setTime(20, 30), + 'division' => 'Men', + 'pool' => 'A', + 'category' => 'Senior', + 'status' => MatchPhase::Setup, + ], + homeTeamAttributes: [ + 'name' => 'Italy', + 'country_code' => 'ITA', + ], + awayTeamAttributes: [ + 'name' => 'Brazil', + 'country_code' => 'BRA', + ], + ); + + $game->resetForSetup(); + + /** @var Game */ + return $game->fresh(['homeTeam', 'awayTeam', 'officials']); +} + +function createCurrentMatchWithoutDetails(): Game +{ + $game = Game::ensureSingleton( + gameAttributes: [ + 'number' => 1, + 'country_code' => '', + 'city' => '', + 'hall' => '', + 'date_time' => now(), + 'division' => '', + 'pool' => '', + 'category' => '', + 'status' => MatchPhase::Setup, + ], + homeTeamAttributes: [ + 'name' => '', + 'country_code' => '', + ], + awayTeamAttributes: [ + 'name' => '', + 'country_code' => '', + ], + ); + + $game->resetForSetup(); + + /** @var Game */ + return $game->fresh(['homeTeam', 'awayTeam', 'officials']); +} + +/** + * @return array{ + * home_players: Collection, + * away_players: Collection, + * home_staff: array, + * away_staff: array + * } + */ +function seedRosterCandidates(Game $game, int $playersPerTeam = 7): array +{ + $homePlayers = Player::factory()->for($game->homeTeam)->count($playersPerTeam)->create(); + $awayPlayers = Player::factory()->for($game->awayTeam)->count($playersPerTeam)->create(); + + $homeStaff = [ + Staff::factory()->for($game->homeTeam)->asCoach()->create(), + Staff::factory()->for($game->homeTeam)->asDoctor()->create(), + ]; + + $awayStaff = [ + Staff::factory()->for($game->awayTeam)->asCoach()->create(), + Staff::factory()->for($game->awayTeam)->asDoctor()->create(), + ]; + + return [ + 'home_players' => $homePlayers, + 'away_players' => $awayPlayers, + 'home_staff' => $homeStaff, + 'away_staff' => $awayStaff, + ]; +} + +function submitInitialRosters(Game $game, int $playersPerTeam = 6): Game +{ + $homePlayers = Player::factory()->for($game->homeTeam)->count($playersPerTeam)->create(); + $awayPlayers = Player::factory()->for($game->awayTeam)->count($playersPerTeam)->create(); + + foreach ($homePlayers as $index => $player) { + $game->addPlayer($player, number: $index + 1, isCaptain: $index === 0); + } + + foreach ($awayPlayers as $index => $player) { + $game->addPlayer($player, number: $index + 11, isCaptain: $index === 0); + } + + $game->markRostersSubmitted(); + + return $game->fresh(); +} + +function assignRequiredOfficials(Game $game): Game +{ + foreach (OfficialRole::cases() as $index => $role) { + $game->addOfficial( + Official::factory() + ->named('Official', 'Crew'.($index + 1)) + ->withCountryCode($index % 2 === 0 ? 'ITA' : 'BRA') + ->create(), + $role, + ); + } + + return $game->fresh(); +} + +function makeReadyCurrentMatch(): Game +{ + $game = createCurrentMatch(); + + submitInitialRosters($game); + assignRequiredOfficials($game); + + return $game->fresh(); +} + +/** + * @return array + */ +function standardLineup(int $startingNumber = 1): array +{ + return [ + 1 => $startingNumber, + 2 => $startingNumber + 1, + 3 => $startingNumber + 2, + 4 => $startingNumber + 3, + 5 => $startingNumber + 4, + 6 => $startingNumber + 5, + ]; +} + +function recordStraightSetsWin(Game $game, TeamAB $winner = TeamAB::TeamA, int $sets = 3): Game +{ + $game->recordToss(TeamSide::Home, TeamAB::TeamA); + + foreach (range(1, $sets) as $setNumber) { + $game->recordLineup($setNumber, TeamAB::TeamA, standardLineup()); + $game->recordLineup($setNumber, TeamAB::TeamB, standardLineup(11)); + $game->recordSetStarted(); + + foreach (range(1, 25) as $rally) { + $game->recordRallyWinner($winner); + } + } + + return $game->fresh(); +} diff --git a/tests/Feature/TeamRosterTest.php b/tests/Feature/TeamRosterTest.php index 46188fb..e66128c 100644 --- a/tests/Feature/TeamRosterTest.php +++ b/tests/Feature/TeamRosterTest.php @@ -28,7 +28,6 @@ $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); Livewire::test(TeamRoster::class, [ - 'gameId' => $game->getKey(), 'team' => TeamAB::TeamA, 'leftSide' => true, ]) @@ -41,7 +40,6 @@ $game = gameWithNumberedRostersForTeamRoster(); Livewire::test(TeamRoster::class, [ - 'gameId' => $game->getKey(), 'team' => TeamAB::TeamA, 'leftSide' => true, 'gameState' => submittedLineupState(), @@ -55,7 +53,6 @@ $game = gameWithNumberedRostersForTeamRoster(); Livewire::test(TeamRoster::class, [ - 'gameId' => $game->getKey(), 'team' => TeamAB::TeamB, 'leftSide' => false, 'gameState' => submittedLineupState(), @@ -70,7 +67,6 @@ $game->recordToss(TeamSide::Away, TeamAB::TeamA); Livewire::test(TeamRoster::class, [ - 'gameId' => $game->getKey(), 'team' => TeamAB::TeamA, 'leftSide' => true, 'gameState' => submittedLineupState(), @@ -85,7 +81,6 @@ $game->recordToss(TeamSide::Away, TeamAB::TeamA); Livewire::test(TeamRoster::class, [ - 'gameId' => $game->getKey(), 'team' => TeamAB::TeamA, 'leftSide' => true, 'gameState' => submittedLineupState(), @@ -100,7 +95,6 @@ $game = gameWithNumberedRostersForTeamRoster(); Livewire::test(TeamRoster::class, [ - 'gameId' => $game->getKey(), 'team' => TeamAB::TeamA, 'leftSide' => true, 'gameState' => GameState::fromAttributes([ @@ -117,7 +111,6 @@ $game = gameWithNumberedRostersForTeamRoster(); Livewire::test(TeamRoster::class, [ - 'gameId' => $game->getKey(), 'team' => TeamAB::TeamA, 'leftSide' => true, 'gameState' => submittedLineupState(), @@ -138,7 +131,6 @@ } Livewire::test(TeamRoster::class, [ - 'gameId' => $game->getKey(), 'team' => TeamAB::TeamA, 'leftSide' => true, ]) @@ -153,7 +145,6 @@ $game = gameWithNumberedRostersForTeamRoster(); Livewire::test(TeamRoster::class, [ - 'gameId' => $game->getKey(), 'team' => TeamAB::TeamA, 'leftSide' => true, ]) @@ -172,7 +163,6 @@ $game = gameWithNumberedRostersForTeamRoster(); Livewire::test(TeamRoster::class, [ - 'gameId' => $game->getKey(), 'team' => TeamAB::TeamB, 'leftSide' => false, ]) @@ -191,7 +181,6 @@ $game = gameWithNumberedRostersForTeamRoster(); Livewire::test(TeamRoster::class, [ - 'gameId' => $game->getKey(), 'team' => TeamAB::TeamB, 'leftSide' => true, ]) @@ -210,7 +199,6 @@ $game = gameWithNumberedRostersForTeamRoster(); Livewire::test(TeamRoster::class, [ - 'gameId' => $game->getKey(), 'team' => TeamAB::TeamA, 'leftSide' => false, ]) @@ -237,7 +225,6 @@ $game->addStaff($homeDoctor, StaffRole::Doctor); Livewire::test(TeamRoster::class, [ - 'gameId' => $game->getKey(), 'team' => TeamAB::TeamA, 'leftSide' => true, ]) @@ -270,7 +257,6 @@ $game->addStaff($homeDoctor, StaffRole::Doctor); Livewire::test(TeamRoster::class, [ - 'gameId' => $game->getKey(), 'team' => TeamAB::TeamA, 'leftSide' => true, ]) @@ -288,7 +274,6 @@ $game = gameWithActiveSetForTeamRoster(); Livewire::test(TeamRoster::class, [ - 'gameId' => $game->getKey(), 'team' => TeamAB::TeamA, 'leftSide' => true, 'gameState' => $game->stateAt(), @@ -302,7 +287,6 @@ $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); Livewire::test(TeamRoster::class, [ - 'gameId' => $game->getKey(), 'team' => TeamAB::TeamA, 'leftSide' => true, ])->assertSeeHtml('data-team-roster-timeouts') @@ -315,7 +299,6 @@ $game->recordTimeOut(TeamAB::TeamA); Livewire::test(TeamRoster::class, [ - 'gameId' => $game->getKey(), 'team' => TeamAB::TeamA, 'leftSide' => true, 'gameState' => $game->stateAt(), @@ -327,7 +310,6 @@ $game = gameWithActiveSetForTeamRoster(); Livewire::test(TeamRoster::class, [ - 'gameId' => $game->getKey(), 'team' => TeamAB::TeamA, 'leftSide' => true, 'gameState' => $game->stateAt(), @@ -342,7 +324,6 @@ $game->recordTimeOut(TeamAB::TeamA); Livewire::test(TeamRoster::class, [ - 'gameId' => $game->getKey(), 'team' => TeamAB::TeamA, 'leftSide' => true, 'gameState' => $game->stateAt(), @@ -379,7 +360,6 @@ expect($game->fresh()->stateAt()->improperRequestsTeamA)->toBe(1); Livewire::test(TeamRoster::class, [ - 'gameId' => $game->getKey(), 'team' => TeamAB::TeamA, 'leftSide' => true, 'gameState' => $game->stateAt(), @@ -405,7 +385,6 @@ $game->recordImproperRequest(TeamAB::TeamA, ImproperRequestType::Substitution); Livewire::test(TeamRoster::class, [ - 'gameId' => $game->getKey(), 'team' => TeamAB::TeamA, 'leftSide' => true, 'gameState' => $game->stateAt(), @@ -438,7 +417,6 @@ $game->recordSetStarted(); Livewire::test(TeamRoster::class, [ - 'gameId' => $game->getKey(), 'team' => TeamAB::TeamA, 'leftSide' => true, 'gameState' => $game->stateAt(), @@ -449,7 +427,6 @@ $game = gameWithActiveSetForTeamRoster(); Livewire::test(TeamRoster::class, [ - 'gameId' => $game->getKey(), 'team' => TeamAB::TeamA, 'leftSide' => true, 'gameState' => $game->stateAt(), @@ -469,7 +446,6 @@ $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); Livewire::test(TeamRoster::class, [ - 'gameId' => $game->getKey(), 'team' => TeamAB::TeamA, 'leftSide' => true, ]) @@ -483,7 +459,6 @@ $game = gameWithActiveSetForTeamRoster(); Livewire::test(TeamRoster::class, [ - 'gameId' => $game->getKey(), 'team' => TeamAB::TeamA, 'leftSide' => true, 'gameState' => $game->stateAt(), @@ -497,7 +472,6 @@ $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); Livewire::test(TeamRoster::class, [ - 'gameId' => $game->getKey(), 'team' => TeamAB::TeamA, 'leftSide' => true, ])->assertSeeHtml('data-team-roster-substitutions') @@ -509,7 +483,6 @@ $state = $game->stateAt(); Livewire::test(TeamRoster::class, [ - 'gameId' => $game->getKey(), 'team' => TeamAB::TeamA, 'leftSide' => true, 'gameState' => GameState::fromAttributes(array_merge($state->toAttributes(), ['substitutions_team_a' => 6])), @@ -523,7 +496,6 @@ recordSixSubstitutionsForTeamRoster($game); Livewire::test(TeamRoster::class, [ - 'gameId' => $game->getKey(), 'team' => TeamAB::TeamA, 'leftSide' => true, 'gameState' => $game->stateAt(), @@ -548,7 +520,6 @@ recordSixSubstitutionsForTeamRoster($game); Livewire::test(TeamRoster::class, [ - 'gameId' => $game->getKey(), 'team' => TeamAB::TeamA, 'leftSide' => true, 'gameState' => $game->stateAt(), @@ -574,7 +545,6 @@ recordSixSubstitutionsForTeamRoster($game); Livewire::test(TeamRoster::class, [ - 'gameId' => $game->getKey(), 'team' => TeamAB::TeamA, 'leftSide' => true, 'gameState' => $game->stateAt(), @@ -597,7 +567,6 @@ $game = gameWithActiveSetForTeamRoster(); Livewire::test(TeamRoster::class, [ - 'gameId' => $game->getKey(), 'team' => TeamAB::TeamA, 'leftSide' => true, 'gameState' => $game->stateAt(), @@ -610,7 +579,6 @@ $game = gameWithActiveSetForTeamRoster(); Livewire::test(TeamRoster::class, [ - 'gameId' => $game->getKey(), 'team' => TeamAB::TeamA, 'leftSide' => true, 'gameState' => $game->stateAt(), @@ -630,7 +598,6 @@ $game = gameWithActiveSetForTeamRoster(); Livewire::test(TeamRoster::class, [ - 'gameId' => $game->getKey(), 'team' => TeamAB::TeamA, 'leftSide' => true, 'gameState' => $game->stateAt(), @@ -650,7 +617,6 @@ } Livewire::test(TeamRoster::class, [ - 'gameId' => $game->getKey(), 'team' => TeamAB::TeamA, 'leftSide' => true, 'gameState' => $game->stateAt(), @@ -667,7 +633,6 @@ $game->recordSubstitution(TeamAB::TeamA, 1, 7); Livewire::test(TeamRoster::class, [ - 'gameId' => $game->getKey(), 'team' => TeamAB::TeamA, 'leftSide' => true, 'gameState' => $game->stateAt(), @@ -679,7 +644,6 @@ ->assertDispatched('game-event-recorded'); Livewire::test(TeamRoster::class, [ - 'gameId' => $game->fresh()->getKey(), 'team' => TeamAB::TeamA, 'leftSide' => true, 'gameState' => $game->fresh()->stateAt(), @@ -697,7 +661,6 @@ $game->recordSubstitution(TeamAB::TeamA, 7, 1); Livewire::test(TeamRoster::class, [ - 'gameId' => $game->getKey(), 'team' => TeamAB::TeamA, 'leftSide' => true, 'gameState' => $game->stateAt(), @@ -714,7 +677,6 @@ $game->recordSubstitution(TeamAB::TeamA, 1, 7); Livewire::test(TeamRoster::class, [ - 'gameId' => $game->getKey(), 'team' => TeamAB::TeamA, 'leftSide' => true, 'gameState' => $game->stateAt(), @@ -740,7 +702,6 @@ $game->recordSetStarted(); Livewire::test(TeamRoster::class, [ - 'gameId' => $game->getKey(), 'team' => TeamAB::TeamA, 'leftSide' => true, 'gameState' => $game->stateAt(), diff --git a/tests/Feature/TossResultSubmissionTest.php b/tests/Feature/TossResultSubmissionTest.php index ab45ff2..9dc0f98 100644 --- a/tests/Feature/TossResultSubmissionTest.php +++ b/tests/Feature/TossResultSubmissionTest.php @@ -26,7 +26,7 @@ $game = Game::factory()->create(); seedTossResultRosterableTeams($game); - Livewire::test(TossResultSubmission::class, ['gameId' => $game->getKey()]) + Livewire::test(TossResultSubmission::class) ->set('teamA', TeamSide::Away->value) ->set('serving', TeamSide::Home->value) ->call('submit') @@ -44,7 +44,7 @@ test('submitting fifth set toss keeps team assignment and records left and serving teams', function (): void { $game = tiedGameReadyForFifthSetToss(); - Livewire::test(TossResultSubmission::class, ['gameId' => $game->getKey()]) + Livewire::test(TossResultSubmission::class) ->assertSee('Submit Fifth Set Toss') ->set('left', TeamSide::Away->value) ->set('serving', TeamSide::Away->value) @@ -74,7 +74,7 @@ seedTossResultRosterableTeams($game); $game->recordToss(TeamSide::Home, TeamAB::TeamA); - Livewire::test(TossResultSubmission::class, ['gameId' => $game->getKey()]) + Livewire::test(TossResultSubmission::class) ->set('teamA', TeamSide::Away->value) ->set('serving', TeamSide::Home->value) ->call('submit') @@ -83,26 +83,10 @@ expect($game->fresh()->events)->toHaveCount(1); }); -test('submitting toss result records the event against the provided game id', function (): void { - $targetGame = Game::factory()->create(); - $otherGame = Game::factory()->create(); - seedTossResultRosterableTeams($targetGame); - - Livewire::test(TossResultSubmission::class, ['gameId' => $targetGame->getKey()]) - ->set('teamA', TeamSide::Away->value) - ->set('serving', TeamSide::Away->value) - ->call('submit') - ->assertHasNoErrors(); - - expect($targetGame->fresh()->events)->toHaveCount(1) - ->and($targetGame->fresh()->events->first()?->payload->serving)->toBe(TeamAB::TeamA) - ->and($otherGame->fresh()->events)->toHaveCount(0); -}); - test('toss modal shows home and away team country codes', function (): void { $game = tiedGameReadyForFifthSetToss(); - Livewire::test(TossResultSubmission::class, ['gameId' => $game->getKey()]) + Livewire::test(TossResultSubmission::class) ->assertSee($game->homeTeam->country_code) ->assertSee($game->awayTeam->country_code) ->assertDontSee('Home Team') @@ -112,7 +96,7 @@ test('initial toss submission fails until rosters have been submitted', function (): void { $game = Game::factory()->create(); - Livewire::test(TossResultSubmission::class, ['gameId' => $game->getKey()]) + Livewire::test(TossResultSubmission::class) ->set('teamA', TeamSide::Away->value) ->set('serving', TeamSide::Home->value) ->call('submit') @@ -123,7 +107,7 @@ $game = Game::factory()->create(); $game->recordToss(TeamSide::Home, TeamAB::TeamA); - Livewire::test(TossResultSubmission::class, ['gameId' => $game->getKey()]) + Livewire::test(TossResultSubmission::class) ->assertDontSee('Submit Toss Result') ->assertDontSee('Save Toss Result'); }); @@ -159,7 +143,7 @@ 'created_at' => Carbon::now(), ]); - Livewire::test(TossResultSubmission::class, ['gameId' => $game->getKey()]) + Livewire::test(TossResultSubmission::class) ->assertDontSee('Submit Toss Result') ->assertDontSee('Save Toss Result'); }); @@ -169,7 +153,7 @@ function seedTossResultRosterableTeams(Game $game): void $homePlayers = Player::factory()->for($game->homeTeam)->count(6)->create(); $awayPlayers = Player::factory()->for($game->awayTeam)->count(6)->create(); - $component = Livewire::test(RosterSubmission::class, ['gameId' => $game->getKey()]); + $component = Livewire::test(RosterSubmission::class); foreach ($homePlayers as $index => $player) { $component->set("homeRosterInputs.{$player->getKey()}", (string) ($index + 1)); diff --git a/tests/Pest.php b/tests/Pest.php index 10301ce..3985bb7 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -3,6 +3,9 @@ use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; +require_once __DIR__.'/Feature/Support/SingleMatchTestSupport.php'; +require_once __DIR__.'/Feature/Support/FakeScoresheetPdfSupport.php'; + /* |-------------------------------------------------------------------------- | Test Case