import { test, expect } from '@playwright/test'; import { execWithRetry, runSql, resolveDeterministicIds, createTestUser, composeFile } 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 TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'; async function loginAs(page: import('@playwright/test').Page, email: string, password: string) { await page.goto(`${ALPHA_URL}/login`); await page.locator('#email').fill(email); await page.locator('#password').fill(password); await page.getByRole('button', { name: /se connecter/i }).click(); await page.waitForURL(/\/dashboard/, { timeout: 60000 }); } function querySql(sql: string): string { return execWithRetry( `docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "${sql}" 2>&1` ); } // ========================================================================= // Smoke tests — navigation only, no data dependency // ========================================================================= test.describe('Teacher Statistics — Navigation (Story 6.8)', () => { const TEACHER_EMAIL = 'e2e-stats-teacher@example.com'; const TEACHER_PASSWORD = 'StatsTest123'; test.beforeAll(async () => { createTestUser('ecole-alpha', TEACHER_EMAIL, TEACHER_PASSWORD, 'ROLE_PROF'); }); test('should display statistics page with navigation link', async ({ page }) => { await loginAs(page, TEACHER_EMAIL, TEACHER_PASSWORD); const statsLink = page.getByRole('link', { name: /statistiques/i }); await expect(statsLink).toBeVisible({ timeout: 10000 }); await statsLink.click(); await expect(page).toHaveURL(/\/dashboard\/teacher\/statistics/); await expect(page.getByRole('heading', { name: /mes statistiques/i })).toBeVisible({ timeout: 15000 }); }); test('should show overview or empty state', async ({ page }) => { await loginAs(page, TEACHER_EMAIL, TEACHER_PASSWORD); await page.goto(`${ALPHA_URL}/dashboard/teacher/statistics`); await expect(page.getByRole('heading', { name: /mes statistiques/i })).toBeVisible({ timeout: 15000 }); await expect(page.getByText('Chargement des statistiques...')).not.toBeVisible({ timeout: 15000 }); // Page must show either class cards or the "Aucune donnée" empty state const hasCards = await page.getByRole('button', { name: /moyenne/i }).count(); const hasEmptyState = await page.getByText('Aucune donnée statistique').count(); expect(hasCards + hasEmptyState).toBeGreaterThan(0); }); }); // ========================================================================= // Data-driven tests with seeded evaluations and grades // ========================================================================= test.describe('Teacher Statistics — Data-Driven (Story 6.8)', () => { const DATA_TEACHER_EMAIL = 'e2e-stats-data-teacher@example.com'; const DATA_TEACHER_PASSWORD = 'StatsData123'; let classId: string; let subjectId: string; let teacherId: string; test.beforeAll(async () => { createTestUser('ecole-alpha', DATA_TEACHER_EMAIL, DATA_TEACHER_PASSWORD, 'ROLE_PROF'); const { academicYearId } = resolveDeterministicIds(TENANT_ID); // Resolve teacher ID const teacherOutput = querySql( `SELECT id FROM users WHERE email = '${DATA_TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}'` ); teacherId = teacherOutput.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/)?.[0] ?? ''; // Find existing class classId = querySql( `SELECT id FROM school_classes WHERE tenant_id = '${TENANT_ID}' LIMIT 1` ).match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/)?.[0] ?? ''; // Find existing subject subjectId = querySql( `SELECT id FROM subjects WHERE tenant_id = '${TENANT_ID}' LIMIT 1` ).match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/)?.[0] ?? ''; if (!teacherId || !classId || !subjectId) return; // Ensure at least 3 students in the class for grade diversity const testStudents = [ { email: 'e2e-stats-student-a@example.com', firstName: 'Alice', lastName: 'Stats' }, { email: 'e2e-stats-student-b@example.com', firstName: 'Bob', lastName: 'Stats' }, { email: 'e2e-stats-student-c@example.com', firstName: 'Charlie', lastName: 'Stats' }, ]; for (const { email, firstName, lastName } of testStudents) { createTestUser('ecole-alpha', email, 'StatsStudent123', 'ROLE_ELEVE'); try { runSql( `UPDATE users SET first_name = '${firstName}', last_name = '${lastName}' ` + `WHERE email = '${email}' AND tenant_id = '${TENANT_ID}' AND first_name = ''` ); } catch { /* best effort */ } const sidOutput = querySql( `SELECT id FROM users WHERE email = '${email}' AND tenant_id = '${TENANT_ID}'` ); const sid = sidOutput.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/)?.[0]; if (sid) { try { runSql( `INSERT INTO class_assignments (id, tenant_id, user_id, school_class_id, academic_year_id, created_at, updated_at) ` + `VALUES (gen_random_uuid(), '${TENANT_ID}', '${sid}', '${classId}', '${academicYearId}', NOW(), NOW()) ` + `ON CONFLICT DO NOTHING` ); } catch { /* may exist */ } } } // Create teacher assignment 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) ` + `VALUES (gen_random_uuid(), '${TENANT_ID}', '${teacherId}', '${classId}', '${subjectId}', '${academicYearId}', 'active', NOW(), NOW(), NOW()) ` + `ON CONFLICT DO NOTHING` ); } catch { /* may exist */ } // Create a published evaluation with grades try { const evalIdOutput = querySql(`SELECT gen_random_uuid()::text`); const evalId = evalIdOutput.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/)?.[0] ?? ''; runSql( `INSERT INTO evaluations (id, tenant_id, class_id, subject_id, teacher_id, title, evaluation_date, grade_scale, coefficient, status, grades_published_at, created_at, updated_at) ` + `VALUES ('${evalId}', '${TENANT_ID}', '${classId}', '${subjectId}', '${teacherId}', 'E2E Stats Eval', CURRENT_DATE - INTERVAL '7 days', 20, 1.0, 'published', NOW(), NOW(), NOW()) ` + `ON CONFLICT DO NOTHING` ); // Get students in the class (class_assignments stores student-class links) const studentOutput = querySql( `SELECT ca.user_id FROM class_assignments ca WHERE ca.school_class_id = '${classId}' AND ca.tenant_id = '${TENANT_ID}' LIMIT 3` ); const studentIds = [...studentOutput.matchAll(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g)].map(m => m[0]); // Find current academic period for student_averages const periodOutput = querySql( `SELECT id FROM academic_periods WHERE tenant_id = '${TENANT_ID}' AND start_date <= CURRENT_DATE AND end_date >= CURRENT_DATE LIMIT 1` ); const periodId = periodOutput.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/)?.[0] ?? ''; const grades = [15.0, 7.0, 12.0]; studentIds.forEach((sid, i) => { const grade = grades[i] ?? 10.0; try { runSql( `INSERT INTO grades (id, tenant_id, evaluation_id, student_id, value, status, created_by, created_at, updated_at) ` + `VALUES (gen_random_uuid(), '${TENANT_ID}', '${evalId}', '${sid}', ${grade}, 'graded', '${teacherId}', NOW(), NOW()) ` + `ON CONFLICT DO NOTHING` ); // Populate student_averages so difficulty badges work if (periodId) { runSql( `INSERT INTO student_averages (id, tenant_id, student_id, subject_id, period_id, average, grade_count, updated_at) ` + `VALUES (gen_random_uuid(), '${TENANT_ID}', '${sid}', '${subjectId}', '${periodId}', ${grade}, 1, NOW()) ` + `ON CONFLICT DO NOTHING` ); } } catch { /* may exist */ } }); } catch { /* may already exist */ } }); test.afterAll(() => { // Cleanup seeded data if (teacherId) { try { runSql(`DELETE FROM student_averages WHERE tenant_id = '${TENANT_ID}' AND subject_id = '${subjectId}'`); runSql(`DELETE FROM grades WHERE tenant_id = '${TENANT_ID}' AND created_by = '${teacherId}'`); runSql(`DELETE FROM evaluations WHERE tenant_id = '${TENANT_ID}' AND teacher_id = '${teacherId}'`); runSql(`DELETE FROM teacher_assignments WHERE tenant_id = '${TENANT_ID}' AND teacher_id = '${teacherId}'`); } catch { /* best effort cleanup */ } } }); test('should display class cards with evaluation and student counts', async ({ page }) => { await loginAs(page, DATA_TEACHER_EMAIL, DATA_TEACHER_PASSWORD); await page.goto(`${ALPHA_URL}/dashboard/teacher/statistics`); await expect(page.getByRole('heading', { name: /mes statistiques/i })).toBeVisible({ timeout: 15000 }); await expect(page.getByText('Chargement des statistiques...')).not.toBeVisible({ timeout: 15000 }); // Class card is a button containing "Moyenne" stat label const classCard = page.locator('button.class-card').first(); await expect(classCard).toBeVisible({ timeout: 10000 }); // Card should contain key stat labels await expect(classCard.getByText('Moyenne')).toBeVisible(); await expect(classCard.getByText('Évaluations')).toBeVisible(); await expect(classCard.getByText('Élèves')).toBeVisible(); }); test('should show export and print buttons in class detail', async ({ page }) => { await loginAs(page, DATA_TEACHER_EMAIL, DATA_TEACHER_PASSWORD); await page.goto(`${ALPHA_URL}/dashboard/teacher/statistics`); await expect(page.getByRole('heading', { name: /mes statistiques/i })).toBeVisible({ timeout: 15000 }); await expect(page.getByText('Chargement des statistiques...')).not.toBeVisible({ timeout: 15000 }); // Click first class card — data is seeded so it must exist const classCard = page.locator('button.class-card').first(); await expect(classCard).toBeVisible({ timeout: 10000 }); await classCard.click(); // Detail view buttons await expect(page.getByRole('button', { name: /exporter csv/i })).toBeVisible({ timeout: 10000 }); await expect(page.getByRole('button', { name: /imprimer/i })).toBeVisible(); await expect(page.getByRole('button', { name: /retour/i })).toBeVisible(); }); test('should navigate back from detail to overview', async ({ page }) => { await loginAs(page, DATA_TEACHER_EMAIL, DATA_TEACHER_PASSWORD); await page.goto(`${ALPHA_URL}/dashboard/teacher/statistics`); await expect(page.getByRole('heading', { name: /mes statistiques/i })).toBeVisible({ timeout: 15000 }); await expect(page.getByText('Chargement des statistiques...')).not.toBeVisible({ timeout: 15000 }); const classCard = page.locator('button.class-card').first(); await expect(classCard).toBeVisible({ timeout: 10000 }); await classCard.click(); await expect(page.getByRole('button', { name: /retour/i })).toBeVisible({ timeout: 10000 }); await page.getByRole('button', { name: /retour/i }).click(); await expect(page.getByRole('heading', { name: /mes statistiques/i })).toBeVisible(); }); test('should show class detail with student table and histogram', async ({ page }) => { await loginAs(page, DATA_TEACHER_EMAIL, DATA_TEACHER_PASSWORD); await page.goto(`${ALPHA_URL}/dashboard/teacher/statistics`); await expect(page.getByRole('heading', { name: /mes statistiques/i })).toBeVisible({ timeout: 15000 }); await expect(page.getByText('Chargement des statistiques...')).not.toBeVisible({ timeout: 15000 }); const classCard = page.locator('button.class-card').first(); await expect(classCard).toBeVisible({ timeout: 10000 }); await classCard.click(); await expect(page.getByRole('button', { name: /retour/i })).toBeVisible({ timeout: 10000 }); // Histogram section await expect(page.locator('.histogram')).toBeVisible({ timeout: 10000 }); // Student table with headers await expect(page.getByRole('columnheader', { name: 'Élève' })).toBeVisible(); await expect(page.getByRole('columnheader', { name: 'Moyenne' })).toBeVisible(); await expect(page.getByRole('columnheader', { name: 'Statut' })).toBeVisible(); // At least one student row const studentRows = page.locator('table.student-table tbody tr'); const count = await studentRows.count(); expect(count).toBeGreaterThan(0); }); test('should show difficulty indicators for struggling students', async ({ page }) => { await loginAs(page, DATA_TEACHER_EMAIL, DATA_TEACHER_PASSWORD); await page.goto(`${ALPHA_URL}/dashboard/teacher/statistics`); await expect(page.getByRole('heading', { name: /mes statistiques/i })).toBeVisible({ timeout: 15000 }); await expect(page.getByText('Chargement des statistiques...')).not.toBeVisible({ timeout: 15000 }); const classCard = page.locator('button.class-card').first(); await expect(classCard).toBeVisible({ timeout: 10000 }); await classCard.click(); await expect(page.getByRole('button', { name: /retour/i })).toBeVisible({ timeout: 10000 }); // Student with grade 7.0 (below 8.0 threshold) should have "En difficulté" badge await expect(page.getByText('En difficulté')).toBeVisible({ timeout: 10000 }); }); test('should trigger CSV export with correct response headers', async ({ page }) => { await loginAs(page, DATA_TEACHER_EMAIL, DATA_TEACHER_PASSWORD); await page.goto(`${ALPHA_URL}/dashboard/teacher/statistics`); await expect(page.getByRole('heading', { name: /mes statistiques/i })).toBeVisible({ timeout: 15000 }); await expect(page.getByText('Chargement des statistiques...')).not.toBeVisible({ timeout: 15000 }); const classCard = page.locator('button.class-card').first(); await expect(classCard).toBeVisible({ timeout: 10000 }); await classCard.click(); await expect(page.getByRole('button', { name: /retour/i })).toBeVisible({ timeout: 10000 }); const exportButton = page.getByRole('button', { name: /exporter csv/i }); await expect(exportButton).toBeVisible({ timeout: 10000 }); const downloadPromise = page.waitForEvent('download', { timeout: 15000 }); await exportButton.click(); const download = await downloadPromise; expect(download.suggestedFilename()).toContain('.csv'); }); test('should navigate to student progression view', async ({ page }) => { await loginAs(page, DATA_TEACHER_EMAIL, DATA_TEACHER_PASSWORD); await page.goto(`${ALPHA_URL}/dashboard/teacher/statistics`); await expect(page.getByRole('heading', { name: /mes statistiques/i })).toBeVisible({ timeout: 15000 }); await expect(page.getByText('Chargement des statistiques...')).not.toBeVisible({ timeout: 15000 }); const classCard = page.locator('button.class-card').first(); await expect(classCard).toBeVisible({ timeout: 10000 }); await classCard.click(); await expect(page.getByRole('button', { name: /retour/i })).toBeVisible({ timeout: 10000 }); // Click a student who has grades (test student "Alice Stats" has grade data) const studentLink = page.getByRole('button', { name: /Alice Stats/i }); await expect(studentLink).toBeVisible({ timeout: 20000 }); await studentLink.click(); // Wait for loading to finish await expect(page.getByText('Chargement de la progression...')).not.toBeVisible({ timeout: 15000 }); // Progression view should show chart or empty state const chart = page.getByRole('img', { name: /progression/i }); const emptyState = page.getByText(/aucune note publiée/i); await expect(chart.or(emptyState)).toBeVisible({ timeout: 10000 }); }); });