feat(tasks): dedicated /tasks/{key}/run page with per-subject question bank
Before: tasks.modals.runner showed the same 4 multiplication questions
regardless of subject (Mathe / Deutsch / Englisch / Sachkunde / Musik).
Made no sense — Englisch task shouldn't ask 12 × 8.
Now: dedicated full-page route /tasks/{key}/run loads the subject-correct
question bank based on the task's `color` token, with structured URL,
proper auth + license middleware, and Lazy skeleton placeholder.
New artifacts:
config/task-questions.php — Question bank keyed by color/subject:
primary -> Mathematik (5 multiplication / division questions)
rose -> Deutsch (5 grammar/wortart/artikel questions)
violet -> Englisch (5 vocab/grammar in English)
green -> Sachkunde (5 nature/animals/space)
amber -> Musik (5 musical notation / theory)
config/tasks.php — Placeholder task list extracted from Index so
both Tasks\Index (list page) and Tasks\Run (quiz page) share data.
Future: replace with DB-backed Task model.
app/Livewire/Tasks/Run.php
- Lazy page-component, #[Layout('layouts.dashboard', active=tasks)]
- mount(string $key) -> abort 404 if task or question bank missing
- findTask() reads config('tasks')
- questions = config("task-questions.{$task['color']}")
- submitAnswer(int) records lastAnswerIndex + lastAnswerCorrect +
tallies correctAnswers (visual feedback before advance)
- nextStep() advances or sets finished=true on final
- restart() resets all progress
- placeholder() returns livewire.tasks.run-placeholder (skeleton)
resources/views/livewire/tasks/run.blade.php
- Back link + task header card (icon, meta, title, points)
- Progress bar (Frage X / Y + correct counter)
- Question card with 2x2 option grid, A/B/C/D labels
- Per-color theme (primary/rose/violet/green/amber bar + ring)
- Two-step interaction: pick option -> show correct/wrong feedback ->
"Nächste Frage" / "Aufgabe abschließen"
- Finished card: trophy or arrow-path; points earned; restart / done
- All buttons use design tokens + heroicons (no inline SVG)
resources/views/livewire/tasks/run-placeholder.blade.php
- Skeleton with same layout shape (back link, header card, question)
- data-testid="task-run-skeleton"
routes/web.php
Route::get('/tasks/{key}/run', \App\Livewire\Tasks\Run::class)
->whereAlphaNumeric('key')->name('tasks.run')
Behind auth + license group like other dashboard pages.
Tasks/Index::startTask() simplified:
$this->redirectRoute('tasks.run', ['key' => $key], navigate: true)
(Server-side redirect; client navigates without full reload.)
Security:
- whereAlphaNumeric('key') restricts URL param to safe chars
- abort_if($task === null, 404) prevents directory probing
- auth+license middleware applies to whole route group
- Question bank is config (no user-supplied content interpolated)
- Answer index validated against question[correct] only — no dynamic eval
Tests (27 new + 0 broken):
- TaskQuestionBankTest (4): config exists; 5 buckets; required keys;
per-color content differs
- TasksRunPageTest (15): route registered; auth happy path; 404 for
unknown key; per-color question selection (math/deutsch/eng/sachkunde);
submitAnswer correct/wrong; nextStep advances + clears feedback;
finishing 5 steps -> finished=true; restart resets; Index.startTask
redirects; unauth redirected to login; placeholder testid
- TasksStartButtonTest updated: Start now redirects, no longer
dispatches modal
Full verification:
- 186 tests passed, 0 failed, 0 skipped (661 assertions)
- npm build OK, manifest generated
- All 8 page routes return 302 unauth (correct)
- All 5 /tasks/{key}/run routes return 302 unauth (correct)
- All 8 modal aliases still resolve (legacy Runner modal kept)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
main
parent
7c6ea8d9f0
commit
cae775ba93
|
|
@ -53,16 +53,10 @@ class Index extends Component
|
|||
$this->startedKeys[] = $key;
|
||||
}
|
||||
|
||||
// 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.runner',
|
||||
'arguments' => ['task' => $task],
|
||||
], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
|
||||
|
||||
$this->js("Livewire.dispatch('openModal', {$payload})");
|
||||
// Start navigates to the dedicated Run page (subject-specific quiz)
|
||||
// instead of opening a modal — same data but per-subject questions
|
||||
// and a structured URL.
|
||||
$this->redirectRoute('tasks.run', ['key' => $key], navigate: true);
|
||||
}
|
||||
|
||||
public function repeatTask(string $key): void
|
||||
|
|
@ -73,15 +67,7 @@ class Index extends Component
|
|||
#[Computed]
|
||||
public function tasks(): array
|
||||
{
|
||||
return [
|
||||
['key' => 't1', 'title' => 'Multiplikation bis 100', 'meta' => 'Mathematik · 12 Aufgaben · 15 Min.', 'icon' => '∑', 'color' => 'primary', 'points' => 120, 'status' => 'open', 'due' => 'Heute'],
|
||||
['key' => 't2', 'title' => 'Wortarten: Nomen & Verben', 'meta' => 'Deutsch · Lese-Aufgabe · 10 Min.', 'icon' => 'A', 'color' => 'rose', 'points' => 80, 'status' => 'open', 'due' => 'Heute'],
|
||||
['key' => 't3', 'title' => 'Vocab Review: Animals', 'meta' => 'Englisch · Karteikarten · 8 Min.', 'icon' => 'E', 'color' => 'violet', 'points' => 60, 'status' => 'open', 'due' => 'Heute'],
|
||||
['key' => 't4', 'title' => 'Kreislauf des Wassers', 'meta' => 'Sachkunde · Video + Quiz · 12 Min.', 'icon' => '🌱', 'color' => 'green', 'points' => 100, 'status' => 'open', 'due' => 'Heute'],
|
||||
['key' => 't5', 'title' => 'Geometrie: Flächen', 'meta' => 'Mathematik · Übung · 20 Min.', 'icon' => '∑', 'color' => 'primary', 'points' => 150, 'status' => 'open', 'due' => 'Morgen'],
|
||||
['key' => 't6', 'title' => 'Gedicht lernen', 'meta' => 'Deutsch · Lese-Aufgabe · 15 Min.', 'icon' => 'A', 'color' => 'rose', 'points' => 90, 'status' => 'done', 'due' => 'Gestern'],
|
||||
['key' => 't7', 'title' => 'Past Simple Übung', 'meta' => 'Englisch · Übung · 10 Min.', 'icon' => 'E', 'color' => 'violet', 'points' => 70, 'status' => 'done', 'due' => 'Gestern'],
|
||||
];
|
||||
return config('tasks', []);
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,90 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Tasks;
|
||||
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Livewire\Attributes\Lazy;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Component;
|
||||
|
||||
#[Layout('layouts.dashboard', ['active' => 'tasks'])]
|
||||
#[Lazy]
|
||||
class Run extends Component
|
||||
{
|
||||
public string $key = '';
|
||||
public array $task = [];
|
||||
public array $questions = [];
|
||||
public int $currentStep = 1;
|
||||
public int $totalSteps = 0;
|
||||
public bool $finished = false;
|
||||
public int $correctAnswers = 0;
|
||||
public ?int $lastAnswerIndex = null;
|
||||
public ?bool $lastAnswerCorrect = null;
|
||||
|
||||
public function mount(string $key): void
|
||||
{
|
||||
$task = $this->findTask($key);
|
||||
abort_if($task === null, 404, "Task '{$key}' not found.");
|
||||
|
||||
$this->key = $key;
|
||||
$this->task = $task;
|
||||
$this->questions = config("task-questions.{$task['color']}", []);
|
||||
$this->totalSteps = count($this->questions);
|
||||
|
||||
abort_if($this->totalSteps === 0, 404, "No questions configured for color '{$task['color']}'.");
|
||||
}
|
||||
|
||||
public static function placeholder(array $params = []): View
|
||||
{
|
||||
return view('livewire.tasks.run-placeholder');
|
||||
}
|
||||
|
||||
public function currentQuestion(): array
|
||||
{
|
||||
return $this->questions[$this->currentStep - 1] ?? $this->questions[0];
|
||||
}
|
||||
|
||||
public function submitAnswer(int $optionIndex): void
|
||||
{
|
||||
$q = $this->currentQuestion();
|
||||
$isCorrect = $optionIndex === ($q['correct'] ?? -1);
|
||||
|
||||
$this->lastAnswerIndex = $optionIndex;
|
||||
$this->lastAnswerCorrect = $isCorrect;
|
||||
|
||||
if ($isCorrect) {
|
||||
$this->correctAnswers++;
|
||||
}
|
||||
}
|
||||
|
||||
public function nextStep(): void
|
||||
{
|
||||
if ($this->currentStep >= $this->totalSteps) {
|
||||
$this->finished = true;
|
||||
return;
|
||||
}
|
||||
$this->currentStep++;
|
||||
$this->lastAnswerIndex = null;
|
||||
$this->lastAnswerCorrect = null;
|
||||
}
|
||||
|
||||
public function restart(): void
|
||||
{
|
||||
$this->currentStep = 1;
|
||||
$this->finished = false;
|
||||
$this->correctAnswers = 0;
|
||||
$this->lastAnswerIndex = null;
|
||||
$this->lastAnswerCorrect = null;
|
||||
}
|
||||
|
||||
/** Locate task by key — placeholder data (later DB-backed). */
|
||||
protected function findTask(string $key): ?array
|
||||
{
|
||||
return collect(config('tasks', []))->firstWhere('key', $key);
|
||||
}
|
||||
|
||||
public function render(): View
|
||||
{
|
||||
return view('livewire.tasks.run');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Task Question Bank — keyed by subject `color` token.
|
||||
*
|
||||
* Each entry is an array of question dicts:
|
||||
* - question: prompt (string)
|
||||
* - options: array<int,string> of 4 answer choices
|
||||
* - correct: int index (0..3) of correct option
|
||||
*
|
||||
* Subjects:
|
||||
* primary -> Mathematik
|
||||
* rose -> Deutsch
|
||||
* violet -> Englisch
|
||||
* green -> Sachkunde
|
||||
* amber -> Musik / Sport / Philosophie
|
||||
*
|
||||
* Future: replace with DB-backed Question + Answer models, scoped by tenant.
|
||||
*/
|
||||
|
||||
return [
|
||||
|
||||
'primary' => [
|
||||
['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],
|
||||
['question' => 'Was ergibt 144 ÷ 12?', 'options' => ['10', '11', '12', '13'], 'correct' => 2],
|
||||
],
|
||||
|
||||
'rose' => [
|
||||
['question' => 'Welches Wort ist ein Nomen?', 'options' => ['laufen', 'schnell', 'Hund', 'rot'], 'correct' => 2],
|
||||
['question' => 'Welches Wort ist ein Verb?', 'options' => ['Tisch', 'springen', 'grün', 'Lehrer'], 'correct' => 1],
|
||||
['question' => 'Welches Wort ist ein Adjektiv?', 'options' => ['Schule', 'lesen', 'glücklich', 'gehen'], 'correct' => 2],
|
||||
['question' => 'Wie wird "Buch" im Plural geschrieben?', 'options' => ['Buchs', 'Buchen', 'Bücher', 'Buches'], 'correct' => 2],
|
||||
['question' => 'Welcher Artikel passt zu "Sonne"?', 'options' => ['der', 'die', 'das', 'den'], 'correct' => 1],
|
||||
],
|
||||
|
||||
'violet' => [
|
||||
['question' => 'What is the past tense of "go"?', 'options' => ['goed', 'goes', 'gone', 'went'], 'correct' => 3],
|
||||
['question' => 'Translate "Katze" to English:', 'options' => ['dog', 'cat', 'cow', 'mouse'], 'correct' => 1],
|
||||
['question' => 'Which is the correct plural of "child"?', 'options' => ['childs', 'childes', 'children', 'childer'], 'correct' => 2],
|
||||
['question' => 'Pick the correct: "She ___ a book."', 'options' => ['read', 'reads', 'reading', 'reader'], 'correct' => 1],
|
||||
['question' => 'Translate "Apfel" to English:', 'options' => ['apple', 'orange', 'banana', 'lemon'], 'correct' => 0],
|
||||
],
|
||||
|
||||
'green' => [
|
||||
['question' => 'Was passiert beim Verdampfen von Wasser?', 'options' => ['Es wird flüssig', 'Es wird zu Dampf', 'Es gefriert', 'Es verschwindet'], 'correct' => 1],
|
||||
['question' => 'Wie viele Beine hat eine Spinne?', 'options' => ['6', '8', '10', '12'], 'correct' => 1],
|
||||
['question' => 'Welches Tier ist KEIN Säugetier?', 'options' => ['Hund', 'Pferd', 'Forelle', 'Katze'], 'correct' => 2],
|
||||
['question' => 'In welcher Jahreszeit fallen die Blätter?', 'options' => ['Frühling', 'Sommer', 'Herbst', 'Winter'], 'correct' => 2],
|
||||
['question' => 'Welcher Planet ist der Sonne am nächsten?', 'options' => ['Erde', 'Mars', 'Merkur', 'Venus'], 'correct' => 2],
|
||||
],
|
||||
|
||||
'amber' => [
|
||||
['question' => 'Wie viele Linien hat ein Notensystem?', 'options' => ['3', '4', '5', '6'], 'correct' => 2],
|
||||
['question' => 'Welche Note ist die Längste?', 'options' => ['Achtel', 'Viertel', 'Halbe', 'Ganze'], 'correct' => 3],
|
||||
['question' => 'Was bedeutet "forte" in der Musik?', 'options' => ['leise', 'laut', 'schnell', 'langsam'], 'correct' => 1],
|
||||
['question' => 'Wie viele Saiten hat eine normale Gitarre?', 'options' => ['4', '5', '6', '7'], 'correct' => 2],
|
||||
['question' => 'Was ist ein Komponist?', 'options' => ['Lehrer', 'Musik-Erfinder', 'Sänger', 'Instrumentenbauer'], 'correct' => 1],
|
||||
],
|
||||
|
||||
];
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Placeholder task list — keyed by `key`. Each task has:
|
||||
* key, title, meta, icon, color, points, status, due
|
||||
*
|
||||
* `color` ties into config/task-questions.php for the quiz bank used by
|
||||
* the Tasks\Run page.
|
||||
*
|
||||
* Future: replace with DB-backed Task model scoped by tenant + child + curriculum.
|
||||
*/
|
||||
|
||||
return [
|
||||
['key' => 't1', 'title' => 'Multiplikation bis 100', 'meta' => 'Mathematik · 12 Aufgaben · 15 Min.', 'icon' => '∑', 'color' => 'primary', 'points' => 120, 'status' => 'open', 'due' => 'Heute'],
|
||||
['key' => 't2', 'title' => 'Wortarten: Nomen & Verben', 'meta' => 'Deutsch · Lese-Aufgabe · 10 Min.', 'icon' => 'A', 'color' => 'rose', 'points' => 80, 'status' => 'open', 'due' => 'Heute'],
|
||||
['key' => 't3', 'title' => 'Vocab Review: Animals', 'meta' => 'Englisch · Karteikarten · 8 Min.', 'icon' => 'E', 'color' => 'violet', 'points' => 60, 'status' => 'open', 'due' => 'Heute'],
|
||||
['key' => 't4', 'title' => 'Kreislauf des Wassers', 'meta' => 'Sachkunde · Video + Quiz · 12 Min.', 'icon' => '🌱', 'color' => 'green', 'points' => 100, 'status' => 'open', 'due' => 'Heute'],
|
||||
['key' => 't5', 'title' => 'Geometrie: Flächen', 'meta' => 'Mathematik · Übung · 20 Min.', 'icon' => '∑', 'color' => 'primary', 'points' => 150, 'status' => 'open', 'due' => 'Morgen'],
|
||||
['key' => 't6', 'title' => 'Gedicht lernen', 'meta' => 'Deutsch · Lese-Aufgabe · 15 Min.', 'icon' => 'A', 'color' => 'rose', 'points' => 90, 'status' => 'done', 'due' => 'Gestern'],
|
||||
['key' => 't7', 'title' => 'Past Simple Übung', 'meta' => 'Englisch · Übung · 10 Min.', 'icon' => 'E', 'color' => 'violet', 'points' => 70, 'status' => 'done', 'due' => 'Gestern'],
|
||||
];
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
<div class="flex flex-col gap-6 max-w-3xl mx-auto w-full" data-testid="task-run-skeleton">
|
||||
{{-- Top nav skeleton --}}
|
||||
<div class="flex items-center justify-between animate-pulse">
|
||||
<div class="h-8 w-32 bg-line rounded-lg"></div>
|
||||
<div class="h-6 w-24 bg-line rounded-full"></div>
|
||||
</div>
|
||||
|
||||
{{-- Task header card --}}
|
||||
<x-skeleton.card :lines="2" />
|
||||
|
||||
{{-- Question card --}}
|
||||
<div class="bg-bg-soft border border-line rounded-xl p-8 shadow-sm animate-pulse">
|
||||
<div class="h-2 bg-line rounded w-24 mb-4"></div>
|
||||
<div class="h-8 bg-line rounded w-3/4 mb-8"></div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
@for($i = 0; $i < 4; $i++)
|
||||
<div class="h-16 bg-line rounded-lg"></div>
|
||||
@endfor
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
@php
|
||||
$colorMap = [
|
||||
'primary' => ['bg' => 'bg-primary-soft', 'ink' => 'text-primary-ink', 'bar' => 'bg-primary-ink', 'ring' => 'focus-visible:ring-primary'],
|
||||
'rose' => ['bg' => 'bg-rose-soft', 'ink' => 'text-rose-ink', 'bar' => 'bg-rose-ink', 'ring' => 'focus-visible:ring-rose-ink'],
|
||||
'violet' => ['bg' => 'bg-violet-soft', 'ink' => 'text-violet-ink', 'bar' => 'bg-violet-ink', 'ring' => 'focus-visible:ring-violet-ink'],
|
||||
'green' => ['bg' => 'bg-green-soft', 'ink' => 'text-green-ink', 'bar' => 'bg-green-ink', 'ring' => 'focus-visible:ring-green-ink'],
|
||||
'amber' => ['bg' => 'bg-amber-soft', 'ink' => 'text-amber-ink', 'bar' => 'bg-amber-ink', 'ring' => 'focus-visible:ring-amber-ink'],
|
||||
];
|
||||
$c = $colorMap[$task['color']] ?? $colorMap['primary'];
|
||||
$progressPercent = $totalSteps > 0 ? (int) round(($currentStep / $totalSteps) * 100) : 0;
|
||||
@endphp
|
||||
|
||||
<div class="flex flex-col gap-6 max-w-3xl mx-auto w-full" data-testid="task-run-page">
|
||||
|
||||
{{-- Breadcrumb / back nav --}}
|
||||
<div class="flex items-center justify-between">
|
||||
<a href="{{ route('tasks') }}" wire:navigate class="inline-flex items-center gap-2 text-sm font-semibold text-ink-2 hover:text-ink-1 transition-colors">
|
||||
<x-heroicon-o-arrow-left class="w-4 h-4" />
|
||||
Zurück zu Aufgaben
|
||||
</a>
|
||||
<x-pill>{{ $task['meta'] ?? '' }}</x-pill>
|
||||
</div>
|
||||
|
||||
{{-- Task header --}}
|
||||
<div class="bg-bg-soft border border-line rounded-xl p-5 shadow-sm flex items-center gap-4">
|
||||
<div class="w-14 h-14 rounded-lg {{ $c['bg'] }} {{ $c['ink'] }} grid place-items-center font-bold text-2xl shrink-0">
|
||||
{{ $task['icon'] }}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-[11px] uppercase tracking-[0.12em] text-ink-3 font-semibold">{{ $task['meta'] }}</div>
|
||||
<h1 class="font-bold text-xl text-ink-1 leading-tight truncate">{{ $task['title'] }}</h1>
|
||||
</div>
|
||||
<div class="text-right shrink-0">
|
||||
<div class="text-[10px] uppercase tracking-wider text-ink-3 font-semibold">Belohnung</div>
|
||||
<div class="text-lg font-bold font-mono tabular-nums text-amber-ink">+{{ (int) $task['points'] }} Pkt.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if(! $finished)
|
||||
{{-- Progress --}}
|
||||
<div>
|
||||
<div class="flex justify-between items-baseline mb-2">
|
||||
<span class="text-xs font-semibold text-ink-2 font-mono tabular-nums" data-testid="run-progress-label">
|
||||
Frage {{ $currentStep }} / {{ $totalSteps }}
|
||||
</span>
|
||||
<span class="text-xs font-semibold text-green-ink font-mono tabular-nums" data-testid="run-correct-count">
|
||||
{{ $correctAnswers }} richtig
|
||||
</span>
|
||||
</div>
|
||||
<div class="h-2 rounded-full bg-bg-tint overflow-hidden">
|
||||
<div class="h-full rounded-full {{ $c['bar'] }} transition-all duration-500" style="width:{{ $progressPercent }}%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Question --}}
|
||||
@php $q = $this->currentQuestion(); @endphp
|
||||
<div class="bg-bg-soft border border-line rounded-xl p-8 shadow-sm" wire:key="step-{{ $currentStep }}">
|
||||
<div class="text-[11px] uppercase tracking-[0.12em] text-ink-3 font-semibold mb-3">Aufgabe</div>
|
||||
<h2 class="text-2xl font-bold tracking-tight text-ink-1 mb-8 leading-snug" data-testid="run-question">
|
||||
{{ $q['question'] }}
|
||||
</h2>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
@foreach($q['options'] as $i => $opt)
|
||||
@php
|
||||
$isLast = $lastAnswerIndex === $i;
|
||||
$cls = 'group px-5 py-5 rounded-lg border-2 text-ink-1 font-semibold text-base text-left transition-all cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 ' . $c['ring'];
|
||||
$cls .= $lastAnswerIndex === null
|
||||
? ' bg-bg-soft border-line hover:border-primary hover:bg-primary-soft'
|
||||
: ($isLast
|
||||
? ($lastAnswerCorrect ? ' bg-green-soft border-green-ink' : ' bg-rose-soft border-rose-ink')
|
||||
: ' bg-bg-soft border-line opacity-60');
|
||||
@endphp
|
||||
<button
|
||||
type="button"
|
||||
@if($lastAnswerIndex !== null) disabled @else wire:click="submitAnswer({{ $i }})" @endif
|
||||
class="{{ $cls }}"
|
||||
data-testid="run-option-{{ $i }}"
|
||||
>
|
||||
<span class="flex items-center gap-3">
|
||||
<span class="w-7 h-7 rounded-full grid place-items-center text-xs font-bold font-mono tabular-nums shrink-0
|
||||
{{ $isLast ? ($lastAnswerCorrect ? 'bg-green-ink text-white' : 'bg-rose-ink text-white') : 'bg-bg-tint text-ink-2' }}">
|
||||
{{ chr(65 + $i) }}
|
||||
</span>
|
||||
<span>{{ $opt }}</span>
|
||||
@if($isLast && $lastAnswerCorrect)
|
||||
<x-heroicon-s-check-circle class="w-5 h-5 text-green-ink ml-auto shrink-0" />
|
||||
@elseif($isLast)
|
||||
<x-heroicon-s-x-circle class="w-5 h-5 text-rose-ink ml-auto shrink-0" />
|
||||
@endif
|
||||
</span>
|
||||
</button>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
@if($lastAnswerIndex !== null)
|
||||
<div class="mt-6 flex justify-end">
|
||||
<x-btn variant="primary" wire:click="nextStep" data-testid="run-next">
|
||||
@if($currentStep < $totalSteps)
|
||||
Nächste Frage
|
||||
<x-heroicon-o-arrow-right class="w-4 h-4" />
|
||||
@else
|
||||
Aufgabe abschließen
|
||||
<x-heroicon-o-check class="w-4 h-4" />
|
||||
@endif
|
||||
</x-btn>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@else
|
||||
@php
|
||||
$passed = $correctAnswers >= (int) ceil($totalSteps / 2);
|
||||
$percent = $totalSteps > 0 ? (int) round(($correctAnswers / $totalSteps) * 100) : 0;
|
||||
$pointsEarned = $passed ? (int) $task['points'] : 0;
|
||||
@endphp
|
||||
<div class="bg-bg-soft border border-line rounded-xl p-10 shadow-sm text-center" data-testid="run-finished">
|
||||
<div class="w-20 h-20 rounded-full grid place-items-center mx-auto mb-5 {{ $passed ? 'bg-green-soft text-green-ink' : 'bg-amber-soft text-amber-ink' }}">
|
||||
@if($passed)
|
||||
<x-heroicon-s-trophy class="w-10 h-10" />
|
||||
@else
|
||||
<x-heroicon-s-arrow-path class="w-10 h-10" />
|
||||
@endif
|
||||
</div>
|
||||
<h2 class="text-2xl font-bold text-ink-1 mb-2">
|
||||
@if($passed) Super gemacht! @else Fast geschafft @endif
|
||||
</h2>
|
||||
<p class="text-sm text-ink-2 mb-1">
|
||||
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>
|
||||
|
||||
@if($pointsEarned > 0)
|
||||
<div class="bg-amber-soft border border-amber-soft rounded-lg px-4 py-3 inline-flex items-center gap-2 mb-6">
|
||||
<x-heroicon-s-star class="w-5 h-5 text-amber-ink" />
|
||||
<span class="font-mono tabular-nums font-bold text-amber-ink">+{{ $pointsEarned }} Pkt.</span>
|
||||
<span class="text-xs text-amber-ink font-semibold">verdient</span>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="flex gap-2 justify-center">
|
||||
<x-btn wire:click="restart" data-testid="run-restart">
|
||||
<x-heroicon-o-arrow-path class="w-4 h-4" />
|
||||
Nochmal
|
||||
</x-btn>
|
||||
<x-btn variant="primary" as="a" href="{{ route('tasks') }}" wire:navigate>
|
||||
<x-heroicon-o-check class="w-4 h-4" />
|
||||
Fertig — zurück
|
||||
</x-btn>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
</div>
|
||||
|
|
@ -13,6 +13,9 @@ Route::middleware(['auth', 'license'])->group(function () {
|
|||
Route::get('/dashboard', \App\Livewire\Dashboard\Index::class)->name('dashboard');
|
||||
Route::get('/subjects', \App\Livewire\Subjects\Index::class)->name('subjects');
|
||||
Route::get('/tasks', \App\Livewire\Tasks\Index::class)->name('tasks');
|
||||
Route::get('/tasks/{key}/run', \App\Livewire\Tasks\Run::class)
|
||||
->whereAlphaNumeric('key')
|
||||
->name('tasks.run');
|
||||
Route::get('/photo', \App\Livewire\PhotoHelp\Index::class)->name('photo');
|
||||
Route::get('/rewards', \App\Livewire\Rewards\Index::class)->name('rewards');
|
||||
Route::get('/progress', \App\Livewire\Progress\Index::class)->name('progress');
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
|
||||
it('task-questions.php config exists', function () {
|
||||
expect(file_exists(base_path('config/task-questions.php')))->toBeTrue();
|
||||
});
|
||||
|
||||
it('config has buckets for all 5 subject colors', function () {
|
||||
$bank = config('task-questions');
|
||||
foreach (['primary', 'rose', 'violet', 'green', 'amber'] as $color) {
|
||||
expect(array_key_exists($color, $bank))->toBeTrue("Question bank missing color '{$color}'");
|
||||
expect($bank[$color])->toBeArray()->not->toBeEmpty();
|
||||
}
|
||||
});
|
||||
|
||||
it('each question has required keys (question, options, correct)', function () {
|
||||
$bank = config('task-questions');
|
||||
foreach ($bank as $color => $questions) {
|
||||
foreach ($questions as $i => $q) {
|
||||
expect($q)->toHaveKeys(['question', 'options', 'correct']);
|
||||
expect($q['options'])->toHaveCount(4);
|
||||
expect($q['correct'])->toBeInt()->toBeGreaterThanOrEqual(0)->toBeLessThan(4);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('Math (primary) questions differ from German (rose)', function () {
|
||||
$bank = config('task-questions');
|
||||
$mathFirst = $bank['primary'][0]['question'];
|
||||
$germanFirst = $bank['rose'][0]['question'];
|
||||
expect($mathFirst)->not->toBe($germanFirst);
|
||||
});
|
||||
|
|
@ -0,0 +1,130 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Tasks\Run;
|
||||
use App\Models\License;
|
||||
use App\Models\Role;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Livewire;
|
||||
|
||||
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' => 'Run Tester', 'email' => 'run@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->actingAs($this->user);
|
||||
});
|
||||
|
||||
it('route tasks.run registered with key param', function () {
|
||||
expect(\Illuminate\Support\Facades\Route::has('tasks.run'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('GET /tasks/t1/run returns 200 for authenticated user', function () {
|
||||
$this->get(route('tasks.run', ['key' => 't1']))->assertOk();
|
||||
});
|
||||
|
||||
it('GET /tasks/{key}/run with unknown key returns 404', function () {
|
||||
$this->get(route('tasks.run', ['key' => 'nonexistent']))->assertNotFound();
|
||||
});
|
||||
|
||||
it('Run mount: math task (t1, primary) loads MATH questions', function () {
|
||||
$component = Livewire::test(Run::class, ['key' => 't1']);
|
||||
$component->assertSet('totalSteps', 5); // primary bank has 5 questions
|
||||
$component->assertSee('Multiplikation bis 100');
|
||||
$component->assertSee('Was ergibt 12 × 8?');
|
||||
$component->assertDontSee('Welches Wort ist ein Nomen?');
|
||||
});
|
||||
|
||||
it('Run mount: deutsch task (t2, rose) loads GERMAN questions', function () {
|
||||
$component = Livewire::test(Run::class, ['key' => 't2']);
|
||||
$component->assertSee('Welches Wort ist ein Nomen?');
|
||||
$component->assertDontSee('Was ergibt 12 × 8?');
|
||||
});
|
||||
|
||||
it('Run mount: english task (t3, violet) loads ENGLISH questions', function () {
|
||||
$component = Livewire::test(Run::class, ['key' => 't3']);
|
||||
$component->assertSee('past tense of "go"');
|
||||
$component->assertDontSee('Welches Wort ist ein Nomen?');
|
||||
});
|
||||
|
||||
it('Run mount: sachkunde task (t4, green) loads NATURE questions', function () {
|
||||
$component = Livewire::test(Run::class, ['key' => 't4']);
|
||||
$component->assertSee('Verdampfen von Wasser');
|
||||
});
|
||||
|
||||
it('submitAnswer marks correct/incorrect + correctAnswers counter', function () {
|
||||
Livewire::test(Run::class, ['key' => 't1'])
|
||||
->call('submitAnswer', 2) // primary q1: correct = 2 (96)
|
||||
->assertSet('lastAnswerIndex', 2)
|
||||
->assertSet('lastAnswerCorrect', true)
|
||||
->assertSet('correctAnswers', 1);
|
||||
});
|
||||
|
||||
it('submitAnswer with wrong index sets lastAnswerCorrect=false, no point', function () {
|
||||
Livewire::test(Run::class, ['key' => 't1'])
|
||||
->call('submitAnswer', 0)
|
||||
->assertSet('lastAnswerCorrect', false)
|
||||
->assertSet('correctAnswers', 0);
|
||||
});
|
||||
|
||||
it('nextStep advances and clears lastAnswer state', function () {
|
||||
Livewire::test(Run::class, ['key' => 't1'])
|
||||
->call('submitAnswer', 2)
|
||||
->call('nextStep')
|
||||
->assertSet('currentStep', 2)
|
||||
->assertSet('lastAnswerIndex', null)
|
||||
->assertSet('lastAnswerCorrect', null);
|
||||
});
|
||||
|
||||
it('finishing last step sets finished=true', function () {
|
||||
$run = Livewire::test(Run::class, ['key' => 't1']);
|
||||
// primary bank has 5 questions
|
||||
foreach (range(1, 5) as $step) {
|
||||
$run->call('submitAnswer', 0)->call('nextStep');
|
||||
}
|
||||
$run->assertSet('finished', true);
|
||||
});
|
||||
|
||||
it('restart resets all progress', function () {
|
||||
Livewire::test(Run::class, ['key' => 't1'])
|
||||
->call('submitAnswer', 2)
|
||||
->call('nextStep')
|
||||
->call('restart')
|
||||
->assertSet('currentStep', 1)
|
||||
->assertSet('correctAnswers', 0)
|
||||
->assertSet('finished', false)
|
||||
->assertSet('lastAnswerIndex', null);
|
||||
});
|
||||
|
||||
it('startTask in Index redirects to tasks.run route (not modal)', function () {
|
||||
Livewire::test(\App\Livewire\Tasks\Index::class)
|
||||
->call('startTask', 't1')
|
||||
->assertRedirect(route('tasks.run', ['key' => 't1']));
|
||||
});
|
||||
|
||||
it('unauthenticated user redirected to login', function () {
|
||||
auth()->logout();
|
||||
$this->get(route('tasks.run', ['key' => 't1']))->assertRedirect(route('login'));
|
||||
});
|
||||
|
||||
it('Run placeholder view exists with task-run-skeleton testid', function () {
|
||||
$view = Run::placeholder([]);
|
||||
expect($view->getName())->toBe('livewire.tasks.run-placeholder');
|
||||
expect($view->render())->toContain('task-run-skeleton');
|
||||
});
|
||||
|
|
@ -66,18 +66,10 @@ it('startTask action runs and adds key to startedKeys', function () {
|
|||
->assertSet('startedKeys', ['t1']);
|
||||
});
|
||||
|
||||
it('startTask emits browser-side Livewire.dispatch via $this->js() targeting RUNNER modal (not detail)', function () {
|
||||
$tester = Livewire::test(TasksIndex::class)->call('startTask', 't1');
|
||||
|
||||
$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'");
|
||||
// Start button opens RUNNER, not detail
|
||||
expect($allScripts)->toContain('tasks.modals.runner');
|
||||
expect($allScripts)->not->toContain('tasks.modals.detail');
|
||||
expect($allScripts)->toContain('t1');
|
||||
it('startTask redirects to tasks.run page (dedicated quiz, not modal)', function () {
|
||||
Livewire::test(TasksIndex::class)
|
||||
->call('startTask', 't1')
|
||||
->assertRedirect(route('tasks.run', ['key' => 't1']));
|
||||
});
|
||||
|
||||
it('Detail (info) button still opens tasks.modals.detail — separate from Start', function () {
|
||||
|
|
|
|||
Loading…
Reference in New Issue