diff --git a/README.md b/README.md index 304ebda..712c214 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Instead of storing only the latest match score, it stores each match action as a The core domain includes: -- Championships and games +- Competitions and games - Teams, players, staff, and officials assigned to a game - An event stream (`game_events`) for everything that happens during a match - Materialized state snapshots (`game_state_snapshots`) for fast reads diff --git a/app/Livewire/Competition.php b/app/Livewire/Competition.php new file mode 100644 index 0000000..b23db3b --- /dev/null +++ b/app/Livewire/Competition.php @@ -0,0 +1,54 @@ +name = $competition !== null + ? $competition->name + : config('competition.name'); + } + + public function save(): void + { + $this->name = trim($this->name); + + $validated = $this->validate([ + 'name' => ['required', 'string', 'max:255'], + ]); + + $competition = CompetitionModel::ensureSingleton(); + $competition->forceFill([ + 'name' => trim((string) $validated['name']), + ])->save(); + + $this->name = $competition->name; + $this->saved = true; + } + + public function updatedName(): void + { + $this->saved = false; + } + + public function render(): View + { + return view('livewire.competition'); + } +} diff --git a/app/Livewire/MatchSetup.php b/app/Livewire/MatchSetup.php index 537884e..32456e9 100644 --- a/app/Livewire/MatchSetup.php +++ b/app/Livewire/MatchSetup.php @@ -8,6 +8,7 @@ use App\Enums\StaffRole; use App\Enums\TeamAB; use App\Exceptions\InvalidGameEventTransition; +use App\Models\Competition; use App\Models\Game; use App\Models\Official; use App\Models\Player; @@ -16,7 +17,6 @@ use App\Services\CurrentMatchResolver; use Illuminate\Contracts\View\View; use Illuminate\Database\Eloquent\Collection; -use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\DB; use Livewire\Component; @@ -26,6 +26,10 @@ class MatchSetup extends Component public string $step = 'missing-match'; + public string $competitionName = ''; + + public bool $editingCompetition = false; + public string $matchNumber = ''; public string $matchCountryCode = ''; @@ -71,11 +75,46 @@ class MatchSetup extends Component public function mount(CurrentMatchResolver $currentMatchResolver): void { + $this->synchronizeCompetitionState(); $this->synchronizeState($currentMatchResolver->current()); } + public function saveCompetition(): void + { + $this->competitionName = trim($this->competitionName); + + $validated = $this->validate([ + 'competitionName' => ['required', 'string', 'max:255'], + ]); + + $competition = Competition::ensureSingleton(); + $competition->forceFill([ + 'name' => trim((string) $validated['competitionName']), + ])->save(); + + $this->editingCompetition = false; + $this->synchronizeCompetitionState(); + $this->synchronizeState($this->game?->fresh(['homeTeam', 'awayTeam', 'officials'])); + } + + public function editCompetition(): void + { + if (! Competition::setupComplete()) { + return; + } + + $this->editingCompetition = true; + $this->step = 'competition'; + } + public function createMatch(): void { + if (! Competition::setupComplete()) { + $this->step = 'competition'; + + return; + } + if ($this->game !== null) { $this->synchronizeState($this->game->fresh(['homeTeam', 'awayTeam', 'officials'])); @@ -87,7 +126,13 @@ public function createMatch(): void public function openStep(string $step): void { - if (! in_array($step, ['match-details', 'rosters', 'officials', 'ready'], true)) { + if (! in_array($step, ['competition', 'match-details', 'rosters', 'officials', 'ready'], true)) { + return; + } + + if ($step === 'competition') { + $this->step = 'competition'; + return; } @@ -102,7 +147,15 @@ public function openStep(string $step): void } $requiredStep = $this->resolver()->nextStep($this->game); - $availableSteps = ['match-details']; + $availableSteps = ['competition']; + + if (! Competition::setupComplete()) { + $this->step = 'competition'; + + return; + } + + $availableSteps[] = 'match-details'; if ($this->game->hasCompleteMatchDetails()) { $availableSteps[] = 'rosters'; @@ -290,11 +343,13 @@ public function saveOfficials(): void public function render(): View { return view('livewire.match-setup', [ - 'competitionName' => $this->game?->competitionName() ?? Config::string('competition.name'), + 'currentCompetitionName' => $this->game?->competitionName() ?? $this->competitionName, + 'competitionConfigured' => Competition::setupComplete(), 'currentRequiredStep' => $this->resolver()->nextStep($this->game), 'isSetupComplete' => $this->game !== null && $this->resolver()->isSetupComplete($this->game), 'isSetupLocked' => $this->setupLocked(), 'setupSteps' => [ + 'competition' => 'Competition', 'match-details' => 'Match details', 'rosters' => 'Team rosters', 'officials' => 'Officials', @@ -307,6 +362,16 @@ private function synchronizeState(?Game $game): void { $this->game = $game; + if (! Competition::setupComplete()) { + $this->step = 'competition'; + + if ($game === null) { + $this->resetFormState(); + } + + return; + } + if ($game === null) { $this->step = 'missing-match'; $this->resetFormState(); @@ -363,6 +428,15 @@ private function resetFormState(): void $this->officialRows = $this->emptyOfficialRows(); } + private function synchronizeCompetitionState(): void + { + $competition = Competition::current(); + + $this->competitionName = $competition !== null + ? $competition->name + : ''; + } + /** * @param array $players * @param array $staffRows diff --git a/app/Models/Competition.php b/app/Models/Competition.php new file mode 100644 index 0000000..ea7e0c8 --- /dev/null +++ b/app/Models/Competition.php @@ -0,0 +1,70 @@ + */ + use HasFactory; + + /** + * @return array + */ + #[\Override] + protected function casts(): array + { + return [ + 'created_at' => 'immutable_datetime', + 'updated_at' => 'immutable_datetime', + ]; + } + + public static function current(): ?self + { + try { + /** @var self */ + return static::query()->sole(); + } catch (ModelNotFoundException) { + return null; + } catch (MultipleRecordsFoundException $exception) { + throw new LogicException('The single-competition application found multiple competition records.', previous: $exception); + } + } + + public static function ensureSingleton(): self + { + $competition = static::current(); + + if ($competition !== null) { + return $competition; + } + + return static::query()->create([ + 'name' => config('competition.name'), + ]); + } + + public static function setupComplete(): bool + { + $competition = static::current(); + + return $competition !== null && trim($competition->name) !== ''; + } +} diff --git a/app/Models/Game.php b/app/Models/Game.php index 112d503..9991b17 100644 --- a/app/Models/Game.php +++ b/app/Models/Game.php @@ -177,7 +177,11 @@ public static function ensureSingleton( */ public function competitionName(): string { - return Config::string('competition.name'); + $competition = Competition::current(); + + return $competition !== null + ? $competition->name + : Config::string('competition.name'); } public function resetForSetup(): void diff --git a/app/Services/CurrentMatchResolver.php b/app/Services/CurrentMatchResolver.php index 947f7a9..22f4141 100644 --- a/app/Services/CurrentMatchResolver.php +++ b/app/Services/CurrentMatchResolver.php @@ -5,6 +5,7 @@ namespace App\Services; use App\Enums\MatchPhase; +use App\Models\Competition; use App\Models\Game; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Database\MultipleRecordsFoundException; @@ -40,6 +41,10 @@ public function currentOrFail(): Game public function landingRouteName(): string { + if (! Competition::setupComplete()) { + return 'match.setup'; + } + $currentGame = $this->current(); if ($currentGame === null) { @@ -53,6 +58,10 @@ public function landingRouteName(): string public function nextStep(?Game $game = null): string { + if (! Competition::setupComplete()) { + return 'competition'; + } + $currentGame = $game ?? $this->current(); if ($currentGame === null) { @@ -82,6 +91,8 @@ public function isSetupComplete(?Game $game = null): bool { $currentGame = $game ?? $this->current(); - return $currentGame !== null && $currentGame->status !== MatchPhase::Setup; + return Competition::setupComplete() + && $currentGame !== null + && $currentGame->status !== MatchPhase::Setup; } } diff --git a/database/factories/CompetitionFactory.php b/database/factories/CompetitionFactory.php new file mode 100644 index 0000000..6a4ea74 --- /dev/null +++ b/database/factories/CompetitionFactory.php @@ -0,0 +1,31 @@ + + */ +class CompetitionFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'name' => fake()->unique()->company(), + ]; + } + + public function named(string $name): static + { + return $this->state(fn (array $attributes): array => [ + 'name' => $name, + ]); + } +} diff --git a/database/migrations/2026_02_01_171038_create_championships_table.php b/database/migrations/2026_02_01_171038_create_competitions_table.php similarity index 84% rename from database/migrations/2026_02_01_171038_create_championships_table.php rename to database/migrations/2026_02_01_171038_create_competitions_table.php index 2197963..c36495d 100644 --- a/database/migrations/2026_02_01_171038_create_championships_table.php +++ b/database/migrations/2026_02_01_171038_create_competitions_table.php @@ -11,7 +11,7 @@ */ public function up(): void { - Schema::create('championships', function (Blueprint $table) { + Schema::create('competitions', function (Blueprint $table) { $table->id(); $table->string('name'); $table->timestamps(); diff --git a/database/migrations/2026_02_01_171119_create_games_table.php b/database/migrations/2026_02_01_171119_create_games_table.php index e3f6efa..20739d1 100644 --- a/database/migrations/2026_02_01_171119_create_games_table.php +++ b/database/migrations/2026_02_01_171119_create_games_table.php @@ -13,7 +13,7 @@ public function up(): void { Schema::create('games', function (Blueprint $table) { $table->id(); - $table->foreignId('championship_id')->constrained()->cascadeOnDelete(); + $table->foreignId('competition_id')->constrained()->cascadeOnDelete(); $table->foreignId('home_team_id')->constrained('teams')->cascadeOnDelete(); $table->foreignId('away_team_id')->constrained('teams')->cascadeOnDelete(); $table->unsignedTinyInteger('number'); 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 index 32ea1f8..ceaaf42 100644 --- 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 @@ -101,26 +101,26 @@ public function up(): void }); Schema::table('games', function (Blueprint $table): void { - $table->dropForeign(['championship_id']); - $table->dropColumn('championship_id'); + $table->dropForeign(['competition_id']); + $table->dropColumn('competition_id'); }); Schema::dropIfExists('game_player'); Schema::dropIfExists('game_staff'); Schema::dropIfExists('game_official'); - Schema::dropIfExists('championships'); + Schema::dropIfExists('competitions'); } public function down(): void { - Schema::create('championships', function (Blueprint $table): void { + Schema::create('competitions', 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(); + $table->foreignId('competition_id')->nullable()->after('id')->constrained()->cascadeOnDelete(); }); Schema::create('game_official', function (Blueprint $table): void { diff --git a/database/migrations/2026_06_21_083853_create_competitions_table.php b/database/migrations/2026_06_21_083853_create_competitions_table.php new file mode 100644 index 0000000..70c4690 --- /dev/null +++ b/database/migrations/2026_06_21_083853_create_competitions_table.php @@ -0,0 +1,28 @@ +id(); + $table->string('name'); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('competitions'); + } +}; diff --git a/resources/views/livewire/competition.blade.php b/resources/views/livewire/competition.blade.php new file mode 100644 index 0000000..b7e8a8b --- /dev/null +++ b/resources/views/livewire/competition.blade.php @@ -0,0 +1,42 @@ +
+
+ +
+
+ Competition +
+ Set the competition details + + Keep the current competition name in the app instead of relying on config defaults. + +
+
+ + @if ($saved) + + Competition details saved. + + @endif +
+
+ + +
+ + Competition name + + + This name is used anywhere the app renders the current competition label. + + + + +
+ + Save competition + +
+
+
+
+
diff --git a/resources/views/livewire/match-setup.blade.php b/resources/views/livewire/match-setup.blade.php index 47ad4b0..d94ff94 100644 --- a/resources/views/livewire/match-setup.blade.php +++ b/resources/views/livewire/match-setup.blade.php @@ -1,40 +1,100 @@ -
+
- -
-
- 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 }}
-
+
+
+ Prepare the current match before play starts + + Use this page to set up the match before it can begin. + + + Start by saving the competition, then fill in the match details, enter both team rosters, and assign all required officials. Until those sections are complete, you will stay on setup and the scoring screen will remain unavailable. + + + Every section stays editable throughout setup. You can move back and forth between steps, adjust earlier information, and review what is still missing before the match becomes ready. Once setup is complete, you can open the match and begin recording tosses, lineups, rallies, and the rest of the live flow. +
@error('setup') - {{ $message }} + {{ $message }} @enderror - +
- @if ($step === 'missing-match') - + @if ($step === 'missing-match' || $step === 'competition') + + @if (! $competitionConfigured) +
+
+
Competition
+ Competition details + + Save the current competition name before continuing with the match setup flow. + +
+ +
+ + Competition name + + + + + + Save competition + +
+
+ @elseif ($editingCompetition) +
+
+
Competition
+ + + Update the competition name inline, then press Enter to save it. + + +
+
+ @else +
+
Competition
+
+ {{ $currentCompetitionName }} + +
+ + Competition details are set. You can still edit them before setup is completed. + +
+ @endif +
+ +
- Create the current match - - The database does not have a current match yet. Create the singleton match record, then complete the setup flow. + Create the current match + + @if ($competitionConfigured) + The database does not have a current match yet. Create the singleton match record, then complete the remaining setup flow. + @else + After saving the competition, create the singleton match record here and continue through the setup flow. + @endif
- + Create current match
@@ -42,11 +102,11 @@ @elseif ($game !== null) @php - $stepOrder = ['match-details', 'rosters', 'officials', 'ready']; + $stepOrder = ['competition', 'match-details', 'rosters', 'officials', 'ready']; @endphp
- +
@foreach ($setupSteps as $stepKey => $stepLabel) @php @@ -61,22 +121,22 @@ wire:click="openStep('{{ $stepKey }}')" class="@class([ 'flex w-full items-center justify-between rounded-2xl border px-4 py-3 text-left transition', - 'border-amber-400/60 bg-amber-500/10 text-white' => $isCurrent, - 'border-emerald-400/40 bg-emerald-500/10 text-slate-100' => $isComplete && ! $isCurrent, - 'border-white/10 bg-white/5 text-slate-300 hover:bg-white/10' => ! $isCurrent && ! $isComplete, + 'border-amber-300 bg-amber-50 text-slate-950' => $isCurrent, + 'border-emerald-200 bg-emerald-50 text-slate-900' => $isComplete && ! $isCurrent, + 'border-slate-200 bg-white text-slate-600 hover:bg-slate-50' => ! $isCurrent && ! $isComplete, ])" > {{ $stepLabel }} - + {{ $isComplete ? 'Done' : ($isCurrent ? 'Current' : 'Pending') }} @endforeach
-
+
Current Match
-
+
{{ $homeTeamName !== '' ? $homeTeamName : 'Home team' }} vs {{ $awayTeamName !== '' ? $awayTeamName : 'Away team' }} @@ -89,18 +149,57 @@ class="@class([
@if ($isSetupLocked) - - Setup is locked - + + 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 === 'competition') + + @if ($editingCompetition) +
+
+
Competition
+ + + Update the competition name inline, then press Enter to save it. + + +
+
+ @else +
+
Competition
+
+ {{ $currentCompetitionName }} + +
+ + Competition details are set. You can still edit them before setup is completed. + +
+ @endif +
@elseif ($step === 'match-details') - +
- Match details - + Match details + Enter the static metadata for the one current match, including the home and away team identity.
@@ -109,37 +208,37 @@ class="@class([
@error('matchNumber') - {{ $message }} + {{ $message }} @enderror
@error('matchCountryCode') - {{ $message }} + {{ $message }} @enderror
@error('matchDateTime') - {{ $message }} + {{ $message }} @enderror
@error('city') - {{ $message }} + {{ $message }} @enderror
@error('hall') - {{ $message }} + {{ $message }} @enderror
@error('pool') - {{ $message }} + {{ $message }} @enderror
@@ -158,41 +257,41 @@ class="@class([
@error('division') - {{ $message }} + {{ $message }} @enderror @error('category') - {{ $message }} + {{ $message }} @enderror
-
- Home team +
+ Home team
@error('homeTeamName') - {{ $message }} + {{ $message }} @enderror
@error('homeTeamCountryCode') - {{ $message }} + {{ $message }} @enderror
-
- Away team +
+ Away team
@error('awayTeamName') - {{ $message }} + {{ $message }} @enderror
@error('awayTeamCountryCode') - {{ $message }} + {{ $message }} @enderror
@@ -204,11 +303,11 @@ class="@class([ @elseif ($step === 'rosters') - +
- Home and away rosters - + Home and away rosters + Enter the match roster directly for each team. Every listed player becomes part of the current match roster.
@@ -218,11 +317,11 @@ class="@class([ '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. + {{ $team['label'] }} + Players, captain, libero selection, and bench staff.
Add player @@ -230,7 +329,7 @@ class="@class([
@error($side.'PlayerRows') - {{ $message }} + {{ $message }} @enderror
@@ -248,19 +347,19 @@ class="@class([
@error($side.'PlayerRows.'.$index.'.first_name') - {{ $message }} + {{ $message }} @enderror
@error($side.'PlayerRows.'.$index.'.last_name') - {{ $message }} + {{ $message }} @enderror
@error($side.'PlayerRows.'.$index.'.number') - {{ $message }} + {{ $message }} @enderror
@@ -285,23 +384,23 @@ class="h-4 w-4 border-zinc-300 text-zinc-900 focus:ring-zinc-500"
- Bench staff + Bench staff @foreach ($team['staff'] as $index => $staffRow)
-
+
{{ $staffRow['role'] }}
@error($side.'StaffRows.'.$index.'.first_name') - {{ $message }} + {{ $message }} @enderror
@error($side.'StaffRows.'.$index.'.last_name') - {{ $message }} + {{ $message }} @enderror
@@ -317,31 +416,31 @@ class="h-4 w-4 border-zinc-300 text-zinc-900 focus:ring-zinc-500" @elseif ($step === 'officials') - +
- Officials - + Officials + Enter the assigned officials for the scoresheet before moving into gameplay.
@foreach ($officialRows as $index => $officialRow) -
- {{ $officialRow['role'] }} +
+ {{ $officialRow['role'] }}
@error('officialRows.'.$index.'.first_name') - {{ $message }} + {{ $message }} @enderror
@error('officialRows.'.$index.'.last_name') - {{ $message }} + {{ $message }} @enderror
@@ -349,7 +448,7 @@ class="h-4 w-4 border-zinc-300 text-zinc-900 focus:ring-zinc-500"
@error('officialRows.'.$index.'.country_code') - {{ $message }} + {{ $message }} @enderror
@@ -364,35 +463,35 @@ class="h-4 w-4 border-zinc-300 text-zinc-900 focus:ring-zinc-500" @endif @if ($step === 'ready' || $isSetupLocked) - +
- Match ready - + Match ready + Static setup is complete. Use the scoring screen for tosses, lineups, and all event recording.
-
+
Fixture
-
{{ $homeTeamName }} vs {{ $awayTeamName }}
+
{{ $homeTeamName }} vs {{ $awayTeamName }}
-
+
Venue
-
{{ $city }}, {{ $hall }}
+
{{ $city }}, {{ $hall }}
-
+
Rosters
-
{{ collect($homePlayerRows)->filter(fn ($row) => trim($row['number']) !== '')->count() }} / {{ collect($awayPlayerRows)->filter(fn ($row) => trim($row['number']) !== '')->count() }}
+
{{ 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
+
{{ 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 diff --git a/routes/web.php b/routes/web.php index b8585ef..949e71b 100644 --- a/routes/web.php +++ b/routes/web.php @@ -2,11 +2,13 @@ declare(strict_types=1); +use App\Livewire\Competition; use App\Livewire\Game; use App\Livewire\MatchSetup; use App\Services\CurrentMatchResolver; use Illuminate\Support\Facades\Route; Route::get('/', fn (CurrentMatchResolver $currentMatchResolver) => redirect()->route($currentMatchResolver->landingRouteName()))->name('home'); +Route::livewire('/competition', Competition::class)->name('competition'); Route::livewire('/setup', MatchSetup::class)->name('match.setup'); Route::livewire('/game', Game::class)->name('game'); diff --git a/tests/Feature/CompetitionPageTest.php b/tests/Feature/CompetitionPageTest.php new file mode 100644 index 0000000..dfa9b51 --- /dev/null +++ b/tests/Feature/CompetitionPageTest.php @@ -0,0 +1,56 @@ +get(route('competition')) + ->assertSuccessful() + ->assertSee('Set the competition details'); +}); + +test('competition page seeds the singleton from config when missing', function (): void { + config()->set('competition.name', 'World Cup Finals'); + + Livewire::test(CompetitionComponent::class) + ->assertSet('name', 'World Cup Finals') + ->call('save') + ->assertSet('saved', true); + + expect(Competition::query()->count())->toBe(1) + ->and(Competition::query()->sole()->name)->toBe('World Cup Finals'); +}); + +test('competition page updates the current competition details', function (): void { + $competition = Competition::factory() + ->named('Nations League Finals') + ->create(); + + Livewire::test(CompetitionComponent::class) + ->assertSet('name', $competition->name) + ->set('name', 'European Competition') + ->call('save') + ->assertSet('saved', true); + + expect(Competition::query()->count())->toBe(1) + ->and(Competition::query()->sole()->name)->toBe('European Competition'); +}); + +test('game competition name prefers the stored competition', function (): void { + config()->set('competition.name', 'Fallback Competition'); + + Competition::factory() + ->named('Stored Competition') + ->create(); + + $game = Game::factory()->create(); + + expect($game->competitionName())->toBe('Stored Competition'); +}); diff --git a/tests/Feature/MatchSetupRoutingTest.php b/tests/Feature/MatchSetupRoutingTest.php index 8902741..f9c1ed4 100644 --- a/tests/Feature/MatchSetupRoutingTest.php +++ b/tests/Feature/MatchSetupRoutingTest.php @@ -4,6 +4,7 @@ use App\Enums\OfficialRole; use App\Livewire\MatchSetup; +use App\Models\Competition; use App\Models\Game; use Illuminate\Foundation\Testing\RefreshDatabase; use Livewire\Livewire; @@ -23,11 +24,13 @@ $this->get(route('match.setup')) ->assertSuccessful() + ->assertSee('Competition details') ->assertSee('Create the current match') - ->assertSee('Create current match'); + ->assertSee('Save competition'); }); test('game redirects to setup when current match setup is incomplete', function (): void { + Competition::factory()->named('Nations League Finals')->create(); createCurrentMatchWithoutDetails(); $this->get(route('game')) @@ -35,6 +38,7 @@ }); test('setup page shows the next required step for an incomplete current match', function (): void { + Competition::factory()->named('Nations League Finals')->create(); createCurrentMatchWithoutDetails(); $this->get(route('match.setup')) @@ -43,7 +47,19 @@ ->assertDontSee('Open current match'); }); +test('game redirects to setup when the competition details are missing', function (): void { + makeReadyCurrentMatch(withCompetition: false); + + $this->get(route('game')) + ->assertRedirect(route('match.setup')); + + $this->get(route('match.setup')) + ->assertSuccessful() + ->assertSee('Competition details'); +}); + test('home redirects to the match when setup is ready', function (): void { + Competition::factory()->named('Nations League Finals')->create(); makeReadyCurrentMatch(); $this->get(route('home')) @@ -51,6 +67,8 @@ }); test('setup can create the current match when none exists', function (): void { + Competition::factory()->named('Nations League Finals')->create(); + Livewire::test(MatchSetup::class) ->call('createMatch') ->assertSet('step', 'match-details') @@ -60,6 +78,7 @@ }); test('setup reuses the current match instead of inserting another one', function (): void { + Competition::factory()->named('Nations League Finals')->create(); $game = createCurrentMatchWithoutDetails(); Livewire::test(MatchSetup::class) @@ -71,6 +90,7 @@ }); test('setup createMatch reuses a singleton created after the component mounts', function (): void { + Competition::factory()->named('Nations League Finals')->create(); $component = Livewire::test(MatchSetup::class); $game = createCurrentMatchWithoutDetails(); @@ -84,9 +104,10 @@ }); test('setup flow can take a blank current match to a playable ready state', function (): void { - createCurrentMatchWithoutDetails(); - $component = Livewire::test(MatchSetup::class) + ->set('competitionName', 'Nations League Finals') + ->call('saveCompetition') + ->call('createMatch') ->set('matchNumber', '7') ->set('matchCountryCode', 'ITA') ->set('city', 'Rome') @@ -133,3 +154,25 @@ $this->get(route('game')) ->assertSuccessful(); }); + +test('competition can be edited inline after it has already been saved', function (): void { + $component = Livewire::test(MatchSetup::class) + ->set('competitionName', 'Nations League Finals') + ->call('saveCompetition') + ->call('createMatch') + ->assertSet('step', 'match-details'); + + $component + ->call('openStep', 'competition') + ->assertSet('step', 'competition') + ->assertSet('editingCompetition', false) + ->call('editCompetition') + ->assertSet('editingCompetition', true) + ->set('competitionName', 'European Competition') + ->call('saveCompetition') + ->assertSet('competitionName', 'European Competition') + ->assertSet('editingCompetition', false) + ->assertSet('step', 'match-details'); + + expect(Competition::query()->sole()->name)->toBe('European Competition'); +}); diff --git a/tests/Feature/SingletonCurrentMatchAccessGuardTest.php b/tests/Feature/SingletonCurrentMatchAccessGuardTest.php index fcb55a1..ac7d5a0 100644 --- a/tests/Feature/SingletonCurrentMatchAccessGuardTest.php +++ b/tests/Feature/SingletonCurrentMatchAccessGuardTest.php @@ -5,7 +5,7 @@ use Illuminate\Support\Facades\File; test('singleton match livewire components do not use direct static Game access', function (): void { - // Historical migrations may still mention championship-era schema names; that is preserved + // Historical migrations may still mention competition-era schema names; that is preserved // migration history, not the active single-match runtime architecture this guard enforces. $componentPaths = collect(File::files(app_path('Livewire'))) ->map(fn (SplFileInfo $file): string => $file->getPathname()) diff --git a/tests/Feature/Support/SingleMatchTestSupport.php b/tests/Feature/Support/SingleMatchTestSupport.php index 05c81b0..e7b9ea3 100644 --- a/tests/Feature/Support/SingleMatchTestSupport.php +++ b/tests/Feature/Support/SingleMatchTestSupport.php @@ -6,6 +6,7 @@ use App\Enums\OfficialRole; use App\Enums\TeamAB; use App\Enums\TeamSide; +use App\Models\Competition; use App\Models\Game; use App\Models\Official; use App\Models\Player; @@ -136,8 +137,12 @@ function assignRequiredOfficials(Game $game): Game return $game->fresh(); } -function makeReadyCurrentMatch(): Game +function makeReadyCurrentMatch(bool $withCompetition = true): Game { + if ($withCompetition) { + Competition::ensureSingleton(); + } + $game = createCurrentMatch(); submitInitialRosters($game);