import { test, expect } from '@playwright/test'; import { runSql, clearCache, resolveDeterministicIds, createTestUser } from './helpers'; const baseUrl = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:4173'; const urlMatch = baseUrl.match(/:(\d+)$/); const PORT = urlMatch ? urlMatch[1] : '4173'; const ALPHA_URL = `http://ecole-alpha.classeo.local:${PORT}`; const TEACHER_EMAIL = 'e2e-eval-teacher@example.com'; const TEACHER_PASSWORD = 'EvalTest123'; const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'; async function loginAsTeacher(page: import('@playwright/test').Page) { await page.goto(`${ALPHA_URL}/login`); await page.locator('#email').fill(TEACHER_EMAIL); await page.locator('#password').fill(TEACHER_PASSWORD); await Promise.all([ page.waitForURL(/\/dashboard/, { timeout: 60000 }), page.getByRole('button', { name: /se connecter/i }).click() ]); } async function navigateToEvaluations(page: import('@playwright/test').Page) { await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations`); await expect(page.getByRole('heading', { name: /mes évaluations/i })).toBeVisible({ timeout: 15000 }); } async function selectClassAndSubject(page: import('@playwright/test').Page) { const classSelect = page.locator('#ev-class'); await expect(classSelect).toBeVisible(); await classSelect.selectOption({ index: 1 }); // Wait for subject options to appear after class selection const subjectSelect = page.locator('#ev-subject'); await expect(subjectSelect).toBeEnabled({ timeout: 5000 }); await expect(subjectSelect.locator('option')).not.toHaveCount(1, { timeout: 10000 }); await subjectSelect.selectOption({ index: 1 }); } function seedTeacherAssignments() { const { academicYearId } = resolveDeterministicIds(TENANT_ID); try { runSql( `INSERT INTO teacher_assignments (id, tenant_id, teacher_id, school_class_id, subject_id, academic_year_id, status, start_date, created_at, updated_at) ` + `SELECT gen_random_uuid(), '${TENANT_ID}', u.id, c.id, s.id, '${academicYearId}', 'active', NOW(), NOW(), NOW() ` + `FROM users u CROSS JOIN school_classes c CROSS JOIN subjects s ` + `WHERE u.email = '${TEACHER_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` + `AND c.tenant_id = '${TENANT_ID}' ` + `AND s.tenant_id = '${TENANT_ID}' ` + `ON CONFLICT DO NOTHING` ); } catch { // Table may not exist } } test.describe('Evaluation Management (Story 6.1)', () => { test.beforeAll(async () => { // Create teacher user createTestUser('ecole-alpha', TEACHER_EMAIL, TEACHER_PASSWORD, 'ROLE_PROF'); // Ensure classes and subject exist const { schoolId, academicYearId } = resolveDeterministicIds(TENANT_ID); try { runSql( `INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, status, created_at, updated_at) ` + `VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', '${academicYearId}', 'E2E-EVAL-6A', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` ); runSql( `INSERT INTO subjects (id, tenant_id, school_id, name, code, status, created_at, updated_at) ` + `VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', 'E2E-EVAL-Maths', 'E2EVALM', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` ); } catch { // May already exist } seedTeacherAssignments(); clearCache(); }); test.beforeEach(async () => { // Clean up ALL evaluations for this teacher (not just by tenant, to avoid // stale data from parallel test files with different teachers) try { runSql(`DELETE FROM grade_events WHERE grade_id IN (SELECT g.id FROM grades g JOIN evaluations e ON g.evaluation_id = e.id WHERE e.tenant_id = '${TENANT_ID}' AND e.teacher_id IN (SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}'))`); runSql(`DELETE FROM grades WHERE evaluation_id IN (SELECT id FROM evaluations WHERE tenant_id = '${TENANT_ID}' AND teacher_id IN (SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}'))`); runSql(`DELETE FROM evaluations WHERE tenant_id = '${TENANT_ID}' AND teacher_id IN (SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}')`); } catch { // Table may not exist } const { schoolId: sId, academicYearId: ayId } = resolveDeterministicIds(TENANT_ID); try { runSql( `INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, status, created_at, updated_at) ` + `VALUES (gen_random_uuid(), '${TENANT_ID}', '${sId}', '${ayId}', 'E2E-EVAL-6A', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` ); runSql( `INSERT INTO subjects (id, tenant_id, school_id, name, code, status, created_at, updated_at) ` + `VALUES (gen_random_uuid(), '${TENANT_ID}', '${sId}', 'E2E-EVAL-Maths', 'E2EVALM', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` ); } catch { // May already exist } seedTeacherAssignments(); clearCache(); }); // ============================================================================ // Navigation // ============================================================================ test.describe('Navigation', () => { test('evaluations link appears in teacher navigation', async ({ page }) => { await loginAsTeacher(page); const nav = page.locator('.desktop-nav'); await expect(nav.getByRole('link', { name: /évaluations/i })).toBeVisible({ timeout: 15000 }); }); test('can navigate to evaluations page', async ({ page }) => { await loginAsTeacher(page); await navigateToEvaluations(page); await expect(page.getByRole('heading', { name: /mes évaluations/i })).toBeVisible(); }); }); // ============================================================================ // Empty State // ============================================================================ test.describe('Empty State', () => { test('shows empty state when no evaluations exist', async ({ page }) => { await loginAsTeacher(page); await navigateToEvaluations(page); await expect(page.getByText(/aucune évaluation/i)).toBeVisible({ timeout: 10000 }); }); }); // ============================================================================ // AC1-AC5: Create Evaluation // ============================================================================ test.describe('AC1-AC5: Create Evaluation', () => { test('can create a new evaluation with default grade scale and coefficient', async ({ page }) => { await loginAsTeacher(page); await navigateToEvaluations(page); // Open create modal await page.getByRole('button', { name: /nouvelle évaluation/i }).click(); await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 }); // Select class and subject await selectClassAndSubject(page); // Fill title await page.locator('#ev-title').fill('Contrôle chapitre 5'); // Fill date await page.locator('#ev-date').fill('2026-06-15'); // Submit await page.getByRole('button', { name: 'Créer', exact: true }).click(); // Wait for modal to close (creation succeeded) await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 15000 }); // Verify evaluation appears in list await expect(page.getByText('Contrôle chapitre 5')).toBeVisible({ timeout: 10000 }); // Verify default grade scale and coefficient badges await expect(page.getByText('/20')).toBeVisible(); await expect(page.getByText('x1')).toBeVisible(); }); test('can create evaluation with custom grade scale and coefficient', async ({ page }) => { await loginAsTeacher(page); await navigateToEvaluations(page); await page.getByRole('button', { name: /nouvelle évaluation/i }).click(); await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 }); // Select class and subject await selectClassAndSubject(page); // Fill form await page.locator('#ev-title').fill('QCM rapide'); await page.locator('#ev-date').fill('2026-06-20'); // Set custom grade scale /10 await page.locator('#ev-scale').fill('10'); // Set coefficient to 0.5 await page.locator('#ev-coeff').fill('0.5'); // Submit await page.getByRole('button', { name: 'Créer', exact: true }).click(); // Verify evaluation with custom values await expect(page.getByText('QCM rapide')).toBeVisible({ timeout: 10000 }); await expect(page.getByText('/10')).toBeVisible(); await expect(page.getByText('x0.5')).toBeVisible(); }); }); // ============================================================================ // AC6: Edit Evaluation // ============================================================================ test.describe('AC6: Edit Evaluation', () => { test('can modify title, description, and coefficient', async ({ page }) => { await loginAsTeacher(page); await navigateToEvaluations(page); // Create an evaluation first await page.getByRole('button', { name: /nouvelle évaluation/i }).click(); await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 }); await selectClassAndSubject(page); await page.locator('#ev-title').fill('Évaluation originale'); await page.locator('#ev-date').fill('2026-06-15'); await page.getByRole('button', { name: 'Créer', exact: true }).click(); await expect(page.getByText('Évaluation originale')).toBeVisible({ timeout: 10000 }); // Open edit modal await page.getByRole('button', { name: /modifier/i }).first().click(); await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 }); // Modify title await page.locator('#edit-title').fill('Évaluation modifiée'); // Modify coefficient await page.locator('#edit-coeff').fill('2'); // Submit await page.getByRole('button', { name: /enregistrer/i }).click(); // Verify changes await expect(page.getByText('Évaluation modifiée')).toBeVisible({ timeout: 10000 }); await expect(page.getByText('x2')).toBeVisible(); }); }); // ============================================================================ // Delete Evaluation // ============================================================================ test.describe('Delete Evaluation', () => { test('can delete an evaluation', async ({ page }) => { await loginAsTeacher(page); await navigateToEvaluations(page); // Create an evaluation first await page.getByRole('button', { name: /nouvelle évaluation/i }).click(); await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 }); await selectClassAndSubject(page); await page.locator('#ev-title').fill('Évaluation à supprimer'); await page.locator('#ev-date').fill('2026-06-15'); await page.getByRole('button', { name: 'Créer', exact: true }).click(); await expect(page.getByText('Évaluation à supprimer')).toBeVisible({ timeout: 10000 }); // Open delete modal await page.getByRole('button', { name: /supprimer/i }).first().click(); await expect(page.getByRole('alertdialog')).toBeVisible({ timeout: 10000 }); // Confirm deletion await page.getByRole('alertdialog').getByRole('button', { name: /supprimer/i }).click(); // Verify evaluation is removed await expect(page.getByText(/aucune évaluation/i)).toBeVisible({ timeout: 10000 }); }); }); // ============================================================================ // T1: Search evaluations by title (P2) // ============================================================================ test.describe('Search evaluations', () => { test('filters evaluations when searching by title', async ({ page }) => { await loginAsTeacher(page); await navigateToEvaluations(page); // Create first evaluation: "Contrôle géométrie" await page.getByRole('button', { name: /nouvelle évaluation/i }).click(); await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 }); await selectClassAndSubject(page); await page.locator('#ev-title').fill('Contrôle géométrie'); await page.locator('#ev-date').fill('2026-06-15'); await page.getByRole('button', { name: 'Créer', exact: true }).click(); await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 15000 }); await expect(page.getByText('Contrôle géométrie')).toBeVisible({ timeout: 10000 }); // Create second evaluation: "QCM algèbre" await page.getByRole('button', { name: /nouvelle évaluation/i }).click(); await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 }); await selectClassAndSubject(page); await page.locator('#ev-title').fill('QCM algèbre'); await page.locator('#ev-date').fill('2026-06-20'); await page.getByRole('button', { name: 'Créer', exact: true }).click(); await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 15000 }); await expect(page.getByText('QCM algèbre')).toBeVisible({ timeout: 10000 }); // Both evaluations should be visible await expect(page.getByText('Contrôle géométrie')).toBeVisible(); await expect(page.getByText('QCM algèbre')).toBeVisible(); // Search for "géométrie" const searchInput = page.getByRole('searchbox', { name: /rechercher par titre/i }); await searchInput.fill('géométrie'); // Wait for debounced search to trigger and results to update await expect(page.getByText('Contrôle géométrie')).toBeVisible({ timeout: 10000 }); await expect(page.getByText('QCM algèbre')).not.toBeVisible({ timeout: 10000 }); // Clear search and verify both reappear await page.getByRole('button', { name: /effacer la recherche/i }).click(); await expect(page.getByText('Contrôle géométrie')).toBeVisible({ timeout: 10000 }); await expect(page.getByText('QCM algèbre')).toBeVisible({ timeout: 10000 }); }); }); // ============================================================================ // T2: Filter evaluations by class (P2) // ============================================================================ test.describe('Filter by class', () => { test('class filter dropdown filters the evaluation list', async ({ page }) => { // Seed a second class and assignment for this test const { schoolId, academicYearId } = resolveDeterministicIds(TENANT_ID); try { runSql( `INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, status, created_at, updated_at) ` + `VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', '${academicYearId}', 'E2E-EVAL-5B', '5ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` ); runSql( `INSERT INTO teacher_assignments (id, tenant_id, teacher_id, school_class_id, subject_id, academic_year_id, status, start_date, created_at, updated_at) ` + `SELECT gen_random_uuid(), '${TENANT_ID}', u.id, c.id, s.id, '${academicYearId}', 'active', NOW(), NOW(), NOW() ` + `FROM users u CROSS JOIN school_classes c CROSS JOIN subjects s ` + `WHERE u.email = '${TEACHER_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` + `AND c.name = 'E2E-EVAL-5B' AND c.tenant_id = '${TENANT_ID}' ` + `AND s.tenant_id = '${TENANT_ID}' ` + `ON CONFLICT DO NOTHING` ); } catch { // May already exist } clearCache(); await loginAsTeacher(page); await navigateToEvaluations(page); // Create evaluation in first class (E2E-EVAL-6A) await page.getByRole('button', { name: /nouvelle évaluation/i }).click(); await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 }); const classSelect = page.locator('#ev-class'); await expect(classSelect).toBeVisible(); await classSelect.selectOption({ label: 'E2E-EVAL-6A' }); const subjectSelect = page.locator('#ev-subject'); await expect(subjectSelect).toBeEnabled({ timeout: 5000 }); await expect(subjectSelect.locator('option')).not.toHaveCount(1, { timeout: 10000 }); await subjectSelect.selectOption({ index: 1 }); await page.locator('#ev-title').fill('Eval classe 6A'); await page.locator('#ev-date').fill('2026-06-15'); await page.getByRole('button', { name: 'Créer', exact: true }).click(); await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 15000 }); await expect(page.getByText('Eval classe 6A')).toBeVisible({ timeout: 10000 }); // Create evaluation in second class (E2E-EVAL-5B) await page.getByRole('button', { name: /nouvelle évaluation/i }).click(); await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 }); const classSelect2 = page.locator('#ev-class'); await classSelect2.selectOption({ label: 'E2E-EVAL-5B' }); const subjectSelect2 = page.locator('#ev-subject'); await expect(subjectSelect2).toBeEnabled({ timeout: 5000 }); await expect(subjectSelect2.locator('option')).not.toHaveCount(1, { timeout: 10000 }); await subjectSelect2.selectOption({ index: 1 }); await page.locator('#ev-title').fill('Eval classe 5B'); await page.locator('#ev-date').fill('2026-06-20'); await page.getByRole('button', { name: 'Créer', exact: true }).click(); await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 15000 }); await expect(page.getByText('Eval classe 5B')).toBeVisible({ timeout: 10000 }); // Both evaluations visible initially await expect(page.getByText('Eval classe 6A')).toBeVisible(); await expect(page.getByText('Eval classe 5B')).toBeVisible(); // Filter by E2E-EVAL-6A const filterSelect = page.getByRole('combobox', { name: /filtrer par classe/i }); await expect(filterSelect).toBeVisible(); await filterSelect.selectOption({ label: 'E2E-EVAL-6A' }); // Wait for filtered results await expect(page.getByText('Eval classe 6A')).toBeVisible({ timeout: 10000 }); await expect(page.getByText('Eval classe 5B')).not.toBeVisible({ timeout: 10000 }); // Reset filter to "Toutes les classes" await filterSelect.selectOption({ label: 'Toutes les classes' }); // Both should reappear await expect(page.getByText('Eval classe 6A')).toBeVisible({ timeout: 10000 }); await expect(page.getByText('Eval classe 5B')).toBeVisible({ timeout: 10000 }); // Cleanup: remove the second class data try { runSql(`DELETE FROM grade_events WHERE grade_id IN (SELECT g.id FROM grades g JOIN evaluations e ON g.evaluation_id = e.id WHERE e.teacher_id IN (SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}') AND e.class_id IN (SELECT id FROM school_classes WHERE name = 'E2E-EVAL-5B' AND tenant_id = '${TENANT_ID}'))`); runSql(`DELETE FROM grades WHERE evaluation_id IN (SELECT id FROM evaluations WHERE teacher_id IN (SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}') AND class_id IN (SELECT id FROM school_classes WHERE name = 'E2E-EVAL-5B' AND tenant_id = '${TENANT_ID}'))`); runSql(`DELETE FROM evaluations WHERE teacher_id IN (SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}') AND class_id IN (SELECT id FROM school_classes WHERE name = 'E2E-EVAL-5B' AND tenant_id = '${TENANT_ID}')`); runSql(`DELETE FROM teacher_assignments WHERE school_class_id IN (SELECT id FROM school_classes WHERE name = 'E2E-EVAL-5B' AND tenant_id = '${TENANT_ID}')`); runSql(`DELETE FROM school_classes WHERE name = 'E2E-EVAL-5B' AND tenant_id = '${TENANT_ID}'`); } catch { // Cleanup is best-effort } }); }); // ============================================================================ // T3: Grade scale equivalence preview (P2) // ============================================================================ test.describe('Grade scale preview', () => { test('shows equivalence preview when barème is not 20', async ({ page }) => { await loginAsTeacher(page); await navigateToEvaluations(page); // Open create modal await page.getByRole('button', { name: /nouvelle évaluation/i }).click(); const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible({ timeout: 10000 }); // Default barème is 20 - no preview should appear const scaleInput = dialog.locator('#ev-scale'); await expect(scaleInput).toHaveValue('20'); const previewHint = dialog.locator('#ev-scale ~ .form-hint'); await expect(previewHint).not.toBeVisible(); // Change barème to 10 await scaleInput.fill('10'); // Verify equivalence preview appears: (10/10 = 20.0/20) await expect(previewHint).toBeVisible({ timeout: 5000 }); await expect(previewHint).toHaveText('(10/10 = 20.0/20)'); // Change barème to 5 -> (10/5 = 40.0/20) await scaleInput.fill('5'); await expect(previewHint).toHaveText('(10/5 = 40.0/20)'); // Change back to 20 -> preview should disappear await scaleInput.fill('20'); await expect(previewHint).not.toBeVisible({ timeout: 5000 }); }); }); });