diff --git a/app/Livewire/Tasks/Index.php b/app/Livewire/Tasks/Index.php index 969f837..0944855 100644 --- a/app/Livewire/Tasks/Index.php +++ b/app/Livewire/Tasks/Index.php @@ -42,26 +42,24 @@ class Index extends Component /** @var array */ 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] diff --git a/app/Livewire/Tasks/Modals/Runner.php b/app/Livewire/Tasks/Modals/Runner.php deleted file mode 100644 index 42ef3d2..0000000 --- a/app/Livewire/Tasks/Modals/Runner.php +++ /dev/null @@ -1,74 +0,0 @@ -> Placeholder mini-quiz; future = DB-loaded items */ - protected array $steps = [ - ['question' => 'Was ergibt 12 × 8?', 'options' => ['64', '88', '96', '102'], 'correct' => 2], - ['question' => 'Was ergibt 15 × 7?', 'options' => ['95', '105', '115', '125'], 'correct' => 1], - ['question' => 'Was ergibt 9 × 11?', 'options' => ['91', '99', '108', '111'], 'correct' => 1], - ['question' => 'Was ergibt 14 × 6?', 'options' => ['72', '78', '84', '88'], 'correct' => 2], - ]; - - public function mount(array $task = []): void - { - $this->task = $task; - $this->totalSteps = count($this->steps); - } - - public function currentQuestion(): array - { - return $this->steps[$this->currentStep - 1] ?? $this->steps[0]; - } - - public function submitAnswer(int $optionIndex): void - { - $q = $this->currentQuestion(); - if ($optionIndex === ($q['correct'] ?? -1)) { - $this->correctAnswers++; - } - - if ($this->currentStep >= $this->totalSteps) { - $this->finished = true; - return; - } - - $this->currentStep++; - $this->answer = null; - } - - public function restart(): void - { - $this->currentStep = 1; - $this->finished = false; - $this->correctAnswers = 0; - $this->answer = null; - } - - public static function modalMaxWidth(): string - { - return '2xl'; - } - - public static function closeModalOnClickAway(): bool - { - return false; - } - - public function render(): View - { - return view('livewire.tasks.modals.runner'); - } -} diff --git a/app/Livewire/Tasks/Run.php b/app/Livewire/Tasks/Run.php index 1847cf4..0f802c4 100644 --- a/app/Livewire/Tasks/Run.php +++ b/app/Livewire/Tasks/Run.php @@ -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 diff --git a/config/task-questions.php b/config/task-questions.php index 38bc770..49e8a98 100644 --- a/config/task-questions.php +++ b/config/task-questions.php @@ -1,63 +1,270 @@ 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'], + ], ], ]; diff --git a/config/tasks.php b/config/tasks.php index c8d1004..83e9f2c 100644 --- a/config/tasks.php +++ b/config/tasks.php @@ -1,21 +1,86 @@ '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', + ], ]; diff --git a/resources/views/livewire/tasks/index.blade.php b/resources/views/livewire/tasks/index.blade.php index c9df709..7b85996 100644 --- a/resources/views/livewire/tasks/index.blade.php +++ b/resources/views/livewire/tasks/index.blade.php @@ -69,7 +69,7 @@ @foreach($this->filteredTasks as $t) @php $c = $colorMap[$t['color']]; @endphp
@@ -98,13 +98,13 @@ @if($t['status'] === 'open') Starten @else Wiederholen diff --git a/resources/views/livewire/tasks/modals/runner.blade.php b/resources/views/livewire/tasks/modals/runner.blade.php deleted file mode 100644 index 782e7ce..0000000 --- a/resources/views/livewire/tasks/modals/runner.blade.php +++ /dev/null @@ -1,98 +0,0 @@ -@php - $q = $this->currentQuestion(); - $progressPercent = $totalSteps > 0 ? (int) round(($currentStep / $totalSteps) * 100) : 0; -@endphp - -
- -
-
-
- {{ $task['icon'] ?? '∑' }} -
-
-
{{ $task['meta'] ?? '' }}
-
{{ $task['title'] ?? 'Aufgabe' }}
-
-
- -
- - @if(! $finished) - {{-- Progress bar --}} -
-
- Frage {{ $currentStep }} / {{ $totalSteps }} - +{{ (int) ($task['points'] ?? 0) }} Pkt. -
-
-
-
-
- - {{-- Question --}} -
-
Aufgabe
-
- {{ $q['question'] }} -
- -
- @foreach($q['options'] as $i => $opt) - - @endforeach -
-
- -
- Klick eine Antwort an, um fortzufahren. - -
- @else - {{-- Finished state --}} - @php - $passed = $correctAnswers >= (int) ceil($totalSteps / 2); - $percent = $totalSteps > 0 ? (int) round(($correctAnswers / $totalSteps) * 100) : 0; - @endphp -
-
- @if($passed) - - @else - - @endif -
- -
- @if($passed) Super gemacht! @else Fast geschafft @endif -
-

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

-

{{ $percent }} % Erfolgsquote

- -
- - - Nochmal - - - @if($passed) Punkte holen @else Fertig @endif - -
-
- @endif - -
diff --git a/resources/views/livewire/tasks/run.blade.php b/resources/views/livewire/tasks/run.blade.php index 953f5e0..1c931aa 100644 --- a/resources/views/livewire/tasks/run.blade.php +++ b/resources/views/livewire/tasks/run.blade.php @@ -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 -
+
{{-- Breadcrumb / back nav --}}
@@ -18,7 +19,10 @@ Zurück zu Aufgaben - {{ $task['meta'] ?? '' }} +
+ {{ $task['subject'] ?? '' }} + {{ $task['topic'] ?? '' }} +
{{-- Task header --}} @@ -28,7 +32,7 @@
{{ $task['meta'] }}
-

{{ $task['title'] }}

+

{{ $task['title'] }}

Belohnung
@@ -52,60 +56,124 @@
- {{-- Question --}} - @php $q = $this->currentQuestion(); @endphp -
-
Aufgabe
-

- {{ $q['question'] }} -

+ {{-- Workbook: Question left (2 cols), Lerni AI helper right --}} +
-
- @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 - + @endforeach +
+ + @if($lastAnswerIndex !== null) +
+ + @if($currentStep < $totalSteps) + Nächste Frage + + @else + Aufgabe abschließen + @endif - - - @endforeach + +
+ @endif
- @if($lastAnswerIndex !== null) -
- - @if($currentStep < $totalSteps) - Nächste Frage - - @else - Aufgabe abschließen - + {{-- Lerni AI-Helper sidebar --}} +
+ @endif + +
@else @php @@ -113,7 +181,7 @@ $percent = $totalSteps > 0 ? (int) round(($correctAnswers / $totalSteps) * 100) : 0; $pointsEarned = $passed ? (int) $task['points'] : 0; @endphp -
+
@if($passed) diff --git a/routes/web.php b/routes/web.php index d6202a3..f144452 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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'); diff --git a/tests/Feature/Frontend/TaskQuestionBankTest.php b/tests/Feature/Frontend/TaskQuestionBankTest.php index 1539822..503ad8a 100644 --- a/tests/Feature/Frontend/TaskQuestionBankTest.php +++ b/tests/Feature/Frontend/TaskQuestionBankTest.php @@ -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']); }); diff --git a/tests/Feature/Livewire/TasksRunPageTest.php b/tests/Feature/Livewire/TasksRunPageTest.php index a949ade..459c664 100644 --- a/tests/Feature/Livewire/TasksRunPageTest.php +++ b/tests/Feature/Livewire/TasksRunPageTest.php @@ -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(); + } + } +}); diff --git a/tests/Feature/Livewire/TasksRunnerModalTest.php b/tests/Feature/Livewire/TasksRunnerModalTest.php deleted file mode 100644 index 868d906..0000000 --- a/tests/Feature/Livewire/TasksRunnerModalTest.php +++ /dev/null @@ -1,109 +0,0 @@ -seed(\Database\Seeders\RolesSeeder::class); - - $tenant = Tenant::firstOrCreate(['name' => 'Test Schule'], ['type' => 'school', 'active' => true]); - $this->user = User::withoutGlobalScopes()->create([ - 'name' => 'Runner Tester', 'email' => 'runner@test.example', - 'tenant_id' => $tenant->id, 'password' => bcrypt('password'), - ]); - - $role = Role::where('name', 'child')->first(); - DB::table('role_user')->insert(['user_id' => $this->user->id, 'role_id' => $role->id, 'created_at' => now(), 'updated_at' => now()]); - - $license = License::withoutGlobalScopes()->create([ - 'tenant_id' => $tenant->id, 'type' => 'school_flat', - 'expires_at' => now()->addYear(), 'active' => true, - ]); - DB::table('license_assignments')->insert(['license_id' => $license->id, 'user_id' => $this->user->id, 'created_at' => now(), 'updated_at' => now()]); - - $this->task = ['key' => 't1', 'title' => 'Multiplikation bis 100', 'meta' => 'Mathe · 4 Aufgaben', 'icon' => '∑', 'points' => 120]; - $this->actingAs($this->user); -}); - -it('Runner modal class extends ModalComponent', function () { - expect(is_subclass_of(Runner::class, ModalComponent::class))->toBeTrue(); -}); - -it('Runner alias resolves to App\\Livewire\\Tasks\\Modals\\Runner', function () { - $cls = app('livewire.finder')->resolveClassComponentClassName('tasks.modals.runner'); - expect($cls)->toBe(Runner::class); -}); - -it('Runner renders question + 4 options for step 1', function () { - Livewire::test(Runner::class, ['task' => $this->task]) - ->assertSet('currentStep', 1) - ->assertSet('totalSteps', 4) - ->assertSet('finished', false) - ->assertSee('Multiplikation bis 100') - ->assertSeeHtml('data-testid="runner-question"') - ->assertSeeHtml('data-testid="runner-option-0"') - ->assertSeeHtml('data-testid="runner-option-3"'); -}); - -it('submitAnswer with correct option increments correctAnswers + advances step', function () { - Livewire::test(Runner::class, ['task' => $this->task]) - ->call('submitAnswer', 2) // step 1 correct = index 2 - ->assertSet('correctAnswers', 1) - ->assertSet('currentStep', 2); -}); - -it('submitAnswer with wrong option still advances step but no point', function () { - Livewire::test(Runner::class, ['task' => $this->task]) - ->call('submitAnswer', 0) // step 1 wrong - ->assertSet('correctAnswers', 0) - ->assertSet('currentStep', 2); -}); - -it('finishing all steps sets finished=true', function () { - Livewire::test(Runner::class, ['task' => $this->task]) - ->call('submitAnswer', 2) // step 1 - ->call('submitAnswer', 1) // step 2 - ->call('submitAnswer', 1) // step 3 - ->call('submitAnswer', 2) // step 4 — final - ->assertSet('finished', true) - ->assertSet('correctAnswers', 4) - ->assertSeeHtml('data-testid="runner-finished"'); -}); - -it('restart resets state', function () { - Livewire::test(Runner::class, ['task' => $this->task]) - ->call('submitAnswer', 2) - ->call('submitAnswer', 1) - ->call('restart') - ->assertSet('currentStep', 1) - ->assertSet('correctAnswers', 0) - ->assertSet('finished', false); -}); - -it('Runner does NOT close on click-away (must finish or cancel explicitly)', function () { - expect(Runner::closeModalOnClickAway())->toBeFalse(); -}); - -it('Runner displays progress bar with step indicator', function () { - $html = Livewire::test(Runner::class, ['task' => $this->task])->html(); - expect($html)->toContain('Frage 1 / 4'); -}); - -it('Runner shows trophy on success, arrow-path on partial', function () { - // 4/4 correct → trophy (passed) - $html = Livewire::test(Runner::class, ['task' => $this->task]) - ->call('submitAnswer', 2) - ->call('submitAnswer', 1) - ->call('submitAnswer', 1) - ->call('submitAnswer', 2) - ->html(); - expect($html)->toContain('Super gemacht!'); -}); diff --git a/tests/Feature/Livewire/TasksStartButtonTest.php b/tests/Feature/Livewire/TasksStartButtonTest.php index 7abc650..80f69e7 100644 --- a/tests/Feature/Livewire/TasksStartButtonTest.php +++ b/tests/Feature/Livewire/TasksStartButtonTest.php @@ -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()"', 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()" 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']); });