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
+
+
+
+
+
+ @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')