325 lines
12 KiB
PHP
325 lines
12 KiB
PHP
<?php
|
||
|
||
use App\Livewire\Tasks\Run;
|
||
use App\Models\License;
|
||
use App\Models\Role;
|
||
use App\Models\Tenant;
|
||
use App\Models\User;
|
||
use Illuminate\Support\Facades\DB;
|
||
use Livewire\Livewire;
|
||
|
||
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
|
||
|
||
beforeEach(function () {
|
||
$this->seed(\Database\Seeders\RolesSeeder::class);
|
||
|
||
$tenant = Tenant::firstOrCreate(['name' => 'Test Schule'], ['type' => 'school', 'active' => true]);
|
||
$this->user = User::withoutGlobalScopes()->create([
|
||
'name' => 'Run Tester', 'email' => 'run@test.example',
|
||
'tenant_id' => $tenant->id, 'password' => bcrypt('password'),
|
||
]);
|
||
|
||
$role = Role::where('name', 'child')->first();
|
||
DB::table('role_user')->insert(['user_id' => $this->user->id, 'role_id' => $role->id, 'created_at' => now(), 'updated_at' => now()]);
|
||
|
||
$license = License::withoutGlobalScopes()->create([
|
||
'tenant_id' => $tenant->id, 'type' => 'school_flat',
|
||
'expires_at' => now()->addYear(), 'active' => true,
|
||
]);
|
||
DB::table('license_assignments')->insert(['license_id' => $license->id, 'user_id' => $this->user->id, 'created_at' => now(), 'updated_at' => now()]);
|
||
|
||
$this->actingAs($this->user);
|
||
});
|
||
|
||
it('route tasks.run registered with 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();
|
||
}
|
||
}
|
||
});
|