import { test, expect } from '@playwright/test'; import { execSync } from 'child_process'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); 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-grade-teacher@example.com'; const TEACHER_PASSWORD = 'GradeTest123'; const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'; const projectRoot = join(__dirname, '../..'); const composeFile = join(projectRoot, 'compose.yaml'); function runSql(sql: string) { execSync( `docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "${sql}" 2>&1`, { encoding: 'utf-8' } ); } function clearCache() { try { execSync( `docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear paginated_queries.cache 2>&1`, { encoding: 'utf-8' } ); } catch { // Cache pool may not exist } } function resolveDeterministicIds(): { schoolId: string; academicYearId: string } { const output = execSync( `docker compose -f "${composeFile}" exec -T php php -r '` + `require "/app/vendor/autoload.php"; ` + `$t="${TENANT_ID}"; $ns="6ba7b814-9dad-11d1-80b4-00c04fd430c8"; ` + `echo Ramsey\\Uuid\\Uuid::uuid5($ns,"school-$t")->toString()."\\n"; ` + `$m=(int)date("n"); $s=$m>=9?(int)date("Y"):(int)date("Y")-1; $e=$s+1; ` + `echo Ramsey\\Uuid\\Uuid::uuid5($ns,"$t:$s-$e")->toString();` + `' 2>&1`, { encoding: 'utf-8' } ).trim(); const [schoolId, academicYearId] = output.split('\n'); return { schoolId: schoolId!, academicYearId: academicYearId! }; } 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() ]); } // Deterministic IDs for test data let evaluationId: string; let classId: string; let student1Id: string; let student2Id: string; test.describe('Grade Input Grid (Story 6.2)', () => { test.beforeAll(async () => { // Create teacher user execSync( `docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${TEACHER_EMAIL} --password=${TEACHER_PASSWORD} --role=ROLE_PROF 2>&1`, { encoding: 'utf-8' } ); const { schoolId, academicYearId } = resolveDeterministicIds(); // Create test class const classOutput = execSync( `docker compose -f "${composeFile}" exec -T php php -r '` + `require "/app/vendor/autoload.php"; ` + `echo Ramsey\\Uuid\\Uuid::uuid5("6ba7b814-9dad-11d1-80b4-00c04fd430c8","grade-class-${TENANT_ID}")->toString();` + `' 2>&1`, { encoding: 'utf-8' } ).trim(); classId = classOutput; 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-GRADE-6B', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` ); // Create test subject const subjectOutput = execSync( `docker compose -f "${composeFile}" exec -T php php -r '` + `require "/app/vendor/autoload.php"; ` + `echo Ramsey\\Uuid\\Uuid::uuid5("6ba7b814-9dad-11d1-80b4-00c04fd430c8","grade-subject-${TENANT_ID}")->toString();` + `' 2>&1`, { encoding: 'utf-8' } ).trim(); const subjectId = subjectOutput; runSql( `INSERT INTO subjects (id, tenant_id, school_id, name, code, status, created_at, updated_at) ` + `VALUES ('${subjectId}', '${TENANT_ID}', '${schoolId}', 'E2E-GRADE-Sciences', 'E2GRDSCI', 'active', NOW(), NOW()) ON CONFLICT 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` ); // Create 2 test students execSync( `docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=e2e-grade-student1@example.com --password=Student123 --role=ROLE_ELEVE --firstName=Alice --lastName=Durand 2>&1`, { encoding: 'utf-8' } ); execSync( `docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=e2e-grade-student2@example.com --password=Student123 --role=ROLE_ELEVE --firstName=Bob --lastName=Martin 2>&1`, { encoding: 'utf-8' } ); // Assign students to class const studentIds = execSync( `docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "SELECT id FROM users WHERE email IN ('e2e-grade-student1@example.com','e2e-grade-student2@example.com') AND tenant_id='${TENANT_ID}' ORDER BY email" 2>&1`, { encoding: 'utf-8' } ); const idMatches = studentIds.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g); if (idMatches && idMatches.length >= 2) { student1Id = idMatches[0]!; student2Id = idMatches[1]!; } // Assign students 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}', '${student1Id}', '${classId}', '${academicYearId}', NOW(), NOW(), NOW()) ON CONFLICT (user_id, academic_year_id) DO NOTHING` ); 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}', '${student2Id}', '${classId}', '${academicYearId}', NOW(), NOW(), NOW()) ON CONFLICT (user_id, academic_year_id) DO NOTHING` ); // Create test evaluation const evalOutput = execSync( `docker compose -f "${composeFile}" exec -T php php -r '` + `require "/app/vendor/autoload.php"; ` + `echo Ramsey\\Uuid\\Uuid::uuid5("6ba7b814-9dad-11d1-80b4-00c04fd430c8","grade-eval-${TENANT_ID}")->toString();` + `' 2>&1`, { encoding: 'utf-8' } ).trim(); evaluationId = evalOutput; clearCache(); }); test.beforeEach(async () => { // Clean grades and recreate evaluation runSql(`DELETE FROM grade_events WHERE grade_id IN (SELECT id FROM grades WHERE evaluation_id = '${evaluationId}')`); runSql(`DELETE FROM grades WHERE evaluation_id = '${evaluationId}'`); runSql(`DELETE FROM evaluations WHERE id = '${evaluationId}'`); 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 '${evaluationId}', '${TENANT_ID}', '${classId}', ` + `(SELECT id FROM subjects WHERE code='E2GRDSCI' AND tenant_id='${TENANT_ID}' LIMIT 1), ` + `u.id, 'E2E Contrôle Sciences', '2026-04-15', 20, 1.0, 'published', NULL, NOW(), NOW() ` + `FROM users u WHERE u.email='${TEACHER_EMAIL}' AND u.tenant_id='${TENANT_ID}' ` + `ON CONFLICT (id) DO UPDATE SET grades_published_at = NULL, updated_at = NOW()` ); clearCache(); }); test.describe('Grade Grid Display', () => { test('shows grade input grid with students', async ({ page }) => { await loginAsTeacher(page); await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/grades`); // Should display evaluation title await expect(page.getByRole('heading', { name: /E2E Contrôle Sciences/i })).toBeVisible({ timeout: 15000 }); // Should show grade inputs const gradeInputs = page.locator('.grade-input'); await expect(gradeInputs.first()).toBeVisible({ timeout: 10000 }); }); test('"Saisir les notes" button navigates to grades page', async ({ page }) => { await loginAsTeacher(page); await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations`); await expect(page.getByRole('heading', { name: /mes évaluations/i })).toBeVisible({ timeout: 15000 }); // Wait for evaluation cards to load await expect(page.getByText('E2E Contrôle Sciences')).toBeVisible({ timeout: 10000 }); await page.getByRole('link', { name: /saisir les notes/i }).first().click(); await expect(page.getByRole('heading', { name: /E2E Contrôle Sciences/i })).toBeVisible({ timeout: 15000 }); }); }); test.describe('Grade Input', () => { test('can enter a numeric grade', async ({ page }) => { await loginAsTeacher(page); await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/grades`); await expect(page.locator('.grade-input').first()).toBeVisible({ timeout: 15000 }); const firstInput = page.locator('.grade-input').first(); await firstInput.fill('15.5'); // Should show the grade in status column await expect(page.locator('.status-graded').first()).toBeVisible({ timeout: 5000 }); }); test('validates grade against scale maximum', async ({ page }) => { await loginAsTeacher(page); await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/grades`); await expect(page.locator('.grade-input').first()).toBeVisible({ timeout: 15000 }); const firstInput = page.locator('.grade-input').first(); await firstInput.fill('25'); // Should show error await expect(page.locator('.input-error-msg').first()).toBeVisible({ timeout: 5000 }); }); }); test.describe('Slash Commands', () => { test('/abs marks student as absent', async ({ page }) => { await loginAsTeacher(page); await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/grades`); await expect(page.locator('.grade-input').first()).toBeVisible({ timeout: 15000 }); const firstInput = page.locator('.grade-input').first(); await firstInput.clear(); await firstInput.pressSequentially('/abs'); await expect(page.locator('.status-absent').first()).toBeVisible({ timeout: 15000 }); }); test('/disp marks student as dispensed', async ({ page }) => { await loginAsTeacher(page); await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/grades`); await expect(page.locator('.grade-input').first()).toBeVisible({ timeout: 15000 }); const firstInput = page.locator('.grade-input').first(); await firstInput.clear(); await firstInput.pressSequentially('/disp'); await expect(page.locator('.status-dispensed').first()).toBeVisible({ timeout: 15000 }); }); }); test.describe('Keyboard Navigation', () => { test('Tab moves to next student', async ({ page }) => { await loginAsTeacher(page); await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/grades`); await expect(page.locator('.grade-input').first()).toBeVisible({ timeout: 15000 }); const firstInput = page.locator('.grade-input').first(); await firstInput.focus(); await firstInput.press('Tab'); // Second input should be focused const secondInput = page.locator('.grade-input').nth(1); await expect(secondInput).toBeFocused({ timeout: 3000 }); }); test('Enter moves to next student', async ({ page }) => { await loginAsTeacher(page); await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/grades`); await expect(page.locator('.grade-input').first()).toBeVisible({ timeout: 15000 }); const firstInput = page.locator('.grade-input').first(); await firstInput.fill('15'); await firstInput.press('Enter'); // Second input should be focused const secondInput = page.locator('.grade-input').nth(1); await expect(secondInput).toBeFocused({ timeout: 3000 }); }); test('Shift+Tab moves to previous student', async ({ page }) => { await loginAsTeacher(page); await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/grades`); await expect(page.locator('.grade-input').first()).toBeVisible({ timeout: 15000 }); const secondInput = page.locator('.grade-input').nth(1); await secondInput.focus(); await secondInput.press('Shift+Tab'); const firstInput = page.locator('.grade-input').first(); await expect(firstInput).toBeFocused({ timeout: 3000 }); }); }); test.describe('Publication', () => { test('publish requires confirmation via modal', async ({ page }) => { clearCache(); await loginAsTeacher(page); await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/grades`); await page.waitForLoadState('networkidle'); await expect(page.locator('.grade-input').first()).toBeVisible({ timeout: 15000 }); // Start listening for the PUT response BEFORE triggering the save const savePromise = page.waitForResponse( (resp) => resp.url().includes('/grades') && resp.request().method() === 'PUT', { timeout: 30000 } ); // Enter grades const firstInput = page.locator('.grade-input').first(); await expect(firstInput).toBeVisible({ timeout: 5000 }); await firstInput.fill('18'); await expect(page.locator('.status-graded').first()).toContainText('18/20', { timeout: 10000 }); // Wait for the PUT to complete await savePromise; // Click publish button — opens confirmation modal await page.getByRole('button', { name: /publier les notes/i }).click(); // Confirmation dialog should appear const dialog = page.getByRole('alertdialog'); await expect(dialog).toBeVisible({ timeout: 5000 }); await expect(dialog.getByText(/cette action est irréversible/i)).toBeVisible(); // Start listening for POST before clicking confirm const publishPromise = page.waitForResponse( (resp) => resp.url().includes('/publish') && resp.request().method() === 'POST', { timeout: 30000 } ); // Confirm publication await dialog.getByRole('button', { name: /confirmer la publication/i }).click(); // Wait for the POST to complete await publishPromise; // Should show published badge await expect(page.getByText(/notes publiées/i)).toBeVisible({ timeout: 10000 }); }); test('publish modal can be cancelled', async ({ page }) => { clearCache(); await loginAsTeacher(page); await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/grades`); await page.waitForLoadState('networkidle'); await expect(page.locator('.grade-input').first()).toBeVisible({ timeout: 15000 }); // Start listening for PUT before triggering const savePromise = page.waitForResponse( (resp) => resp.url().includes('/grades') && resp.request().method() === 'PUT', { timeout: 30000 } ); // Enter a grade const firstInput = page.locator('.grade-input').first(); await expect(firstInput).toBeVisible({ timeout: 5000 }); await firstInput.fill('14'); await expect(page.locator('.status-graded').first()).toContainText('14/20', { timeout: 10000 }); // Wait for save to complete await savePromise; const publishBtn = page.getByRole('button', { name: /publier les notes/i }); // Open modal then cancel await publishBtn.click(); const dialog = page.getByRole('alertdialog'); await expect(dialog).toBeVisible({ timeout: 5000 }); await expect(dialog.getByText(/cette action est irréversible/i)).toBeVisible(); await dialog.getByRole('button', { name: /annuler/i }).click(); // Modal should close, publish button still visible await expect(dialog).not.toBeVisible({ timeout: 3000 }); await expect(publishBtn).toBeVisible(); }); }); });