feat(tasks): semantic slugs + per-slug question banks + Lerni AI-helper
Before:
- Task slug "t1" was generic, meaningless to user and crawlers.
- Question bank was keyed by `color`, so EVERY math task (multiplikation,
geometrie, ...) shared identical questions.
- No way for child / parent to ask AI for an explanation step-by-step.
Now (Workbook + Lerni variant from Übungsseite template):
Semantic slugs in config/tasks.php:
math-multiplikation-bis-100
math-geometrie-flaechen
deutsch-wortarten-nomen-verben
deutsch-gedicht-lernen
englisch-vocab-animals
englisch-past-simple
sachkunde-wasserkreislauf
Each task now has:
slug, title, topic, subject, meta, icon, color, points, status, due
config/task-questions.php re-keyed by `slug` (not color):
Every task gets its OWN question set — math-multiplikation has
multiplication questions, math-geometrie has area/perimeter, deutsch
has wortarten/grammar, etc.
Each question now has 5 keys:
question, options[4], correct, lerni_hint, lerni_steps[]
Lerni AI-helper (right sidebar in Workbook layout):
- Closed by default; "Frag Lerni" button reveals
- Hint box (💡) with short text-explanation
- Numbered Lösungsweg with method-steps (colored badges per subject)
- Shortlink URL `?lerni=1` (parents/teachers can deep-link to a
pre-opened explanation)
- #[Url(as: 'lerni')] hydrates from URL
- Auto-closes on nextStep (fresh question = fresh thinking)
Route updated:
Route::get('/tasks/{slug}/run', \App\Livewire\Tasks\Run::class)
->where('slug', '[a-z0-9-]+') // tighter than whereAlphaNumeric;
// allows hyphens, rejects UPPER
->name('tasks.run')
Tasks/Index::startTask / repeatTask: $slug arg + redirectRoute to
tasks.run with slug.
View livewire/tasks/run.blade.php switched from single column to
2-column grid (max-w-6xl): col-span-2 question card + col-span-1
Lerni aside. Per-subject color theming (focus-ring, progress bar,
step badges, option border on feedback).
Cleanup:
- Removed App\Livewire\Tasks\Modals\Runner (obsolete; page-based now)
- Removed livewire.tasks.modals.runner view
- Removed TasksRunnerModalTest
Tests (TasksRunPageTest 22, TaskQuestionBankTest 6, TasksStartButton 8):
- Semantic slug validation (no t[0-9]+ patterns)
- Per-slug question selection (math vs deutsch vs english vs sachkunde)
- math-multiplikation != math-geometrie (different content same color)
- Lerni-panel renders; lerni-open trigger; toggleLerni reveals steps
- Shortlink contains slug + ?lerni=1
- ?lerni=1 query auto-opens helper
- Route 404 for UPPER chars and unknown slugs
- startTask redirects to tasks.run with slug
Verification:
- 188 tests passed, 0 failed, 0 skipped (1024 assertions)
- npm build OK
- All 8 page routes 302 unauth
- All 7 task slug routes 302 unauth
- tasks/UPPER/run 404 (route regex)
- 7 modal aliases resolve (Runner removed)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
main
parent
cae775ba93
commit
d3f976b18e
|
|
@ -42,26 +42,24 @@ class Index extends Component
|
|||
/** @var array<int,string> */
|
||||
public array $startedKeys = [];
|
||||
|
||||
public function startTask(string $key): void
|
||||
public function startTask(string $slug): void
|
||||
{
|
||||
$task = collect($this->tasks)->firstWhere('key', $key);
|
||||
$task = collect($this->tasks)->firstWhere('slug', $slug);
|
||||
if ($task === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! in_array($key, $this->startedKeys, true)) {
|
||||
$this->startedKeys[] = $key;
|
||||
if (! in_array($slug, $this->startedKeys, true)) {
|
||||
$this->startedKeys[] = $slug;
|
||||
}
|
||||
|
||||
// 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);
|
||||
// Start navigates to the dedicated Run page (per-slug quiz + Lerni AI).
|
||||
$this->redirectRoute('tasks.run', ['slug' => $slug], navigate: true);
|
||||
}
|
||||
|
||||
public function repeatTask(string $key): void
|
||||
public function repeatTask(string $slug): void
|
||||
{
|
||||
$this->startTask($key);
|
||||
$this->startTask($slug);
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
|
|
|
|||
|
|
@ -1,74 +0,0 @@
|
|||
<?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');
|
||||
}
|
||||
}
|
||||
|
|
@ -5,13 +5,14 @@ namespace App\Livewire\Tasks;
|
|||
use Illuminate\Contracts\View\View;
|
||||
use Livewire\Attributes\Lazy;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Url;
|
||||
use Livewire\Component;
|
||||
|
||||
#[Layout('layouts.dashboard', ['active' => 'tasks'])]
|
||||
#[Lazy]
|
||||
class Run extends Component
|
||||
{
|
||||
public string $key = '';
|
||||
public string $slug = '';
|
||||
public array $task = [];
|
||||
public array $questions = [];
|
||||
public int $currentStep = 1;
|
||||
|
|
@ -21,17 +22,21 @@ class Run extends Component
|
|||
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.");
|
||||
/** Lerni AI helper panel — toggled via showLerni; can be shared via URL (?lerni=1). */
|
||||
#[Url(as: 'lerni', keep: false)]
|
||||
public bool $showLerni = false;
|
||||
|
||||
$this->key = $key;
|
||||
public function mount(string $slug): void
|
||||
{
|
||||
$task = $this->findTask($slug);
|
||||
abort_if($task === null, 404, "Task '{$slug}' not found.");
|
||||
|
||||
$this->slug = $slug;
|
||||
$this->task = $task;
|
||||
$this->questions = config("task-questions.{$task['color']}", []);
|
||||
$this->questions = config("task-questions.{$slug}", []);
|
||||
$this->totalSteps = count($this->questions);
|
||||
|
||||
abort_if($this->totalSteps === 0, 404, "No questions configured for color '{$task['color']}'.");
|
||||
abort_if($this->totalSteps === 0, 404, "No questions configured for task '{$slug}'.");
|
||||
}
|
||||
|
||||
public static function placeholder(array $params = []): View
|
||||
|
|
@ -66,6 +71,13 @@ class Run extends Component
|
|||
$this->currentStep++;
|
||||
$this->lastAnswerIndex = null;
|
||||
$this->lastAnswerCorrect = null;
|
||||
// Auto-close Lerni helper when moving to next question
|
||||
$this->showLerni = false;
|
||||
}
|
||||
|
||||
public function toggleLerni(): void
|
||||
{
|
||||
$this->showLerni = ! $this->showLerni;
|
||||
}
|
||||
|
||||
public function restart(): void
|
||||
|
|
@ -75,12 +87,12 @@ class Run extends Component
|
|||
$this->correctAnswers = 0;
|
||||
$this->lastAnswerIndex = null;
|
||||
$this->lastAnswerCorrect = null;
|
||||
$this->showLerni = false;
|
||||
}
|
||||
|
||||
/** Locate task by key — placeholder data (later DB-backed). */
|
||||
protected function findTask(string $key): ?array
|
||||
protected function findTask(string $slug): ?array
|
||||
{
|
||||
return collect(config('tasks', []))->firstWhere('key', $key);
|
||||
return collect(config('tasks', []))->firstWhere('slug', $slug);
|
||||
}
|
||||
|
||||
public function render(): View
|
||||
|
|
|
|||
|
|
@ -1,63 +1,270 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Task Question Bank — keyed by subject `color` token.
|
||||
* Task Question Bank — keyed by task `slug`.
|
||||
*
|
||||
* 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
|
||||
* Each entry: array of question dicts:
|
||||
* - question: prompt
|
||||
* - options: [4 strings]
|
||||
* - correct: int 0..3
|
||||
* - lerni_hint: short step-by-step Lerni explanation (KI-Helper)
|
||||
* - lerni_steps: array of method-steps (KI-Helper "Lösungsweg")
|
||||
*
|
||||
* Subjects:
|
||||
* primary -> Mathematik
|
||||
* rose -> Deutsch
|
||||
* violet -> Englisch
|
||||
* green -> Sachkunde
|
||||
* amber -> Musik / Sport / Philosophie
|
||||
*
|
||||
* Future: replace with DB-backed Question + Answer models, scoped by tenant.
|
||||
* Future: replace with DB-backed Question + Answer models.
|
||||
*/
|
||||
|
||||
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],
|
||||
'math-multiplikation-bis-100' => [
|
||||
[
|
||||
'question' => 'Was ergibt 12 × 8?',
|
||||
'options' => ['64', '88', '96', '102'],
|
||||
'correct' => 2,
|
||||
'lerni_hint' => 'Multipliziere die Einer und Zehner getrennt: 8×2=16 und 8×10=80. Beides zusammen = 96.',
|
||||
'lerni_steps' => ['Zerlege 12 in 10 + 2', '8 × 10 = 80', '8 × 2 = 16', '80 + 16 = 96'],
|
||||
],
|
||||
[
|
||||
'question' => 'Was ergibt 15 × 7?',
|
||||
'options' => ['95', '105', '115', '125'],
|
||||
'correct' => 1,
|
||||
'lerni_hint' => 'Zerlege 15 in 10 + 5. 7×10=70 und 7×5=35. Zusammen = 105.',
|
||||
'lerni_steps' => ['15 = 10 + 5', '7 × 10 = 70', '7 × 5 = 35', '70 + 35 = 105'],
|
||||
],
|
||||
[
|
||||
'question' => 'Was ergibt 9 × 11?',
|
||||
'options' => ['91', '99', '108', '111'],
|
||||
'correct' => 1,
|
||||
'lerni_hint' => '11er-Trick: 9 × 11 = 9 × 10 + 9 × 1 = 90 + 9 = 99.',
|
||||
'lerni_steps' => ['9 × 10 = 90', '9 × 1 = 9', '90 + 9 = 99'],
|
||||
],
|
||||
[
|
||||
'question' => 'Was ergibt 14 × 6?',
|
||||
'options' => ['72', '78', '84', '88'],
|
||||
'correct' => 2,
|
||||
'lerni_hint' => '14 = 10 + 4. 6×10=60, 6×4=24, zusammen = 84.',
|
||||
'lerni_steps' => ['14 = 10 + 4', '6 × 10 = 60', '6 × 4 = 24', '60 + 24 = 84'],
|
||||
],
|
||||
[
|
||||
'question' => 'Was ergibt 144 ÷ 12?',
|
||||
'options' => ['10', '11', '12', '13'],
|
||||
'correct' => 2,
|
||||
'lerni_hint' => 'Welche Zahl × 12 ergibt 144? Die 12 selbst, denn 12 × 12 = 144.',
|
||||
'lerni_steps' => ['Suche x mit x × 12 = 144', 'Probier x = 12', '12 × 12 = 144 ✓'],
|
||||
],
|
||||
],
|
||||
|
||||
'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],
|
||||
'math-geometrie-flaechen' => [
|
||||
[
|
||||
'question' => 'Fläche eines Rechtecks 5cm × 4cm?',
|
||||
'options' => ['9 cm²', '18 cm²', '20 cm²', '25 cm²'],
|
||||
'correct' => 2,
|
||||
'lerni_hint' => 'Fläche Rechteck = Länge × Breite = 5 × 4 = 20 cm².',
|
||||
'lerni_steps' => ['Formel: A = L × B', 'L = 5, B = 4', 'A = 5 × 4 = 20 cm²'],
|
||||
],
|
||||
[
|
||||
'question' => 'Fläche eines Quadrats mit Seitenlänge 6cm?',
|
||||
'options' => ['12 cm²', '24 cm²', '30 cm²', '36 cm²'],
|
||||
'correct' => 3,
|
||||
'lerni_hint' => 'Quadrat: A = Seite × Seite = 6 × 6 = 36 cm².',
|
||||
'lerni_steps' => ['Formel: A = s × s', 's = 6', 'A = 6 × 6 = 36 cm²'],
|
||||
],
|
||||
[
|
||||
'question' => 'Umfang eines Quadrats mit Seitenlänge 5cm?',
|
||||
'options' => ['10 cm', '15 cm', '20 cm', '25 cm'],
|
||||
'correct' => 2,
|
||||
'lerni_hint' => 'Umfang = 4 × Seite = 4 × 5 = 20 cm.',
|
||||
'lerni_steps' => ['U = 4 × s', 's = 5', 'U = 4 × 5 = 20 cm'],
|
||||
],
|
||||
[
|
||||
'question' => 'Wie viele Ecken hat ein Dreieck?',
|
||||
'options' => ['2', '3', '4', '5'],
|
||||
'correct' => 1,
|
||||
'lerni_hint' => 'Ein Dreieck hat per Definition 3 Ecken und 3 Seiten.',
|
||||
'lerni_steps' => ['Dreieck = "tri" (drei) + "Eck"', '3 Seiten verbinden = 3 Ecken'],
|
||||
],
|
||||
[
|
||||
'question' => 'Welche Form hat 4 gleich lange Seiten?',
|
||||
'options' => ['Dreieck', 'Quadrat', 'Kreis', 'Trapez'],
|
||||
'correct' => 1,
|
||||
'lerni_hint' => 'Quadrat — alle 4 Seiten sind gleich lang, alle Ecken 90°.',
|
||||
'lerni_steps' => ['4 gleiche Seiten ⇒ Quadrat oder Raute', '90° Ecken ⇒ Quadrat'],
|
||||
],
|
||||
],
|
||||
|
||||
'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],
|
||||
'deutsch-wortarten-nomen-verben' => [
|
||||
[
|
||||
'question' => 'Welches Wort ist ein Nomen?',
|
||||
'options' => ['laufen', 'schnell', 'Hund', 'rot'],
|
||||
'correct' => 2,
|
||||
'lerni_hint' => 'Nomen sind Dinge, Personen oder Tiere. "Hund" ist ein Tier — also ein Nomen.',
|
||||
'lerni_steps' => ['Nomen = Namenwort', 'Großgeschrieben', 'Beispiele: Hund, Tisch, Lina'],
|
||||
],
|
||||
[
|
||||
'question' => 'Welches Wort ist ein Verb?',
|
||||
'options' => ['Tisch', 'springen', 'grün', 'Lehrer'],
|
||||
'correct' => 1,
|
||||
'lerni_hint' => 'Verben sind Tätigkeitswörter. "springen" beschreibt eine Tätigkeit.',
|
||||
'lerni_steps' => ['Verb = Tätigkeitswort', 'Antwort auf "Was tut jemand?"', 'springen = Tätigkeit ✓'],
|
||||
],
|
||||
[
|
||||
'question' => 'Welches Wort ist ein Adjektiv?',
|
||||
'options' => ['Schule', 'lesen', 'glücklich', 'gehen'],
|
||||
'correct' => 2,
|
||||
'lerni_hint' => 'Adjektive beschreiben Eigenschaften. "glücklich" beschreibt wie jemand sich fühlt.',
|
||||
'lerni_steps' => ['Adjektiv = Eigenschaftswort', 'Antwort auf "Wie ist etwas?"', 'glücklich beschreibt Gefühl ✓'],
|
||||
],
|
||||
[
|
||||
'question' => 'Wie wird "Buch" im Plural geschrieben?',
|
||||
'options' => ['Buchs', 'Buchen', 'Bücher', 'Buches'],
|
||||
'correct' => 2,
|
||||
'lerni_hint' => 'Plural von Buch ist "Bücher" — mit Umlaut ü und -er-Endung.',
|
||||
'lerni_steps' => ['Buch → Bücher', 'Umlaut: u → ü', 'Endung: + er'],
|
||||
],
|
||||
[
|
||||
'question' => 'Welcher Artikel passt zu "Sonne"?',
|
||||
'options' => ['der', 'die', 'das', 'den'],
|
||||
'correct' => 1,
|
||||
'lerni_hint' => '"Die Sonne" — Sonne ist im Deutschen weiblich (feminin).',
|
||||
'lerni_steps' => ['Genus prüfen: m / f / n', 'Sonne = feminin', 'Artikel feminin Nominativ: die'],
|
||||
],
|
||||
],
|
||||
|
||||
'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],
|
||||
'deutsch-gedicht-lernen' => [
|
||||
[
|
||||
'question' => 'Was reimt sich auf "Haus"?',
|
||||
'options' => ['Buch', 'Maus', 'Tisch', 'Auto'],
|
||||
'correct' => 1,
|
||||
'lerni_hint' => 'Wörter reimen sich, wenn sie ähnlich klingen am Ende. Haus — Maus reimt sich (-aus).',
|
||||
'lerni_steps' => ['Reim: gleicher Endklang', 'Haus endet auf "-aus"', 'Maus endet auf "-aus" ✓'],
|
||||
],
|
||||
[
|
||||
'question' => 'Wie nennt man Sätze, die reimen?',
|
||||
'options' => ['Prosa', 'Drama', 'Vers', 'Roman'],
|
||||
'correct' => 2,
|
||||
'lerni_hint' => 'Reimende Zeilen in Gedichten heißen Verse.',
|
||||
'lerni_steps' => ['Vers = Gedichtzeile', 'Mehrere Verse = Strophe'],
|
||||
],
|
||||
[
|
||||
'question' => 'Was sagt man oft am Anfang eines Gedichts?',
|
||||
'options' => ['Tschüss', 'Es war einmal', 'Hallo', 'Auf Wiedersehen'],
|
||||
'correct' => 1,
|
||||
'lerni_hint' => '"Es war einmal" leitet oft Geschichten und Gedichte ein.',
|
||||
'lerni_steps' => ['Erzähl-Einleitung', 'Klassisch für Märchen und Gedichte'],
|
||||
],
|
||||
[
|
||||
'question' => 'Wie lernt man ein Gedicht am besten?',
|
||||
'options' => ['Nur einmal lesen', 'Auswendig laut sprechen', 'Schnell überfliegen', 'Nicht lernen'],
|
||||
'correct' => 1,
|
||||
'lerni_hint' => 'Gedichte lernt man am besten, wenn man sie laut spricht — mehrfach.',
|
||||
'lerni_steps' => ['Lies langsam vor', 'Wiederhole laut', 'Verse einzeln üben', 'Ganz aufsagen'],
|
||||
],
|
||||
],
|
||||
|
||||
'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],
|
||||
'englisch-vocab-animals' => [
|
||||
[
|
||||
'question' => 'Translate "Katze" to English:',
|
||||
'options' => ['dog', 'cat', 'cow', 'mouse'],
|
||||
'correct' => 1,
|
||||
'lerni_hint' => 'Katze = cat. Eselsbrücke: K-atze → c-at, beide drei Buchstaben mit -at-.',
|
||||
'lerni_steps' => ['Listen: "cat" /kæt/', 'Sound: K-Anfang wie Katze', 'cat ✓'],
|
||||
],
|
||||
[
|
||||
'question' => 'Translate "Hund" to English:',
|
||||
'options' => ['cat', 'bird', 'dog', 'fish'],
|
||||
'correct' => 2,
|
||||
'lerni_hint' => 'Hund = dog. "Dog" hat 3 Buchstaben, wie ein kurzes Bellen.',
|
||||
'lerni_steps' => ['dog /dɒɡ/', 'kurzes Wort, kurzer Hund-Laut'],
|
||||
],
|
||||
[
|
||||
'question' => 'Translate "Maus" to English:',
|
||||
'options' => ['mouse', 'horse', 'house', 'cheese'],
|
||||
'correct' => 0,
|
||||
'lerni_hint' => 'Maus = mouse. Ähnlich klingt es: "Maus" → "mouse".',
|
||||
'lerni_steps' => ['Maus → mouse', 'au → ou', 's bleibt s'],
|
||||
],
|
||||
[
|
||||
'question' => 'What is the plural of "mouse"?',
|
||||
'options' => ['mouses', 'mice', 'mouses', 'mouse'],
|
||||
'correct' => 1,
|
||||
'lerni_hint' => 'Unregelmäßiger Plural: mouse → mice. NICHT "mouses".',
|
||||
'lerni_steps' => ['mouse = Einzahl', 'mice = Mehrzahl', 'unregelmäßig: kein "-s"'],
|
||||
],
|
||||
[
|
||||
'question' => 'Translate "Vogel" to English:',
|
||||
'options' => ['fish', 'bird', 'cat', 'frog'],
|
||||
'correct' => 1,
|
||||
'lerni_hint' => 'Vogel = bird. Eselsbrücke: B-ird wie B-aum, wo Vögel sitzen.',
|
||||
'lerni_steps' => ['bird /bɜːd/', 'kurzes Wort, weicher Klang'],
|
||||
],
|
||||
],
|
||||
|
||||
'englisch-past-simple' => [
|
||||
[
|
||||
'question' => 'What is the past tense of "go"?',
|
||||
'options' => ['goed', 'goes', 'gone', 'went'],
|
||||
'correct' => 3,
|
||||
'lerni_hint' => 'go → went (irregular). Past tense komplett anders als die Grundform.',
|
||||
'lerni_steps' => ['go = Grundform', 'unregelmäßig: kein -ed', 'past simple: went'],
|
||||
],
|
||||
[
|
||||
'question' => 'Past tense of "play"?',
|
||||
'options' => ['played', 'plaied', 'play', 'plays'],
|
||||
'correct' => 0,
|
||||
'lerni_hint' => 'play ist regelmäßig: einfach -ed dranhängen → played.',
|
||||
'lerni_steps' => ['Regelmäßig + ed', 'play + ed = played'],
|
||||
],
|
||||
[
|
||||
'question' => 'Pick the correct past: "Yesterday I ___ to school."',
|
||||
'options' => ['go', 'goes', 'went', 'gone'],
|
||||
'correct' => 2,
|
||||
'lerni_hint' => '"Yesterday" = gestern → Past Simple. go → went.',
|
||||
'lerni_steps' => ['Yesterday = past', 'go past = went', 'I went to school ✓'],
|
||||
],
|
||||
[
|
||||
'question' => 'Past tense of "see"?',
|
||||
'options' => ['seed', 'saw', 'sees', 'seen'],
|
||||
'correct' => 1,
|
||||
'lerni_hint' => 'see → saw (irregular). "Seen" ist Past Participle, nicht Past Simple.',
|
||||
'lerni_steps' => ['see (Grundform)', 'saw (past simple)', 'seen (past participle)'],
|
||||
],
|
||||
],
|
||||
|
||||
'sachkunde-wasserkreislauf' => [
|
||||
[
|
||||
'question' => 'Was passiert beim Verdampfen von Wasser?',
|
||||
'options' => ['Es wird flüssig', 'Es wird zu Dampf', 'Es gefriert', 'Es verschwindet'],
|
||||
'correct' => 1,
|
||||
'lerni_hint' => 'Wasser verdunstet bei Wärme: flüssig → gasförmig (Dampf).',
|
||||
'lerni_steps' => ['Sonne erwärmt Wasser', 'Wasser steigt als Dampf auf', 'Dampf = gasförmig'],
|
||||
],
|
||||
[
|
||||
'question' => 'Wie heißt der Vorgang, wenn Dampf zu Wassertropfen wird?',
|
||||
'options' => ['Verdampfung', 'Kondensation', 'Versickerung', 'Niederschlag'],
|
||||
'correct' => 1,
|
||||
'lerni_hint' => 'Kondensation = Gas → Flüssigkeit (Dampf wird zu Wolken/Tröpfchen).',
|
||||
'lerni_steps' => ['Dampf kühlt ab', 'wird zu kleinen Tropfen', 'Tropfen = Kondensation'],
|
||||
],
|
||||
[
|
||||
'question' => 'Was passiert bei Niederschlag?',
|
||||
'options' => ['Wasser steigt auf', 'Wasser fällt vom Himmel', 'Wasser gefriert', 'Wasser verdampft'],
|
||||
'correct' => 1,
|
||||
'lerni_hint' => 'Niederschlag = Regen oder Schnee, Wasser fällt aus Wolken zur Erde.',
|
||||
'lerni_steps' => ['Wolken werden schwer', 'Tropfen fallen', 'als Regen oder Schnee'],
|
||||
],
|
||||
[
|
||||
'question' => 'Wohin fließt Regenwasser meistens?',
|
||||
'options' => ['In den Himmel', 'In Flüsse und Seen', 'In die Sonne', 'Ins Weltall'],
|
||||
'correct' => 1,
|
||||
'lerni_hint' => 'Regenwasser fließt zu Bächen, Flüssen, Seen und schließlich ins Meer.',
|
||||
'lerni_steps' => ['Regen fällt', 'sammelt sich in Bächen', 'Bach → Fluss → See → Meer'],
|
||||
],
|
||||
[
|
||||
'question' => 'Wie viele Phasen hat der Wasserkreislauf?',
|
||||
'options' => ['2', '3', '4', '5'],
|
||||
'correct' => 2,
|
||||
'lerni_hint' => 'Vier Phasen: Verdunstung, Kondensation, Niederschlag, Versickerung/Abfluss.',
|
||||
'lerni_steps' => ['1. Verdunstung', '2. Kondensation', '3. Niederschlag', '4. Abfluss'],
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
|
|
|
|||
|
|
@ -1,21 +1,86 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Placeholder task list — keyed by `key`. Each task has:
|
||||
* key, title, meta, icon, color, points, status, due
|
||||
* Placeholder task list — keyed by `slug` (semantic, URL-safe).
|
||||
*
|
||||
* `color` ties into config/task-questions.php for the quiz bank used by
|
||||
* the Tasks\Run page.
|
||||
* Each task:
|
||||
* slug — unique, [a-z0-9-]+ (URL segment)
|
||||
* title — human-readable
|
||||
* topic — short category (e.g. "Multiplikation", "Wortarten")
|
||||
* meta — display string (subject · count · time)
|
||||
* icon — single-char unicode for color tile
|
||||
* color — design-token color (primary|rose|violet|green|amber)
|
||||
* subject — Mathematik / Deutsch / Englisch / Sachkunde / Musik
|
||||
* points — reward on success
|
||||
* status — open | done
|
||||
* due — display string
|
||||
*
|
||||
* Future: replace with DB-backed Task model scoped by tenant + child + curriculum.
|
||||
* Quizzes for each slug live in config/task-questions.php under the same slug.
|
||||
* Future: DB-backed Task + TaskItem models scoped by tenant + 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'],
|
||||
[
|
||||
'slug' => 'math-multiplikation-bis-100',
|
||||
'title' => 'Multiplikation bis 100',
|
||||
'topic' => 'Multiplikation',
|
||||
'subject' => 'Mathematik',
|
||||
'meta' => 'Mathematik · 5 Aufgaben · 10 Min.',
|
||||
'icon' => '∑', 'color' => 'primary',
|
||||
'points' => 120, 'status' => 'open', 'due' => 'Heute',
|
||||
],
|
||||
[
|
||||
'slug' => 'math-geometrie-flaechen',
|
||||
'title' => 'Geometrie: Flächen',
|
||||
'topic' => 'Geometrie',
|
||||
'subject' => 'Mathematik',
|
||||
'meta' => 'Mathematik · 5 Aufgaben · 15 Min.',
|
||||
'icon' => '∑', 'color' => 'primary',
|
||||
'points' => 150, 'status' => 'open', 'due' => 'Morgen',
|
||||
],
|
||||
[
|
||||
'slug' => 'deutsch-wortarten-nomen-verben',
|
||||
'title' => 'Wortarten: Nomen & Verben',
|
||||
'topic' => 'Wortarten',
|
||||
'subject' => 'Deutsch',
|
||||
'meta' => 'Deutsch · 5 Aufgaben · 10 Min.',
|
||||
'icon' => 'A', 'color' => 'rose',
|
||||
'points' => 80, 'status' => 'open', 'due' => 'Heute',
|
||||
],
|
||||
[
|
||||
'slug' => 'deutsch-gedicht-lernen',
|
||||
'title' => 'Gedicht lernen',
|
||||
'topic' => 'Lesen',
|
||||
'subject' => 'Deutsch',
|
||||
'meta' => 'Deutsch · 4 Aufgaben · 15 Min.',
|
||||
'icon' => 'A', 'color' => 'rose',
|
||||
'points' => 90, 'status' => 'done', 'due' => 'Gestern',
|
||||
],
|
||||
[
|
||||
'slug' => 'englisch-vocab-animals',
|
||||
'title' => 'Vocab Review: Animals',
|
||||
'topic' => 'Vokabeln',
|
||||
'subject' => 'Englisch',
|
||||
'meta' => 'Englisch · 5 Aufgaben · 8 Min.',
|
||||
'icon' => 'E', 'color' => 'violet',
|
||||
'points' => 60, 'status' => 'open', 'due' => 'Heute',
|
||||
],
|
||||
[
|
||||
'slug' => 'englisch-past-simple',
|
||||
'title' => 'Past Simple Übung',
|
||||
'topic' => 'Grammar',
|
||||
'subject' => 'Englisch',
|
||||
'meta' => 'Englisch · 4 Aufgaben · 10 Min.',
|
||||
'icon' => 'E', 'color' => 'violet',
|
||||
'points' => 70, 'status' => 'done', 'due' => 'Gestern',
|
||||
],
|
||||
[
|
||||
'slug' => 'sachkunde-wasserkreislauf',
|
||||
'title' => 'Kreislauf des Wassers',
|
||||
'topic' => 'Natur',
|
||||
'subject' => 'Sachkunde',
|
||||
'meta' => 'Sachkunde · 5 Aufgaben · 12 Min.',
|
||||
'icon' => '🌱', 'color' => 'green',
|
||||
'points' => 100, 'status' => 'open', 'due' => 'Heute',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@
|
|||
@foreach($this->filteredTasks as $t)
|
||||
@php $c = $colorMap[$t['color']]; @endphp
|
||||
<div
|
||||
wire:key="task-{{ $t['key'] }}"
|
||||
wire:key="task-{{ $t['slug'] }}"
|
||||
class="group grid grid-cols-[44px_1fr_auto] gap-3 items-center py-3 border-t border-line first:border-t-0"
|
||||
data-testid="task-row"
|
||||
>
|
||||
|
|
@ -98,13 +98,13 @@
|
|||
</button>
|
||||
@if($t['status'] === 'open')
|
||||
<x-btn variant="primary" size="sm"
|
||||
wire:click="startTask('{{ $t['key'] }}')"
|
||||
wire:click="startTask('{{ $t['slug'] }}')"
|
||||
data-testid="task-start-btn">
|
||||
Starten
|
||||
</x-btn>
|
||||
@else
|
||||
<x-btn size="sm"
|
||||
wire:click="repeatTask('{{ $t['key'] }}')"
|
||||
wire:click="repeatTask('{{ $t['slug'] }}')"
|
||||
data-testid="task-repeat-btn">
|
||||
Wiederholen
|
||||
</x-btn>
|
||||
|
|
|
|||
|
|
@ -1,98 +0,0 @@
|
|||
@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>
|
||||
|
|
@ -1,16 +1,17 @@
|
|||
@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'],
|
||||
'primary' => ['bg' => 'bg-primary-soft', 'ink' => 'text-primary-ink', 'bar' => 'bg-primary-ink', 'ring' => 'focus-visible:ring-primary', 'btn' => 'bg-primary-ink'],
|
||||
'rose' => ['bg' => 'bg-rose-soft', 'ink' => 'text-rose-ink', 'bar' => 'bg-rose-ink', 'ring' => 'focus-visible:ring-rose-ink', 'btn' => 'bg-rose-ink'],
|
||||
'violet' => ['bg' => 'bg-violet-soft', 'ink' => 'text-violet-ink', 'bar' => 'bg-violet-ink', 'ring' => 'focus-visible:ring-violet-ink','btn' => 'bg-violet-ink'],
|
||||
'green' => ['bg' => 'bg-green-soft', 'ink' => 'text-green-ink', 'bar' => 'bg-green-ink', 'ring' => 'focus-visible:ring-green-ink', 'btn' => 'bg-green-ink'],
|
||||
'amber' => ['bg' => 'bg-amber-soft', 'ink' => 'text-amber-ink', 'bar' => 'bg-amber-ink', 'ring' => 'focus-visible:ring-amber-ink', 'btn' => 'bg-amber-ink'],
|
||||
];
|
||||
$c = $colorMap[$task['color']] ?? $colorMap['primary'];
|
||||
$progressPercent = $totalSteps > 0 ? (int) round(($currentStep / $totalSteps) * 100) : 0;
|
||||
$q = $finished ? null : $this->currentQuestion();
|
||||
@endphp
|
||||
|
||||
<div class="flex flex-col gap-6 max-w-3xl mx-auto w-full" data-testid="task-run-page">
|
||||
<div class="flex flex-col gap-6 max-w-6xl mx-auto w-full" data-testid="task-run-page">
|
||||
|
||||
{{-- Breadcrumb / back nav --}}
|
||||
<div class="flex items-center justify-between">
|
||||
|
|
@ -18,7 +19,10 @@
|
|||
<x-heroicon-o-arrow-left class="w-4 h-4" />
|
||||
Zurück zu Aufgaben
|
||||
</a>
|
||||
<x-pill>{{ $task['meta'] ?? '' }}</x-pill>
|
||||
<div class="flex items-center gap-2">
|
||||
<x-pill>{{ $task['subject'] ?? '' }}</x-pill>
|
||||
<x-pill variant="primary">{{ $task['topic'] ?? '' }}</x-pill>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Task header --}}
|
||||
|
|
@ -28,7 +32,7 @@
|
|||
</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>
|
||||
<h1 class="font-bold text-xl text-ink-1 leading-tight truncate" data-testid="run-title">{{ $task['title'] }}</h1>
|
||||
</div>
|
||||
<div class="text-right shrink-0">
|
||||
<div class="text-[10px] uppercase tracking-wider text-ink-3 font-semibold">Belohnung</div>
|
||||
|
|
@ -52,9 +56,11 @@
|
|||
</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 }}">
|
||||
{{-- Workbook: Question left (2 cols), Lerni AI helper right --}}
|
||||
<div class="grid grid-cols-3 gap-5">
|
||||
|
||||
{{-- Question card (col-span 2) --}}
|
||||
<div class="col-span-2 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'] }}
|
||||
|
|
@ -107,13 +113,75 @@
|
|||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Lerni AI-Helper sidebar --}}
|
||||
<aside class="bg-bg-soft border border-line rounded-xl shadow-sm overflow-hidden" data-testid="lerni-panel">
|
||||
<header class="px-5 py-4 border-b border-line bg-bg-tint">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<div class="w-9 h-9 rounded-lg bg-gradient-to-br from-primary-ink to-violet-ink text-white grid place-items-center font-bold shrink-0">
|
||||
<x-heroicon-s-sparkles class="w-5 h-5" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="font-bold text-sm text-ink-1">Lerni</div>
|
||||
<div class="text-[10px] uppercase tracking-wider text-ink-3 font-semibold">KI-Helfer</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if(! $showLerni)
|
||||
<div class="p-5 text-center">
|
||||
<p class="text-sm text-ink-2 mb-4 leading-relaxed">
|
||||
Steckst du fest? Ich erkläre dir Schritt für Schritt, wie du diese Aufgabe lösen kannst.
|
||||
</p>
|
||||
<x-btn variant="primary" wire:click="toggleLerni" class="w-full justify-center" data-testid="lerni-open">
|
||||
<x-heroicon-o-sparkles class="w-4 h-4" />
|
||||
Frag Lerni
|
||||
</x-btn>
|
||||
</div>
|
||||
@else
|
||||
<div class="p-5 flex flex-col gap-4">
|
||||
<div class="bg-primary-soft border border-primary-soft rounded-lg p-4">
|
||||
<div class="text-[10px] uppercase tracking-wider text-primary-ink font-bold mb-1">💡 Tipp</div>
|
||||
<p class="text-sm text-ink-1 leading-relaxed" data-testid="lerni-hint">{{ $q['lerni_hint'] ?? 'Versuch es mit dem Lösungsweg unten.' }}</p>
|
||||
</div>
|
||||
|
||||
@if(! empty($q['lerni_steps']))
|
||||
<div>
|
||||
<div class="text-[10px] uppercase tracking-wider text-ink-3 font-bold mb-2">Lösungsweg</div>
|
||||
<ol class="flex flex-col gap-2" data-testid="lerni-steps">
|
||||
@foreach($q['lerni_steps'] as $i => $step)
|
||||
<li class="flex gap-2.5 items-start">
|
||||
<span class="w-5 h-5 rounded-full {{ $c['btn'] }} text-white grid place-items-center text-[10px] font-bold font-mono shrink-0 mt-0.5">{{ $i + 1 }}</span>
|
||||
<span class="text-sm text-ink-1 leading-snug">{{ $step }}</span>
|
||||
</li>
|
||||
@endforeach
|
||||
</ol>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<button type="button" wire:click="toggleLerni"
|
||||
class="text-xs text-ink-3 hover:text-ink-1 underline self-start transition-colors"
|
||||
data-testid="lerni-close">
|
||||
Lerni schließen
|
||||
</button>
|
||||
|
||||
{{-- Shortlink share (for parents/teachers) --}}
|
||||
<div class="mt-2 pt-3 border-t border-line">
|
||||
<div class="text-[10px] uppercase tracking-wider text-ink-3 font-bold mb-1.5">Shortlink</div>
|
||||
<code class="font-mono text-[11px] text-ink-2 break-all" data-testid="lerni-shortlink">{{ route('tasks.run', ['slug' => $slug]) }}?lerni=1</code>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</aside>
|
||||
|
||||
</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="bg-bg-soft border border-line rounded-xl p-10 shadow-sm text-center max-w-2xl mx-auto" 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" />
|
||||
|
|
|
|||
|
|
@ -13,8 +13,8 @@ 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')
|
||||
Route::get('/tasks/{slug}/run', \App\Livewire\Tasks\Run::class)
|
||||
->where('slug', '[a-z0-9-]+')
|
||||
->name('tasks.run');
|
||||
Route::get('/photo', \App\Livewire\PhotoHelp\Index::class)->name('photo');
|
||||
Route::get('/rewards', \App\Livewire\Rewards\Index::class)->name('rewards');
|
||||
|
|
|
|||
|
|
@ -4,28 +4,44 @@ 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 () {
|
||||
it('config is keyed by task slug (semantic, not by color)', function () {
|
||||
$bank = config('task-questions');
|
||||
// Should NOT have keys 'primary', 'rose', etc. directly
|
||||
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();
|
||||
expect(array_key_exists($color, $bank))->toBeFalse("Bank should be slug-keyed, not color-keyed; found '{$color}'");
|
||||
}
|
||||
// Should have task slugs
|
||||
expect(array_key_exists('math-multiplikation-bis-100', $bank))->toBeTrue();
|
||||
expect(array_key_exists('deutsch-wortarten-nomen-verben', $bank))->toBeTrue();
|
||||
});
|
||||
|
||||
it('each question has required keys (question, options, correct)', function () {
|
||||
it('each question has required keys (question, options, correct, lerni_hint, lerni_steps)', function () {
|
||||
$bank = config('task-questions');
|
||||
foreach ($bank as $color => $questions) {
|
||||
foreach ($bank as $slug => $questions) {
|
||||
foreach ($questions as $i => $q) {
|
||||
expect($q)->toHaveKeys(['question', 'options', 'correct']);
|
||||
expect($q)->toHaveKeys(['question', 'options', 'correct', 'lerni_hint', 'lerni_steps']);
|
||||
expect($q['options'])->toHaveCount(4);
|
||||
expect($q['correct'])->toBeInt()->toBeGreaterThanOrEqual(0)->toBeLessThan(4);
|
||||
expect($q['lerni_steps'])->toBeArray()->not->toBeEmpty();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
it('each task slug in config/tasks.php has a matching question bank', function () {
|
||||
foreach (config('tasks', []) as $t) {
|
||||
$bank = config("task-questions.{$t['slug']}");
|
||||
expect($bank)->toBeArray()->not->toBeEmpty("Task slug '{$t['slug']}' has no question bank");
|
||||
}
|
||||
});
|
||||
|
||||
it('different task slugs return different questions', function () {
|
||||
$math = config('task-questions.math-multiplikation-bis-100');
|
||||
$german = config('task-questions.deutsch-wortarten-nomen-verben');
|
||||
expect($math[0]['question'])->not->toBe($german[0]['question']);
|
||||
});
|
||||
|
||||
it('math-geometrie and math-multiplikation share color but have different questions', function () {
|
||||
$mult = config('task-questions.math-multiplikation-bis-100');
|
||||
$geom = config('task-questions.math-geometrie-flaechen');
|
||||
expect($mult[0]['question'])->not->toBe($geom[0]['question']);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -31,96 +31,146 @@ beforeEach(function () {
|
|||
$this->actingAs($this->user);
|
||||
});
|
||||
|
||||
it('route tasks.run registered with key param', function () {
|
||||
it('route tasks.run registered with slug 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/{slug}/run returns 200 for valid slug', function () {
|
||||
$this->get(route('tasks.run', ['slug' => 'math-multiplikation-bis-100']))->assertOk();
|
||||
});
|
||||
|
||||
it('GET /tasks/{key}/run with unknown key returns 404', function () {
|
||||
$this->get(route('tasks.run', ['key' => 'nonexistent']))->assertNotFound();
|
||||
it('GET /tasks/{slug}/run with unknown slug returns 404', function () {
|
||||
$this->get(route('tasks.run', ['slug' => 'nonexistent-slug']))->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
|
||||
it('GET /tasks/{slug}/run with bogus URL chars returns 404', function () {
|
||||
// Route regex [a-z0-9-]+ filters injection attempts
|
||||
$this->get('/tasks/UPPER_CASE/run')->assertNotFound();
|
||||
});
|
||||
|
||||
it('Run mount: math slug loads MATH questions (not generic)', function () {
|
||||
$component = Livewire::test(Run::class, ['slug' => 'math-multiplikation-bis-100']);
|
||||
$component->assertSet('totalSteps', 5);
|
||||
$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']);
|
||||
it('Run mount: deutsch slug loads GERMAN questions', function () {
|
||||
$component = Livewire::test(Run::class, ['slug' => 'deutsch-wortarten-nomen-verben']);
|
||||
$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: english vocab loads ENGLISH questions', function () {
|
||||
$component = Livewire::test(Run::class, ['slug' => 'englisch-vocab-animals']);
|
||||
$component->assertSee('Translate "Katze"');
|
||||
});
|
||||
|
||||
it('Run mount: sachkunde task (t4, green) loads NATURE questions', function () {
|
||||
$component = Livewire::test(Run::class, ['key' => 't4']);
|
||||
it('Run mount: sachkunde wasserkreislauf loads NATURE questions', function () {
|
||||
$component = Livewire::test(Run::class, ['slug' => 'sachkunde-wasserkreislauf']);
|
||||
$component->assertSee('Verdampfen von Wasser');
|
||||
});
|
||||
|
||||
it('math multiplikation task shows multiplication question', function () {
|
||||
Livewire::test(Run::class, ['slug' => 'math-multiplikation-bis-100'])
|
||||
->assertSee('Was ergibt 12');
|
||||
});
|
||||
|
||||
it('math geometry task shows geometry question (not multiplikation)', function () {
|
||||
Livewire::withoutLazyLoading();
|
||||
Livewire::test(Run::class, ['slug' => 'math-geometrie-flaechen'])
|
||||
->assertSee('Rechtecks')
|
||||
->assertDontSee('Was ergibt 12');
|
||||
});
|
||||
|
||||
it('submitAnswer marks correct/incorrect + correctAnswers counter', function () {
|
||||
Livewire::test(Run::class, ['key' => 't1'])
|
||||
->call('submitAnswer', 2) // primary q1: correct = 2 (96)
|
||||
Livewire::test(Run::class, ['slug' => 'math-multiplikation-bis-100'])
|
||||
->call('submitAnswer', 2) // q1 correct = index 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'])
|
||||
it('submitAnswer wrong index: no point', function () {
|
||||
Livewire::test(Run::class, ['slug' => 'math-multiplikation-bis-100'])
|
||||
->call('submitAnswer', 0)
|
||||
->assertSet('lastAnswerCorrect', false)
|
||||
->assertSet('correctAnswers', 0);
|
||||
});
|
||||
|
||||
it('nextStep advances and clears lastAnswer state', function () {
|
||||
Livewire::test(Run::class, ['key' => 't1'])
|
||||
it('nextStep advances + clears feedback + closes Lerni helper', function () {
|
||||
Livewire::test(Run::class, ['slug' => 'math-multiplikation-bis-100'])
|
||||
->call('toggleLerni')
|
||||
->assertSet('showLerni', true)
|
||||
->call('submitAnswer', 2)
|
||||
->call('nextStep')
|
||||
->assertSet('currentStep', 2)
|
||||
->assertSet('lastAnswerIndex', null)
|
||||
->assertSet('lastAnswerCorrect', null);
|
||||
->assertSet('showLerni', false);
|
||||
});
|
||||
|
||||
it('finishing last step sets finished=true', function () {
|
||||
$run = Livewire::test(Run::class, ['key' => 't1']);
|
||||
// primary bank has 5 questions
|
||||
it('finishing all 5 steps sets finished=true', function () {
|
||||
$run = Livewire::test(Run::class, ['slug' => 'math-multiplikation-bis-100']);
|
||||
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'])
|
||||
it('restart resets all progress including Lerni state', function () {
|
||||
Livewire::test(Run::class, ['slug' => 'math-multiplikation-bis-100'])
|
||||
->call('toggleLerni')
|
||||
->call('submitAnswer', 2)
|
||||
->call('nextStep')
|
||||
->call('restart')
|
||||
->assertSet('currentStep', 1)
|
||||
->assertSet('correctAnswers', 0)
|
||||
->assertSet('finished', false)
|
||||
->assertSet('lastAnswerIndex', null);
|
||||
->assertSet('lastAnswerIndex', null)
|
||||
->assertSet('showLerni', false);
|
||||
});
|
||||
|
||||
it('startTask in Index redirects to tasks.run route (not modal)', function () {
|
||||
it('startTask in Index redirects to tasks.run with slug', function () {
|
||||
Livewire::test(\App\Livewire\Tasks\Index::class)
|
||||
->call('startTask', 't1')
|
||||
->assertRedirect(route('tasks.run', ['key' => 't1']));
|
||||
->call('startTask', 'math-multiplikation-bis-100')
|
||||
->assertRedirect(route('tasks.run', ['slug' => 'math-multiplikation-bis-100']));
|
||||
});
|
||||
|
||||
it('unauthenticated user redirected to login', function () {
|
||||
auth()->logout();
|
||||
$this->get(route('tasks.run', ['key' => 't1']))->assertRedirect(route('login'));
|
||||
$this->get(route('tasks.run', ['slug' => 'math-multiplikation-bis-100']))->assertRedirect(route('login'));
|
||||
});
|
||||
|
||||
it('Lerni panel renders in HTML', function () {
|
||||
$html = Livewire::test(Run::class, ['slug' => 'math-multiplikation-bis-100'])->html();
|
||||
expect($html)->toContain('data-testid="lerni-panel"');
|
||||
expect($html)->toContain('Lerni');
|
||||
expect($html)->toContain('data-testid="lerni-open"');
|
||||
});
|
||||
|
||||
it('toggleLerni reveals lerni-hint + lerni-steps', function () {
|
||||
$html = Livewire::test(Run::class, ['slug' => 'math-multiplikation-bis-100'])
|
||||
->call('toggleLerni')
|
||||
->html();
|
||||
expect($html)->toContain('data-testid="lerni-hint"');
|
||||
expect($html)->toContain('data-testid="lerni-steps"');
|
||||
expect($html)->toContain('Zerlege 12 in 10 + 2');
|
||||
});
|
||||
|
||||
it('Lerni shortlink URL contains slug + ?lerni=1', function () {
|
||||
$html = Livewire::test(Run::class, ['slug' => 'math-multiplikation-bis-100'])
|
||||
->call('toggleLerni')
|
||||
->html();
|
||||
expect($html)->toContain('data-testid="lerni-shortlink"');
|
||||
expect($html)->toContain('math-multiplikation-bis-100');
|
||||
expect($html)->toContain('?lerni=1');
|
||||
});
|
||||
|
||||
it('?lerni=1 query auto-opens helper on first paint', function () {
|
||||
Livewire::withQueryParams(['lerni' => 1])
|
||||
->test(Run::class, ['slug' => 'math-multiplikation-bis-100'])
|
||||
->assertSet('showLerni', true);
|
||||
});
|
||||
|
||||
it('Run placeholder view exists with task-run-skeleton testid', function () {
|
||||
|
|
@ -128,3 +178,29 @@ it('Run placeholder view exists with task-run-skeleton testid', function () {
|
|||
expect($view->getName())->toBe('livewire.tasks.run-placeholder');
|
||||
expect($view->render())->toContain('task-run-skeleton');
|
||||
});
|
||||
|
||||
it('config/tasks.php uses semantic slugs (not t1/t2)', function () {
|
||||
$tasks = config('tasks');
|
||||
foreach ($tasks as $t) {
|
||||
expect($t)->toHaveKey('slug');
|
||||
expect($t['slug'])->toMatch('/^[a-z0-9-]+$/', "Slug '{$t['slug']}' must be lowercase + hyphens");
|
||||
expect($t['slug'])->not->toMatch('/^t[0-9]+$/', "Slug '{$t['slug']}' is generic — use semantic name");
|
||||
}
|
||||
});
|
||||
|
||||
it('every task slug has a matching question bank entry', function () {
|
||||
foreach (config('tasks', []) as $t) {
|
||||
$questions = config("task-questions.{$t['slug']}");
|
||||
expect($questions)->toBeArray()->not->toBeEmpty("Slug '{$t['slug']}' missing questions");
|
||||
}
|
||||
});
|
||||
|
||||
it('each question has lerni_hint + lerni_steps for AI helper', function () {
|
||||
foreach (config('task-questions', []) as $slug => $questions) {
|
||||
foreach ($questions as $i => $q) {
|
||||
expect($q)->toHaveKey('lerni_hint');
|
||||
expect($q)->toHaveKey('lerni_steps');
|
||||
expect($q['lerni_steps'])->toBeArray()->not->toBeEmpty();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,109 +0,0 @@
|
|||
<?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!');
|
||||
});
|
||||
|
|
@ -13,88 +13,78 @@ uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
|
|||
beforeEach(function () {
|
||||
$this->seed(\Database\Seeders\RolesSeeder::class);
|
||||
|
||||
$tenant = Tenant::firstOrCreate(
|
||||
['name' => 'Test Schule'],
|
||||
['type' => 'school', 'active' => true]
|
||||
);
|
||||
$tenant = Tenant::firstOrCreate(['name' => 'Test Schule'], ['type' => 'school', 'active' => true]);
|
||||
|
||||
$this->user = User::withoutGlobalScopes()->create([
|
||||
'name' => 'Start Tester',
|
||||
'email' => 'start@test.example',
|
||||
'tenant_id' => $tenant->id,
|
||||
'password' => bcrypt('password'),
|
||||
'name' => 'Start Tester', 'email' => 'start@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,
|
||||
'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,
|
||||
'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,
|
||||
'license_id' => $license->id, 'user_id' => $this->user->id,
|
||||
'created_at' => now(), 'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$this->actingAs($this->user);
|
||||
});
|
||||
|
||||
it('Start button has data-testid + wire:click="startTask(...)"', function () {
|
||||
it('Start button has data-testid + wire:click="startTask(<slug>)"', function () {
|
||||
$html = Livewire::test(TasksIndex::class)->html();
|
||||
expect($html)->toContain('data-testid="task-start-btn"');
|
||||
expect($html)->toContain("wire:click=\"startTask('t1')\"");
|
||||
expect($html)->toContain("wire:click=\"startTask('math-multiplikation-bis-100')\"");
|
||||
});
|
||||
|
||||
it('Repeat button has wire:click="repeatTask(...)" for done tasks', function () {
|
||||
it('Repeat button has wire:click="repeatTask(<slug>)" for done tasks', function () {
|
||||
$html = Livewire::test(TasksIndex::class)->call('setFilter', 'done')->html();
|
||||
expect($html)->toContain('data-testid="task-repeat-btn"');
|
||||
expect($html)->toContain('repeatTask(');
|
||||
});
|
||||
|
||||
it('startTask action runs and adds key to startedKeys', function () {
|
||||
it('startTask action runs and adds slug to startedKeys', function () {
|
||||
Livewire::test(TasksIndex::class)
|
||||
->assertSet('startedKeys', [])
|
||||
->call('startTask', 't1')
|
||||
->assertSet('startedKeys', ['t1']);
|
||||
->call('startTask', 'math-multiplikation-bis-100')
|
||||
->assertSet('startedKeys', ['math-multiplikation-bis-100']);
|
||||
});
|
||||
|
||||
it('startTask redirects to tasks.run page (dedicated quiz, not modal)', function () {
|
||||
it('startTask redirects to tasks.run page with slug', function () {
|
||||
Livewire::test(TasksIndex::class)
|
||||
->call('startTask', 't1')
|
||||
->assertRedirect(route('tasks.run', ['key' => 't1']));
|
||||
->call('startTask', 'math-multiplikation-bis-100')
|
||||
->assertRedirect(route('tasks.run', ['slug' => 'math-multiplikation-bis-100']));
|
||||
});
|
||||
|
||||
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 slug', function () {
|
||||
Livewire::test(TasksIndex::class)
|
||||
->call('startTask', 'non-existent')
|
||||
->call('startTask', 'non-existent-slug')
|
||||
->assertSet('startedKeys', []);
|
||||
});
|
||||
|
||||
it('repeatTask works for done tasks', function () {
|
||||
Livewire::test(TasksIndex::class)
|
||||
->call('repeatTask', 't6')
|
||||
->assertSet('startedKeys', ['t6']);
|
||||
->call('repeatTask', 'deutsch-gedicht-lernen')
|
||||
->assertSet('startedKeys', ['deutsch-gedicht-lernen']);
|
||||
});
|
||||
|
||||
it('multiple startTask calls do not duplicate startedKeys', function () {
|
||||
Livewire::test(TasksIndex::class)
|
||||
->call('startTask', 't1')
|
||||
->call('startTask', 't1')
|
||||
->call('startTask', 't1')
|
||||
->assertSet('startedKeys', ['t1']);
|
||||
->call('startTask', 'math-multiplikation-bis-100')
|
||||
->call('startTask', 'math-multiplikation-bis-100')
|
||||
->call('startTask', 'math-multiplikation-bis-100')
|
||||
->assertSet('startedKeys', ['math-multiplikation-bis-100']);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue