seed(\Database\Seeders\RolesSeeder::class); $tenant = Tenant::firstOrCreate(['name' => 'Test Schule'], ['type' => 'school', 'active' => true]); $this->user = User::withoutGlobalScopes()->create([ 'name' => 'Run Tester', 'email' => 'run@test.example', 'tenant_id' => $tenant->id, 'password' => bcrypt('password'), ]); $role = Role::where('name', 'child')->first(); DB::table('role_user')->insert(['user_id' => $this->user->id, 'role_id' => $role->id, 'created_at' => now(), 'updated_at' => now()]); $license = License::withoutGlobalScopes()->create([ 'tenant_id' => $tenant->id, 'type' => 'school_flat', 'expires_at' => now()->addYear(), 'active' => true, ]); DB::table('license_assignments')->insert(['license_id' => $license->id, 'user_id' => $this->user->id, 'created_at' => now(), 'updated_at' => now()]); $this->actingAs($this->user); }); it('route tasks.run registered with slug param', function () { expect(\Illuminate\Support\Facades\Route::has('tasks.run'))->toBeTrue(); }); 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/{slug}/run with unknown slug returns 404', function () { $this->get(route('tasks.run', ['slug' => 'nonexistent-slug']))->assertNotFound(); }); 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 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 vocab loads ENGLISH questions', function () { $component = Livewire::test(Run::class, ['slug' => 'englisch-vocab-animals']); $component->assertSee('Translate "Katze"'); }); 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('submitChoice marks correct/incorrect + correctAnswers counter', function () { Livewire::test(Run::class, ['slug' => 'math-multiplikation-bis-100']) ->call('submitChoice', 2) // q1 correct = index 2 (96) ->assertSet('lastAnswer', ['type' => 'choice', 'index' => 2]) ->assertSet('lastAnswerCorrect', true) ->assertSet('correctAnswers', 1); }); it('submitChoice wrong index: no point', function () { Livewire::test(Run::class, ['slug' => 'math-multiplikation-bis-100']) ->call('submitChoice', 0) ->assertSet('lastAnswerCorrect', false) ->assertSet('correctAnswers', 0); }); it('nextStep advances + clears feedback + closes Lerni helper', function () { Livewire::test(Run::class, ['slug' => 'math-multiplikation-bis-100']) ->call('toggleLerni') ->assertSet('showLerni', true) ->call('submitChoice', 2) ->call('nextStep') ->assertSet('currentStep', 2) ->assertSet('lastAnswer', null) ->assertSet('showLerni', false); }); 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('submitChoice', 0)->call('nextStep'); } $run->assertSet('finished', true); }); it('restart resets all progress including Lerni state', function () { Livewire::test(Run::class, ['slug' => 'math-multiplikation-bis-100']) ->call('toggleLerni') ->call('submitChoice', 2) ->call('nextStep') ->call('restart') ->assertSet('currentStep', 1) ->assertSet('correctAnswers', 0) ->assertSet('finished', false) ->assertSet('lastAnswer', null) ->assertSet('showLerni', false); }); // ===== Cloze (Lückentext) tests ===== it('cloze: deutsch-lueckentexte loads with cloze type', function () { Livewire::test(Run::class, ['slug' => 'deutsch-lueckentexte']) ->assertSee('Lückentext') ->assertSee('Der Hund'); }); it('cloze: submitCloze with correct text marks correct', function () { Livewire::test(Run::class, ['slug' => 'deutsch-lueckentexte']) ->set('clozeInput', 'läuft') ->call('submitCloze') ->assertSet('lastAnswerCorrect', true) ->assertSet('correctAnswers', 1); }); it('cloze: case-insensitive match', function () { Livewire::test(Run::class, ['slug' => 'deutsch-lueckentexte']) ->set('clozeInput', 'LÄUFT') ->call('submitCloze') ->assertSet('lastAnswerCorrect', true); }); it('cloze: accept[] alternative accepted', function () { Livewire::test(Run::class, ['slug' => 'deutsch-lueckentexte']) ->set('clozeInput', 'rennt') ->call('submitCloze') ->assertSet('lastAnswerCorrect', true); }); it('cloze: empty input is not correct', function () { Livewire::test(Run::class, ['slug' => 'deutsch-lueckentexte']) ->set('clozeInput', '') ->call('submitCloze') ->assertSet('lastAnswerCorrect', false); }); it('cloze: wrong answer recorded as incorrect', function () { Livewire::test(Run::class, ['slug' => 'deutsch-lueckentexte']) ->set('clozeInput', 'springt') ->call('submitCloze') ->assertSet('lastAnswerCorrect', false) ->assertSet('correctAnswers', 0); }); it('cloze: lastAnswer payload shape is cloze', function () { Livewire::test(Run::class, ['slug' => 'deutsch-lueckentexte']) ->set('clozeInput', 'läuft') ->call('submitCloze') ->assertSet('lastAnswer', ['type' => 'cloze', 'text' => 'läuft']); }); it('cloze: nextStep clears clozeInput', function () { Livewire::test(Run::class, ['slug' => 'deutsch-lueckentexte']) ->set('clozeInput', 'läuft') ->call('submitCloze') ->call('nextStep') ->assertSet('clozeInput', '') ->assertSet('lastAnswer', null); }); // ===== Geo-Map tests ===== it('geo-map: geografie-europa-laender loads with geo-map type', function () { Livewire::test(Run::class, ['slug' => 'geografie-europa-laender']) ->assertSee('Klicke das Land') ->assertSee('Wo liegt Deutschland?'); }); it('geo-map: submitRegion correct key marks correct', function () { Livewire::test(Run::class, ['slug' => 'geografie-europa-laender']) ->call('submitRegion', 'deutschland') ->assertSet('lastAnswerCorrect', true) ->assertSet('correctAnswers', 1); }); it('geo-map: submitRegion wrong key marks incorrect', function () { Livewire::test(Run::class, ['slug' => 'geografie-europa-laender']) ->call('submitRegion', 'spanien') ->assertSet('lastAnswerCorrect', false) ->assertSet('correctAnswers', 0); }); it('geo-map: lastAnswer payload shape is geo-map', function () { Livewire::test(Run::class, ['slug' => 'geografie-europa-laender']) ->call('submitRegion', 'deutschland') ->assertSet('lastAnswer', ['type' => 'geo-map', 'region' => 'deutschland']); }); it('geo-map: SVG region paths render in HTML', function () { $html = Livewire::test(Run::class, ['slug' => 'geografie-europa-laender'])->html(); expect($html)->toContain('data-testid="run-geomap"'); expect($html)->toContain('data-testid="run-geomap-region-deutschland"'); expect($html)->toContain('data-testid="run-geomap-region-frankreich"'); }); // ===== Cross-type guards ===== it('cross-type: submitChoice does nothing for cloze question', function () { Livewire::test(Run::class, ['slug' => 'deutsch-lueckentexte']) ->call('submitChoice', 0) ->assertSet('lastAnswer', null) ->assertSet('correctAnswers', 0); }); it('cross-type: submitCloze does nothing for choice question', function () { Livewire::test(Run::class, ['slug' => 'math-multiplikation-bis-100']) ->set('clozeInput', 'whatever') ->call('submitCloze') ->assertSet('lastAnswer', null); }); it('cross-type: submitRegion does nothing for choice question', function () { Livewire::test(Run::class, ['slug' => 'math-multiplikation-bis-100']) ->call('submitRegion', 'deutschland') ->assertSet('lastAnswer', null); }); it('startTask in Index redirects to tasks.run with slug', function () { Livewire::test(\App\Livewire\Tasks\Index::class) ->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', ['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 () { $view = Run::placeholder([]); 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(); } } });