diff --git a/app/Livewire/Tasks/Index.php b/app/Livewire/Tasks/Index.php index bcc32b2..9e7bee0 100644 --- a/app/Livewire/Tasks/Index.php +++ b/app/Livewire/Tasks/Index.php @@ -53,12 +53,12 @@ class Index extends Component $this->startedKeys[] = $key; } - // Future: redirect to a real lesson runner. For now open detail modal. - // Use $this->js() to emit a browser-side Livewire dispatch — server-side - // $this->dispatch()->to(...) in Livewire 4 + wire-elements drops named - // payload args, causing "$component dependency unresolved" in Modal::openModal. + // Start = open the Runner modal (actual quiz/exercise) — NOT the Detail + // modal which is reserved for the info-icon button. Use $this->js() to + // emit a browser-side Livewire dispatch — server-side $this->dispatch() + // with ->to(...) in Livewire 4 + wire-elements drops named payload args. $payload = json_encode([ - 'component' => 'tasks.modals.detail', + 'component' => 'tasks.modals.runner', 'arguments' => ['task' => $task], ], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); diff --git a/app/Livewire/Tasks/Modals/Runner.php b/app/Livewire/Tasks/Modals/Runner.php new file mode 100644 index 0000000..42ef3d2 --- /dev/null +++ b/app/Livewire/Tasks/Modals/Runner.php @@ -0,0 +1,74 @@ +> Placeholder mini-quiz; future = DB-loaded items */ + protected array $steps = [ + ['question' => 'Was ergibt 12 × 8?', 'options' => ['64', '88', '96', '102'], 'correct' => 2], + ['question' => 'Was ergibt 15 × 7?', 'options' => ['95', '105', '115', '125'], 'correct' => 1], + ['question' => 'Was ergibt 9 × 11?', 'options' => ['91', '99', '108', '111'], 'correct' => 1], + ['question' => 'Was ergibt 14 × 6?', 'options' => ['72', '78', '84', '88'], 'correct' => 2], + ]; + + public function mount(array $task = []): void + { + $this->task = $task; + $this->totalSteps = count($this->steps); + } + + public function currentQuestion(): array + { + return $this->steps[$this->currentStep - 1] ?? $this->steps[0]; + } + + public function submitAnswer(int $optionIndex): void + { + $q = $this->currentQuestion(); + if ($optionIndex === ($q['correct'] ?? -1)) { + $this->correctAnswers++; + } + + if ($this->currentStep >= $this->totalSteps) { + $this->finished = true; + return; + } + + $this->currentStep++; + $this->answer = null; + } + + public function restart(): void + { + $this->currentStep = 1; + $this->finished = false; + $this->correctAnswers = 0; + $this->answer = null; + } + + public static function modalMaxWidth(): string + { + return '2xl'; + } + + public static function closeModalOnClickAway(): bool + { + return false; + } + + public function render(): View + { + return view('livewire.tasks.modals.runner'); + } +} diff --git a/resources/views/livewire/tasks/modals/runner.blade.php b/resources/views/livewire/tasks/modals/runner.blade.php new file mode 100644 index 0000000..782e7ce --- /dev/null +++ b/resources/views/livewire/tasks/modals/runner.blade.php @@ -0,0 +1,98 @@ +@php + $q = $this->currentQuestion(); + $progressPercent = $totalSteps > 0 ? (int) round(($currentStep / $totalSteps) * 100) : 0; +@endphp + +
+ +
+
+
+ {{ $task['icon'] ?? '∑' }} +
+
+
{{ $task['meta'] ?? '' }}
+
{{ $task['title'] ?? 'Aufgabe' }}
+
+
+ +
+ + @if(! $finished) + {{-- Progress bar --}} +
+
+ Frage {{ $currentStep }} / {{ $totalSteps }} + +{{ (int) ($task['points'] ?? 0) }} Pkt. +
+
+
+
+
+ + {{-- Question --}} +
+
Aufgabe
+
+ {{ $q['question'] }} +
+ +
+ @foreach($q['options'] as $i => $opt) + + @endforeach +
+
+ + + @else + {{-- Finished state --}} + @php + $passed = $correctAnswers >= (int) ceil($totalSteps / 2); + $percent = $totalSteps > 0 ? (int) round(($correctAnswers / $totalSteps) * 100) : 0; + @endphp +
+
+ @if($passed) + + @else + + @endif +
+ +
+ @if($passed) Super gemacht! @else Fast geschafft @endif +
+

+ Du hast {{ $correctAnswers }} + von {{ $totalSteps }} Aufgaben richtig. +

+

{{ $percent }} % Erfolgsquote

