import { test, expect } from '@playwright/test'; import { execWithRetry, runSql, clearCache, 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 STUDENT_EMAIL = 'e2e-student-grades@example.com'; const STUDENT_PASSWORD = 'StudentGrades123'; const TEACHER_EMAIL = 'e2e-sg-teacher@example.com'; const TEACHER_PASSWORD = 'TeacherGrades123'; const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'; let classId: string; let subjectId: string; let subject2Id: string; let studentId: string; let evalId1: string; let evalId2: string; let periodId: string; function uuid5(name: string): string { return execWithRetry( `docker compose -f "${composeFile}" exec -T php php -r '` + `require "/app/vendor/autoload.php"; ` + `echo Ramsey\\Uuid\\Uuid::uuid5("6ba7b814-9dad-11d1-80b4-00c04fd430c8","${name}")->toString();` + `' 2>&1` ).trim(); } async function loginAsStudent(page: import('@playwright/test').Page) { await page.goto(`${ALPHA_URL}/login`); await page.locator('#email').fill(STUDENT_EMAIL); await page.locator('#password').fill(STUDENT_PASSWORD); await page.getByRole('button', { name: /se connecter/i }).click(); await page.waitForURL(/\/dashboard/, { timeout: 60000 }); } test.describe('Student Grade Consultation (Story 6.6)', () => { test.describe.configure({ mode: 'serial' }); test.beforeAll(async () => { // Create users createTestUser('ecole-alpha', STUDENT_EMAIL, STUDENT_PASSWORD, 'ROLE_ELEVE --firstName=Émilie --lastName=Dubois'); createTestUser('ecole-alpha', TEACHER_EMAIL, TEACHER_PASSWORD, 'ROLE_PROF'); const { schoolId, academicYearId } = resolveDeterministicIds(TENANT_ID); // Resolve student ID const idOutput = execWithRetry( `docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "SELECT id FROM users WHERE email='${STUDENT_EMAIL}' AND tenant_id='${TENANT_ID}'" 2>&1` ); const idMatch = idOutput.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/); studentId = idMatch![0]!; // Create deterministic IDs classId = uuid5(`sg-class-${TENANT_ID}`); subjectId = uuid5(`sg-subject1-${TENANT_ID}`); subject2Id = uuid5(`sg-subject2-${TENANT_ID}`); evalId1 = uuid5(`sg-eval1-${TENANT_ID}`); evalId2 = uuid5(`sg-eval2-${TENANT_ID}`); periodId = uuid5(`sg-period-${TENANT_ID}`); // Create class runSql( `INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, status, created_at, updated_at) ` + `VALUES ('${classId}', '${TENANT_ID}', '${schoolId}', '${academicYearId}', 'E2E-SG-4A', '4ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` ); // Create subjects runSql( `INSERT INTO subjects (id, tenant_id, school_id, name, code, status, created_at, updated_at) ` + `VALUES ('${subjectId}', '${TENANT_ID}', '${schoolId}', 'E2E-SG-Mathématiques', 'E2ESGMATH', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` ); runSql( `INSERT INTO subjects (id, tenant_id, school_id, name, code, status, created_at, updated_at) ` + `VALUES ('${subject2Id}', '${TENANT_ID}', '${schoolId}', 'E2E-SG-Français', 'E2ESGFRA', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` ); // Assign student to class runSql( `INSERT INTO class_assignments (id, tenant_id, user_id, school_class_id, academic_year_id, assigned_at, created_at, updated_at) ` + `VALUES (gen_random_uuid(), '${TENANT_ID}', '${studentId}', '${classId}', '${academicYearId}', NOW(), NOW(), NOW()) ON CONFLICT (user_id, academic_year_id) DO NOTHING` ); // Create teacher assignment 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, '${classId}', '${subjectId}', '${academicYearId}', 'active', NOW(), NOW(), NOW() ` + `FROM users u WHERE u.email = '${TEACHER_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` + `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, '${classId}', '${subject2Id}', '${academicYearId}', 'active', NOW(), NOW(), NOW() ` + `FROM users u WHERE u.email = '${TEACHER_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` + `ON CONFLICT DO NOTHING` ); // Create published evaluation 1 (Maths - older) 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) ` + `SELECT '${evalId1}', '${TENANT_ID}', '${classId}', '${subjectId}', u.id, 'DS Mathématiques', '2026-03-01', 20, 2.0, 'published', NOW(), NOW(), NOW() ` + `FROM users u WHERE u.email='${TEACHER_EMAIL}' AND u.tenant_id='${TENANT_ID}' ` + `ON CONFLICT (id) DO NOTHING` ); // Create published evaluation 2 (Français - more recent) 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) ` + `SELECT '${evalId2}', '${TENANT_ID}', '${classId}', '${subject2Id}', u.id, 'Dictée', '2026-03-15', 20, 1.0, 'published', NOW(), NOW(), NOW() ` + `FROM users u WHERE u.email='${TEACHER_EMAIL}' AND u.tenant_id='${TENANT_ID}' ` + `ON CONFLICT (id) DO NOTHING` ); // Insert grades runSql( `INSERT INTO grades (id, tenant_id, evaluation_id, student_id, value, status, created_by, created_at, updated_at, appreciation) ` + `SELECT gen_random_uuid(), '${TENANT_ID}', '${evalId1}', '${studentId}', 16.5, 'graded', u.id, NOW(), NOW(), 'Très bon travail' ` + `FROM users u WHERE u.email='${TEACHER_EMAIL}' AND u.tenant_id='${TENANT_ID}' ` + `ON CONFLICT (evaluation_id, student_id) DO NOTHING` ); runSql( `INSERT INTO grades (id, tenant_id, evaluation_id, student_id, value, status, created_by, created_at, updated_at) ` + `SELECT gen_random_uuid(), '${TENANT_ID}', '${evalId2}', '${studentId}', 14.0, 'graded', u.id, NOW(), NOW() ` + `FROM users u WHERE u.email='${TEACHER_EMAIL}' AND u.tenant_id='${TENANT_ID}' ` + `ON CONFLICT (evaluation_id, student_id) DO NOTHING` ); // Insert class statistics runSql( `INSERT INTO evaluation_statistics (evaluation_id, average, min_grade, max_grade, median_grade, graded_count, updated_at) ` + `VALUES ('${evalId1}', 14.2, 8.0, 18.5, 14.5, 25, NOW()) ON CONFLICT (evaluation_id) DO NOTHING` ); runSql( `INSERT INTO evaluation_statistics (evaluation_id, average, min_grade, max_grade, median_grade, graded_count, updated_at) ` + `VALUES ('${evalId2}', 12.8, 6.0, 17.0, 13.0, 25, NOW()) ON CONFLICT (evaluation_id) DO NOTHING` ); // Find the academic period covering the current date (needed for /me/averages auto-detection) const periodOutput = execWithRetry( `docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "SELECT id FROM academic_periods WHERE tenant_id='${TENANT_ID}' AND start_date <= CURRENT_DATE AND end_date >= CURRENT_DATE LIMIT 1" 2>&1` ); const periodMatch = periodOutput.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/); periodId = periodMatch ? periodMatch[0]! : uuid5(`sg-period-${TENANT_ID}`); // Insert student averages (subject + general) 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}', '${studentId}', '${subjectId}', '${periodId}', 16.5, 1, NOW()) ` + `ON CONFLICT (student_id, subject_id, period_id) DO NOTHING` ); 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}', '${studentId}', '${subject2Id}', '${periodId}', 14.0, 1, NOW()) ` + `ON CONFLICT (student_id, subject_id, period_id) DO NOTHING` ); runSql( `INSERT INTO student_general_averages (id, tenant_id, student_id, period_id, average, updated_at) ` + `VALUES (gen_random_uuid(), '${TENANT_ID}', '${studentId}', '${periodId}', 15.25, NOW()) ` + `ON CONFLICT (student_id, period_id) DO NOTHING` ); clearCache(); }); // ========================================================================= // AC2: Dashboard notes — grades and averages visible // ========================================================================= test('AC2: student sees recent grades on dashboard', async ({ page }) => { await loginAsStudent(page); // Dashboard should show grades widget const gradesSection = page.locator('.grades-list'); await expect(gradesSection).toBeVisible({ timeout: 15000 }); // Should show at least one grade const gradeItems = page.locator('.grade-item'); await expect(gradeItems.first()).toBeVisible({ timeout: 10000 }); }); test('AC2: student navigates to full grades page', async ({ page }) => { await loginAsStudent(page); await page.goto(`${ALPHA_URL}/dashboard/student-grades`); // Page title await expect(page.getByRole('heading', { name: 'Mes notes' })).toBeVisible({ timeout: 15000 }); // Should show grade cards const gradeCards = page.locator('.grade-card'); await expect(gradeCards.first()).toBeVisible({ timeout: 10000 }); // Should show both grades (Dictée more recent first) await expect(gradeCards).toHaveCount(2); }); test('AC2: grades show value, subject, and evaluation title', async ({ page }) => { await loginAsStudent(page); await page.goto(`${ALPHA_URL}/dashboard/student-grades`); await expect(page.locator('.grade-card').first()).toBeVisible({ timeout: 15000 }); // Check first grade (Dictée - more recent) const firstCard = page.locator('.grade-card').first(); await expect(firstCard.locator('.grade-subject')).toContainText('E2E-SG-Français'); await expect(firstCard.locator('.grade-eval-title')).toContainText('Dictée'); await expect(firstCard.locator('.grade-value')).toContainText('14/20'); // Check second grade (DS Maths) const secondCard = page.locator('.grade-card').nth(1); await expect(secondCard.locator('.grade-subject')).toContainText('E2E-SG-Mathématiques'); await expect(secondCard.locator('.grade-value')).toContainText('16.5/20'); }); test('AC2: class statistics visible on grades', async ({ page }) => { await loginAsStudent(page); await page.goto(`${ALPHA_URL}/dashboard/student-grades`); await expect(page.locator('.grade-card').first()).toBeVisible({ timeout: 15000 }); const firstStats = page.locator('.grade-card').first().locator('.grade-card-stats'); await expect(firstStats).toContainText('Moy. classe'); }); test('AC2: appreciation visible on grade', async ({ page }) => { await loginAsStudent(page); await page.goto(`${ALPHA_URL}/dashboard/student-grades`); await expect(page.locator('.grade-card').first()).toBeVisible({ timeout: 15000 }); // The Maths grade has an appreciation await expect(page.locator('.grade-appreciation').first()).toContainText('Très bon travail'); }); // ========================================================================= // AC3: Subject detail — click on subject shows all evaluations // ========================================================================= test('AC3: click on subject shows detail modal', async ({ page }) => { await loginAsStudent(page); await page.goto(`${ALPHA_URL}/dashboard/student-grades`); // Wait for averages section const avgCard = page.locator('.average-card').first(); await expect(avgCard).toBeVisible({ timeout: 15000 }); // Click on first subject average card await avgCard.click(); // Modal should appear const modal = page.getByRole('dialog'); await expect(modal).toBeVisible({ timeout: 5000 }); // Modal should show grade details await expect(modal.locator('.detail-item')).toHaveCount(1); }); test('AC3: subject detail modal closes with Escape', async ({ page }) => { await loginAsStudent(page); await page.goto(`${ALPHA_URL}/dashboard/student-grades`); const avgCard = page.locator('.average-card').first(); await expect(avgCard).toBeVisible({ timeout: 15000 }); await avgCard.click(); const modal = page.getByRole('dialog'); await expect(modal).toBeVisible({ timeout: 5000 }); await page.keyboard.press('Escape'); await expect(modal).not.toBeVisible({ timeout: 5000 }); }); // ========================================================================= // AC4: Discover mode — notes hidden by default, click to reveal // ========================================================================= test('AC4: discover mode toggle exists', async ({ page }) => { await loginAsStudent(page); await page.goto(`${ALPHA_URL}/dashboard/student-grades`); await expect(page.locator('.discover-toggle')).toBeVisible({ timeout: 15000 }); await expect(page.locator('.toggle-label')).toContainText('Mode découverte'); }); test('AC4: enabling discover mode hides grade values', async ({ page }) => { await loginAsStudent(page); await page.goto(`${ALPHA_URL}/dashboard/student-grades`); await expect(page.locator('.grade-card').first()).toBeVisible({ timeout: 15000 }); // Enable discover mode const toggle = page.locator('.discover-toggle input'); await toggle.check(); // Grade values should be blurred and reveal hint visible await expect(page.locator('.grade-blur').first()).toBeVisible({ timeout: 5000 }); await expect(page.locator('.reveal-hint').first()).toContainText('Cliquer pour révéler'); }); test('AC4: clicking card in discover mode reveals the grade', async ({ page }) => { await loginAsStudent(page); await page.goto(`${ALPHA_URL}/dashboard/student-grades`); await expect(page.locator('.grade-card').first()).toBeVisible({ timeout: 15000 }); // Enable discover mode await page.locator('.discover-toggle input').check(); await expect(page.locator('.grade-blur').first()).toBeVisible({ timeout: 5000 }); // Click the card to reveal await page.locator('.grade-card-btn').first().click(); // Grade value should now be visible (no longer blurred) const firstCard = page.locator('.grade-card').first(); await expect(firstCard.locator('.grade-value:not(.grade-blur)')).toBeVisible({ timeout: 5000 }); }); // ========================================================================= // AC5: Badge "Nouveau" on recent grades // ========================================================================= test('AC5: new grades show Nouveau badge', async ({ page }) => { // Clear localStorage to simulate fresh session await page.goto(`${ALPHA_URL}/login`); await page.evaluate(() => { localStorage.removeItem('classeo_grades_seen'); localStorage.removeItem('classeo_grade_preferences'); localStorage.removeItem('classeo_grades_revealed'); }); await loginAsStudent(page); await page.goto(`${ALPHA_URL}/dashboard/student-grades`); await expect(page.locator('.grade-card').first()).toBeVisible({ timeout: 15000 }); // Badges should be visible on new grades await expect(page.locator('.badge-new').first()).toBeVisible({ timeout: 5000 }); }); // ========================================================================= // AC2: Averages section visible // ========================================================================= test('AC2: subject averages section displays correctly', async ({ page }) => { await loginAsStudent(page); await page.goto(`${ALPHA_URL}/dashboard/student-grades`); // Wait for averages section const avgSection = page.locator('.averages-section'); await expect(avgSection).toBeVisible({ timeout: 15000 }); // Should show heading await expect(avgSection.getByRole('heading', { name: 'Moyennes par matière' })).toBeVisible(); // Should show at least one average card const avgCards = page.locator('.average-card'); await expect(avgCards.first()).toBeVisible({ timeout: 10000 }); }); test('AC2: general average visible on grades page', async ({ page }) => { await loginAsStudent(page); await page.goto(`${ALPHA_URL}/dashboard/student-grades`); const generalAvg = page.locator('.general-average'); await expect(generalAvg).toBeVisible({ timeout: 15000 }); await expect(generalAvg).toContainText('Moyenne générale'); await expect(generalAvg.locator('.avg-value')).toContainText('/20'); }); // ========================================================================= // AC4: Discover mode persistence // ========================================================================= test('AC4: discover mode persists after page reload', async ({ page }) => { await loginAsStudent(page); await page.goto(`${ALPHA_URL}/dashboard/student-grades`); await expect(page.locator('.grade-card').first()).toBeVisible({ timeout: 15000 }); // Enable discover mode await page.locator('.discover-toggle input').check(); await expect(page.locator('.grade-blur').first()).toBeVisible({ timeout: 5000 }); // Reload the page await page.reload(); // Discover mode should still be active after reload await expect(page.locator('.grade-card').first()).toBeVisible({ timeout: 15000 }); await expect(page.locator('.grade-blur').first()).toBeVisible({ timeout: 5000 }); // Disable discover mode for cleanup await page.locator('.discover-toggle input').uncheck(); }); // ========================================================================= // Dashboard: grade card pop-in // ========================================================================= test('dashboard: clicking a grade card opens detail pop-in', async ({ page }) => { // Ensure discover mode is off await page.goto(`${ALPHA_URL}/login`); await page.evaluate(() => localStorage.setItem('classeo_grade_preferences', '{"revealMode":"immediate"}')); await loginAsStudent(page); const gradeBtn = page.locator('.grade-item-btn').first(); await expect(gradeBtn).toBeVisible({ timeout: 15000 }); await gradeBtn.click(); // Detail modal should appear const modal = page.locator('.grade-detail-modal'); await expect(modal).toBeVisible({ timeout: 5000 }); // Modal shows evaluation title await expect(modal.locator('.grade-detail-title')).toBeVisible(); // Modal shows grade value await expect(modal.locator('.grade-detail-value')).toBeVisible(); }); test('dashboard: grade pop-in shows appreciation', async ({ page }) => { await loginAsStudent(page); const gradeItems = page.locator('.grade-item-btn'); await expect(gradeItems.first()).toBeVisible({ timeout: 15000 }); // Click the Maths grade which has an appreciation // Maths is second in the list (Dictée/Français is more recent) await gradeItems.nth(1).click(); const modal = page.locator('.grade-detail-modal'); await expect(modal).toBeVisible({ timeout: 5000 }); await expect(modal.locator('.grade-detail-appreciation')).toContainText('Très bon travail'); }); test('dashboard: grade pop-in shows class statistics', async ({ page }) => { await loginAsStudent(page); const gradeBtn = page.locator('.grade-item-btn').first(); await expect(gradeBtn).toBeVisible({ timeout: 15000 }); await gradeBtn.click(); const modal = page.locator('.grade-detail-modal'); await expect(modal).toBeVisible({ timeout: 5000 }); // Stats grid with Moyenne, Min, Max await expect(modal.locator('.grade-detail-stats')).toBeVisible(); await expect(modal.locator('.stat-label').first()).toBeVisible(); }); test('dashboard: grade pop-in closes with Escape', async ({ page }) => { await loginAsStudent(page); const gradeBtn = page.locator('.grade-item-btn').first(); await expect(gradeBtn).toBeVisible({ timeout: 15000 }); await gradeBtn.click(); const modal = page.locator('.grade-detail-modal'); await expect(modal).toBeVisible({ timeout: 5000 }); await page.keyboard.press('Escape'); await expect(modal).not.toBeVisible({ timeout: 5000 }); }); // ========================================================================= // Dashboard: discover mode // ========================================================================= test('dashboard: discover mode toggle exists', async ({ page }) => { await loginAsStudent(page); const toggle = page.locator('.widget-discover-toggle'); await expect(toggle).toBeVisible({ timeout: 15000 }); await expect(toggle).toContainText('Mode découverte'); }); test('dashboard: discover mode blurs grades', async ({ page }) => { await loginAsStudent(page); const toggle = page.locator('.widget-discover-toggle input'); await expect(toggle).toBeVisible({ timeout: 15000 }); await toggle.check(); // Grades should be blurred await expect(page.locator('.grade-item-btn .grade-blur').first()).toBeVisible({ timeout: 5000 }); await expect(page.locator('.grade-reveal-hint').first()).toBeVisible(); // Cleanup await toggle.uncheck(); }); test('dashboard: clicking blurred card reveals grade', async ({ page }) => { await loginAsStudent(page); // Enable discover mode const toggle = page.locator('.widget-discover-toggle input'); await expect(toggle).toBeVisible({ timeout: 15000 }); await toggle.check(); await expect(page.locator('.grade-item-btn .grade-blur').first()).toBeVisible({ timeout: 5000 }); // Click to reveal await page.locator('.grade-item-btn').first().click(); // Grade value should now be visible (no blur), and no pop-in should open const firstItem = page.locator('.grade-item-btn').first(); await expect(firstItem.locator('.grade-value:not(.grade-blur)')).toBeVisible({ timeout: 5000 }); await expect(page.locator('.grade-detail-modal')).not.toBeVisible(); // Cleanup await toggle.uncheck(); }); // ========================================================================= // Full page: clickable grade cards // ========================================================================= test('grades page: clicking a grade card opens subject detail modal', async ({ page }) => { // Ensure discover mode is off await page.goto(`${ALPHA_URL}/login`); await page.evaluate(() => localStorage.setItem('classeo_grade_preferences', '{"revealMode":"immediate"}')); await loginAsStudent(page); await page.goto(`${ALPHA_URL}/dashboard/student-grades`); const gradeCard = page.locator('.grade-card-btn').first(); await expect(gradeCard).toBeVisible({ timeout: 15000 }); await gradeCard.click(); // Subject detail modal should appear const modal = page.getByRole('dialog'); await expect(modal).toBeVisible({ timeout: 5000 }); // Should show detail items await expect(modal.locator('.detail-item')).toHaveCount(1); }); test('grades page: clicking second card opens correct subject modal', async ({ page }) => { await loginAsStudent(page); await page.goto(`${ALPHA_URL}/dashboard/student-grades`); const secondCard = page.locator('.grade-card-btn').nth(1); await expect(secondCard).toBeVisible({ timeout: 15000 }); await secondCard.click(); const modal = page.getByRole('dialog'); await expect(modal).toBeVisible({ timeout: 5000 }); // Should show the Maths subject detail await expect(modal.locator('.detail-item')).toHaveCount(1); await expect(modal.locator('.detail-title')).toContainText('DS Mathématiques'); }); // ========================================================================= // Navigation // ========================================================================= test('student can navigate to grades page from nav bar', async ({ page }) => { await loginAsStudent(page); const navLink = page.getByRole('link', { name: /mes notes/i }); await expect(navLink).toBeVisible({ timeout: 15000 }); await navLink.click(); await page.waitForURL(/student-grades/, { timeout: 10000 }); await expect(page.getByRole('heading', { name: 'Mes notes' })).toBeVisible({ timeout: 10000 }); }); });