feat(tasks): separate Runner modal — Start actually runs the task

Before: Both info-icon (Details) and Start button opened the same
modal (tasks.modals.detail). Made no sense — Start should DO the task,
not just show metadata.

Now:
- Info-icon (i)     -> tasks.modals.detail  (read-only metadata)
- Start / Wiederholen-> tasks.modals.runner (interactive quiz)

New: App\Livewire\Tasks\Modals\Runner

Multi-step quiz modal with:
- 4-step progress bar (Frage X / Y + colored bar)
- Question + 4 answer options (2x2 grid, Tailwind cards)
- submitAnswer(int $option) advances step + tallies correctAnswers
- finished state shows trophy (passed) or arrow-path (partial)
- restart() resets quiz to step 1
- closeModalOnClickAway() = false (forces explicit cancel/finish)
- 2xl max-width for comfortable reading

Tasks/Index::startTask() now dispatches tasks.modals.runner via
$this->js("Livewire.dispatch('openModal', ...)") — same dispatch pattern
as before, different target component.

Placeholder data: 4 multiplication questions hardcoded. Future iteration
should load task content from DB (TaskItem / Question models).

Tests:
- TasksRunnerModalTest (10 tests, 25 assertions):
    * Runner extends ModalComponent + alias resolves
    * Step 1 renders question + 4 options
    * Correct answer increments correctAnswers + advances step
    * Wrong answer advances step without point
    * Final step sets finished=true
    * restart() resets state
    * closeModalOnClickAway = false
    * Progress label "Frage X / Y"
    * Success path shows trophy
- TasksStartButtonTest updated:
    * Asserts startTask emits dispatch with 'tasks.modals.runner'
      (NOT 'tasks.modals.detail')
    * Asserts info-button still routes to detail
- 167 tests passed, 0 failed, 0 skipped

Verification:
- All 8 modal aliases resolve via livewire.finder
- All 8 page routes return 302 unauth
- npm build OK
- No HTTP / no console errors expected

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
main
Boban Blaskovic 2026-05-22 21:59:51 +02:00
parent a6c44fd169
commit 7c6ea8d9f0
5 changed files with 297 additions and 8 deletions

View File

@ -53,12 +53,12 @@ class Index extends Component
$this->startedKeys[] = $key; $this->startedKeys[] = $key;
} }
// Future: redirect to a real lesson runner. For now open detail modal. // Start = open the Runner modal (actual quiz/exercise) — NOT the Detail
// Use $this->js() to emit a browser-side Livewire dispatch — server-side // modal which is reserved for the info-icon button. Use $this->js() to
// $this->dispatch()->to(...) in Livewire 4 + wire-elements drops named // emit a browser-side Livewire dispatch — server-side $this->dispatch()
// payload args, causing "$component dependency unresolved" in Modal::openModal. // with ->to(...) in Livewire 4 + wire-elements drops named payload args.
$payload = json_encode([ $payload = json_encode([
'component' => 'tasks.modals.detail', 'component' => 'tasks.modals.runner',
'arguments' => ['task' => $task], 'arguments' => ['task' => $task],
], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); ], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);

View File