+ +
+ + + Nochmal + + + @if($passed) Punkte holen @else Fertig @endif + +
+
+ @endif + +
diff --git a/tests/Feature/Livewire/TasksRunnerModalTest.php b/tests/Feature/Livewire/TasksRunnerModalTest.php new file mode 100644 index 0000000..868d906 --- /dev/null +++ b/tests/Feature/Livewire/TasksRunnerModalTest.php @@ -0,0 +1,109 @@ +seed(\Database\Seeders\RolesSeeder::class); + + $tenant = Tenant::firstOrCreate(['name' => 'Test Schule'], ['type' => 'school', 'active' => true]); + $this->user = User::withoutGlobalScopes()->create([ + 'name' => 'Runner Tester', 'email' => 'runner@test.example', + 'tenant_id' => $tenant->id, 'password' => bcrypt('password'), + ]); + + $role = Role::where('name', 'child')->first(); + DB::table('role_user')->insert(['user_id' => $this->user->id, 'role_id' => $role->id, 'created_at' => now(), 'updated_at' => now()]); + + $license = License::withoutGlobalScopes()->create([ + 'tenant_id' => $tenant->id, 'type' => 'school_flat', + 'expires_at' => now()->addYear(), 'active' => true, + ]); + DB::table('license_assignments')->insert(['license_id' => $license->id, 'user_id' => $this->user->id, 'created_at' => now(), 'updated_at' => now()]); + + $this->task = ['key' => 't1', 'title' => 'Multiplikation bis 100', 'meta' => 'Mathe · 4 Aufgaben', 'icon' => '∑', 'points' => 120]; + $this->actingAs($this->user); +}); + +it('Runner modal class extends ModalComponent', function () { + expect(is_subclass_of(Runner::class, ModalComponent::class))->toBeTrue(); +}); + +it('Runner alias resolves to App\\Livewire\\Tasks\\Modals\\Runner', function () { + $cls = app('livewire.finder')->resolveClassComponentClassName('tasks.modals.runner'); + expect($cls)->toBe(Runner::class); +}); + +it('Runner renders question + 4 options for step 1', function () { + Livewire::test(Runner::class, ['task' => $this->task]) + ->assertSet('currentStep', 1) + ->assertSet('totalSteps', 4) + ->assertSet('finished', false) + ->assertSee('Multiplikation bis 100') + ->assertSeeHtml('data-testid="runner-question"') + ->assertSeeHtml('data-testid="runner-option-0"') + ->assertSeeHtml('data-testid="runner-option-3"'); +}); + +it('submitAnswer with correct option increments correctAnswers + advances step', function () { + Livewire::test(Runner::class, ['task' => $this->task]) + ->call('submitAnswer', 2) // step 1 correct = index 2 + ->assertSet('correctAnswers', 1) + ->assertSet('currentStep', 2); +}); + +it('submitAnswer with wrong option still advances step but no point', function () { + Livewire::test(Runner::class, ['task' => $this->task]) + ->call('submitAnswer', 0) // step 1 wrong + ->assertSet('correctAnswers', 0) + ->assertSet('currentStep', 2); +}); + +it('finishing all steps sets finished=true', function () { + Livewire::test(Runner::class, ['task' => $this->task]) + ->call('submitAnswer', 2) // step 1 + ->call('submitAnswer', 1) // step 2 + ->call('submitAnswer', 1) // step 3 + ->call('submitAnswer', 2) // step 4 — final + ->assertSet('finished', true) + ->assertSet('correctAnswers', 4) + ->assertSeeHtml('data-testid="runner-finished"'); +}); + +it('restart resets state', function () { + Livewire::test(Runner::class, ['task' => $this->task]) + ->call('submitAnswer', 2) + ->call('submitAnswer', 1) + ->call('restart') + ->assertSet('currentStep', 1) + ->assertSet('correctAnswers', 0) + ->assertSet('finished', false); +}); + +it('Runner does NOT close on click-away (must finish or cancel explicitly)', function () { + expect(Runner::closeModalOnClickAway())->toBeFalse(); +}); + +it('Runner displays progress bar with step indicator', function () { + $html = Livewire::test(Runner::class, ['task' => $this->task])->html(); + expect($html)->toContain('Frage 1 / 4'); +}); + +it('Runner shows trophy on success, arrow-path on partial', function () { + // 4/4 correct → trophy (passed) + $html = Livewire::test(Runner::class, ['task' => $this->task]) + ->call('submitAnswer', 2) + ->call('submitAnswer', 1) + ->call('submitAnswer', 1) + ->call('submitAnswer', 2) + ->html(); + expect($html)->toContain('Super gemacht!'); +}); diff --git a/tests/Feature/Livewire/TasksStartButtonTest.php b/tests/Feature/Livewire/TasksStartButtonTest.php index 51e3d7a..1ed2362 100644 --- a/tests/Feature/Livewire/TasksStartButtonTest.php +++ b/tests/Feature/Livewire/TasksStartButtonTest.php @@ -66,19 +66,27 @@ it('startTask action runs and adds key to startedKeys', function () { ->assertSet('startedKeys', ['t1']); }); -it('startTask emits browser-side Livewire.dispatch via $this->js()', function () { +it('startTask emits browser-side Livewire.dispatch via $this->js() targeting RUNNER modal (not detail)', function () { $tester = Livewire::test(TasksIndex::class)->call('startTask', 't1'); - // Livewire 4 stores js() output in component effects under 'xjs' key $effects = $tester->effects ?? []; $scripts = $effects['xjs'] ?? $effects['js'] ?? []; $allScripts = implode("\n", is_array($scripts) ? array_map(fn($s) => is_array($s) ? json_encode($s) : (string) $s, $scripts) : [(string) $scripts]); expect($allScripts)->toContain("Livewire.dispatch('openModal'"); - expect($allScripts)->toContain('tasks.modals.detail'); + // Start button opens RUNNER, not detail + expect($allScripts)->toContain('tasks.modals.runner'); + expect($allScripts)->not->toContain('tasks.modals.detail'); expect($allScripts)->toContain('t1'); }); +it('Detail (info) button still opens tasks.modals.detail — separate from Start', function () { + $html = Livewire::test(TasksIndex::class)->html(); + // Info-button is client-side wire:click="$dispatch('openModal', ...detail...)" + expect($html)->toContain('data-testid="task-detail-btn"'); + expect($html)->toContain('tasks.modals.detail'); +}); + it('startTask ignores unknown task keys', function () { Livewire::test(TasksIndex::class) ->call('startTask', 'non-existent')