@ -0,0 +1,74 @@
<?php
namespace App\Livewire\Tasks\Modals;
use Illuminate\Contracts\View\View;
use LivewireUI\Modal\ModalComponent;
class Runner extends ModalComponent
{
public array $task = [];
public int $currentStep = 1;
public int $totalSteps = 4;
public bool $finished = false;
public ?string $answer = null;
public int $correctAnswers = 0;
/** @var array<int,array<string,mixed>> 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');
}
}

View File

@ -0,0 +1,98 @@
@php
$q = $this->currentQuestion();
$progressPercent = $totalSteps > 0 ? (int) round(($currentStep / $totalSteps) * 100) : 0;
@endphp
<div data-testid="task-runner-modal">
<header class="flex items-center justify-between gap-4 p-5 border-b border-line">
<div class="flex items-center gap-3 min-w-0">
<div class="w-10 h-10 rounded-lg bg-primary-soft text-primary-ink grid place-items-center font-bold text-lg shrink-0">
{{ $task['icon'] ?? '∑' }}
</div>
<div class="min-w-0">
<div class="text-[10px] uppercase tracking-[0.12em] text-ink-3 font-semibold">{{ $task['meta'] ?? '' }}</div>
<div class="font-bold text-base text-ink-1 truncate">{{ $task['title'] ?? 'Aufgabe' }}</div>
</div>
</div>
<button type="button" wire:click="closeModal" aria-label="Schließen" class="text-ink-3 hover:text-ink-1 transition-colors p-1 shrink-0">
<x-heroicon-o-x-mark class="w-5 h-5" />
</button>
</header>
@if(! $finished)
{{-- Progress bar --}}
<div class="px-5 pt-4">
<div class="flex justify-between items-baseline mb-2">
<span class="text-xs font-semibold text-ink-2 font-mono tabular-nums">Frage {{ $currentStep }} / {{ $totalSteps }}</span>
<span class="text-xs font-semibold text-amber-ink font-mono tabular-nums">+{{ (int) ($task['points'] ?? 0) }} Pkt.</span>
</div>
<div class="h-1.5 rounded-full bg-bg-base overflow-hidden">
<div class="h-full rounded-full bg-primary-ink transition-all duration-500" style="width:{{ $progressPercent }}%;"></div>
</div>
</div>
{{-- Question --}}
<div class="p-5">
<div class="text-[11px] uppercase tracking-[0.12em] text-ink-3 font-semibold mb-2">Aufgabe</div>
<div class="text-xl font-bold tracking-tight text-ink-1 mb-5" data-testid="runner-question">
{{ $q['question'] }}
</div>
<div class="grid grid-cols-2 gap-3" wire:key="step-{{ $currentStep }}">
@foreach($q['options'] as $i => $opt)
<button
type="button"
wire:click="submitAnswer({{ $i }})"
wire:loading.attr="disabled"
wire:target="submitAnswer"
class="px-4 py-4 rounded-lg bg-bg-soft border-2 border-line text-ink-1 font-semibold text-base hover:border-primary hover:bg-primary-soft transition-all cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2"
data-testid="runner-option-{{ $i }}"
>
{{ $opt }}
</button>
@endforeach
</div>
</div>
<footer class="px-5 pb-5 pt-2 flex justify-between items-center text-xs text-ink-3">
<span>Klick eine Antwort an, um fortzufahren.</span>
<button type="button" wire:click="closeModal" class="hover:text-ink-1 underline">Abbrechen</button>
</footer>
@else
{{-- Finished state --}}
@php
$passed = $correctAnswers >= (int) ceil($totalSteps / 2);
$percent = $totalSteps > 0 ? (int) round(($correctAnswers / $totalSteps) * 100) : 0;
@endphp
<div class="p-8 text-center" data-testid="runner-finished">
<div class="w-16 h-16 rounded-full grid place-items-center mx-auto mb-4 {{ $passed ? 'bg-green-soft text-green-ink' : 'bg-amber-soft text-amber-ink' }}">
@if($passed)
<x-heroicon-s-trophy class="w-8 h-8" />
@else
<x-heroicon-s-arrow-path class="w-8 h-8" />
@endif
</div>
<div class="text-xl font-bold text-ink-1 mb-1">
@if($passed) Super gemacht! @else Fast geschafft @endif
</div>
<p class="text-sm text-ink-2 mb-2">
Du hast <span class="font-mono tabular-nums font-bold text-ink-1">{{ $correctAnswers }}</span>
von <span class="font-mono tabular-nums font-bold text-ink-1">{{ $totalSteps }}</span> Aufgaben richtig.
</p>
<p class="text-xs text-ink-3 mb-6 font-mono tabular-nums">{{ $percent }} % Erfolgsquote</p>
<div class="flex gap-2">
<x-btn wire:click="restart" class="flex-1 justify-center">
<x-heroicon-o-arrow-path class="w-4 h-4" />
Nochmal
</x-btn>
<x-btn variant="primary" wire:click="closeModal" class="flex-1 justify-center">
@if($passed) Punkte holen @else Fertig @endif
</x-btn>
</div>
</div>
@endif
</div>

View File

@ -0,0 +1,109 @@
<?php
use App\Livewire\Tasks\Modals\Runner;
use App\Models\License;
use App\Models\Role;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Livewire\Livewire;
use LivewireUI\Modal\ModalComponent;
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
beforeEach(function () {
$this->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!');
});

View File

@ -66,19 +66,27 @@ it('startTask action runs and adds key to startedKeys', function () {
->assertSet('startedKeys', ['t1']); ->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'); $tester = Livewire::test(TasksIndex::class)->call('startTask', 't1');
// Livewire 4 stores js() output in component effects under 'xjs' key
$effects = $tester->effects ?? []; $effects = $tester->effects ?? [];
$scripts = $effects['xjs'] ?? $effects['js'] ?? []; $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]); $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("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'); 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 () { it('startTask ignores unknown task keys', function () {
Livewire::test(TasksIndex::class) Livewire::test(TasksIndex::class)
->call('startTask', 'non-existent') ->call('startTask', 'non-existent')