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 TEACHER_EMAIL = 'e2e-appr-teacher@example.com'; const TEACHER_PASSWORD = 'ApprTest123'; 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() ]); } /** Navigate to grades page and verify grade is loaded (pre-seeded via SQL). */ async function waitForGradeLoaded(page: import('@playwright/test').Page) { await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/grades`); await expect(page.locator('.grade-input').first()).toBeVisible({ timeout: 15000 }); // Grade was pre-inserted in beforeEach, should show as 15/20 await expect(page.locator('.status-graded').first()).toContainText('15/20', { timeout: 10000 }); } let evaluationId: string; let classId: string; let student1Id: string; test.describe('Appreciations (Story 6.4)', () => { test.beforeAll(async () => { createTestUser('ecole-alpha', TEACHER_EMAIL, TEACHER_PASSWORD, 'ROLE_PROF'); const { schoolId, academicYearId } = resolveDeterministicIds(TENANT_ID); const classOutput = 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","appr-class-${TENANT_ID}")->toString();` + `' 2>&1` ).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-APPR-4A', '4ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` ); const subjectOutput = 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","appr-subject-${TENANT_ID}")->toString();` + `' 2>&1` ).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-APPR-Français', 'E2APRFR', '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, '${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` ); createTestUser('ecole-alpha', 'e2e-appr-student1@example.com', 'Student123', 'ROLE_ELEVE --firstName=Claire --lastName=Petit'); const studentIds = execWithRetry( `docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "SELECT id FROM users WHERE email = 'e2e-appr-student1@example.com' AND tenant_id='${TENANT_ID}'" 2>&1` ); 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 >= 1) { student1Id = idMatches[0]!; } 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` ); const evalOutput = 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","appr-eval-${TENANT_ID}")->toString();` + `' 2>&1` ).trim(); evaluationId = evalOutput; clearCache(); }); test.beforeEach(async () => { // Clean appreciation templates for the teacher runSql(`DELETE FROM appreciation_templates WHERE tenant_id = '${TENANT_ID}' AND teacher_id IN (SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}')`); // 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='E2APRFR' AND tenant_id='${TENANT_ID}' LIMIT 1), ` + `u.id, 'E2E Contrôle Français', '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()` ); // Pre-insert a grade for the student so appreciation tests don't depend on auto-save 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}', '${evaluationId}', '${student1Id}', 15, 'graded', ` + `(SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}'), NOW(), NOW() ` + `ON CONFLICT DO NOTHING` ); clearCache(); }); test.describe('Appreciation Input', () => { test('clicking appreciation icon opens text area', async ({ page }) => { await loginAsTeacher(page); await waitForGradeLoaded(page); // Click appreciation button const apprBtn = page.locator('.btn-appreciation').first(); await apprBtn.click(); // Appreciation panel should open with textarea await expect(page.locator('.appreciation-panel')).toBeVisible({ timeout: 5000 }); await expect(page.locator('.appreciation-textarea')).toBeVisible(); }); test('typing appreciation shows character counter', async ({ page }) => { await loginAsTeacher(page); await waitForGradeLoaded(page); // Open appreciation panel await page.locator('.btn-appreciation').first().click(); await expect(page.locator('.appreciation-textarea')).toBeVisible({ timeout: 5000 }); // Type appreciation await page.locator('.appreciation-textarea').fill('Bon travail'); // Should show character count await expect(page.locator('.char-counter')).toContainText('11/500'); }); // Firefox: auto-save debounce (setTimeout) doesn't trigger reliably with Playwright fill() test('appreciation auto-saves after typing', async ({ page, browserName }) => { test.skip(browserName === 'firefox', 'Firefox auto-save timing unreliable with Playwright'); await loginAsTeacher(page); await waitForGradeLoaded(page); // Open appreciation panel await page.locator('.btn-appreciation').first().click(); await expect(page.locator('.appreciation-textarea')).toBeVisible({ timeout: 5000 }); // Type appreciation text (pressSequentially to reliably trigger Svelte bind + oninput) const textarea = page.locator('.appreciation-textarea'); await textarea.click(); await expect(textarea).toBeFocused(); await textarea.pressSequentially('Bon travail', { delay: 50 }); await expect(textarea).not.toHaveValue(''); // Wait for auto-save by checking the UI status indicator (1s debounce + network) await expect(page.getByText('Sauvegardé')).toBeVisible({ timeout: 15000 }); }); test('appreciation icon changes when appreciation exists', async ({ page }) => { // Pre-insert appreciation via SQL runSql( `UPDATE grades SET appreciation = 'Excellent' WHERE evaluation_id = '${evaluationId}' AND student_id = '${student1Id}' AND tenant_id = '${TENANT_ID}'` ); clearCache(); await loginAsTeacher(page); await waitForGradeLoaded(page); // Button should have "has-appreciation" class since appreciation was pre-inserted await expect(page.locator('.btn-appreciation.has-appreciation').first()).toBeVisible({ timeout: 5000 }); }); }); test.describe('Appreciation Templates', () => { test('can open template manager and create a template', async ({ page }) => { await loginAsTeacher(page); await waitForGradeLoaded(page); // Open appreciation panel await page.locator('.btn-appreciation').first().click(); await expect(page.locator('.appreciation-panel')).toBeVisible({ timeout: 5000 }); // Click "Gérer" to open template manager await page.locator('.btn-template-manage').click(); // Template manager modal should be visible const modal = page.getByRole('dialog'); await expect(modal).toBeVisible({ timeout: 5000 }); await expect(modal.getByText('Gérer les modèles')).toBeVisible(); // Fill the new template form await modal.locator('.template-input').fill('Très bon travail'); await modal.locator('.template-textarea').fill('Très bon travail, continuez ainsi !'); await modal.getByLabel('Positive').check(); // Listen for POST const createPromise = page.waitForResponse( (resp) => resp.url().includes('/appreciation-templates') && resp.request().method() === 'POST', { timeout: 30000 } ); // Create template await modal.getByRole('button', { name: 'Créer' }).click(); await createPromise; // Template should appear in list await expect(modal.getByText('Très bon travail, continuez')).toBeVisible({ timeout: 5000 }); }); test('can apply template to appreciation', async ({ page }) => { await loginAsTeacher(page); await waitForGradeLoaded(page); // Open appreciation panel await page.locator('.btn-appreciation').first().click(); await expect(page.locator('.appreciation-panel')).toBeVisible({ timeout: 5000 }); // Create a template first via the manager await page.locator('.btn-template-manage').click(); const modal = page.getByRole('dialog'); await expect(modal).toBeVisible({ timeout: 5000 }); await modal.locator('.template-input').fill('Progrès encourageants'); await modal.locator('.template-textarea').fill('Progrès encourageants ce trimestre, poursuivez vos efforts.'); const createPromise = page.waitForResponse( (resp) => resp.url().includes('/appreciation-templates') && resp.request().method() === 'POST', { timeout: 30000 } ); await modal.getByRole('button', { name: 'Créer' }).click(); await createPromise; // Close manager await modal.getByRole('button', { name: 'Fermer' }).click(); await expect(modal).not.toBeVisible({ timeout: 5000 }); // The appreciation panel may still be open from before the modal, // or it may have closed. Toggle if needed. const panel = page.locator('.appreciation-panel'); if (!(await panel.isVisible())) { await page.locator('.btn-appreciation').first().click(); } await expect(panel).toBeVisible({ timeout: 10000 }); // Click "Modèles" to show template dropdown await page.locator('.btn-template-select').click(); await expect(page.locator('.template-dropdown')).toBeVisible({ timeout: 5000 }); // Listen for appreciation auto-save const apprSavePromise = page.waitForResponse( (resp) => resp.url().includes('/appreciation') && resp.request().method() === 'PUT', { timeout: 30000 } ); // Click the template await page.locator('.template-item').first().click(); // Textarea should contain the template content await expect(page.locator('.appreciation-textarea')).toHaveValue('Progrès encourageants ce trimestre, poursuivez vos efforts.', { timeout: 5000 }); // Wait for auto-save await apprSavePromise; }); test('can edit an existing template', async ({ page }) => { await loginAsTeacher(page); await waitForGradeLoaded(page); // Open appreciation panel then template manager await page.locator('.btn-appreciation').first().click(); await page.locator('.btn-template-manage').click(); const modal = page.getByRole('dialog'); await expect(modal).toBeVisible({ timeout: 5000 }); // Create a template to edit await modal.locator('.template-input').fill('Avant modification'); await modal.locator('.template-textarea').fill('Contenu avant modification'); const createPromise = page.waitForResponse( (resp) => resp.url().includes('/appreciation-templates') && resp.request().method() === 'POST', { timeout: 30000 } ); await modal.getByRole('button', { name: 'Créer' }).click(); await createPromise; // Click "Modifier" on the template await modal.getByRole('button', { name: 'Modifier' }).first().click(); // Form should show "Modifier le modèle" and be pre-filled await expect(modal.getByText('Modifier le modèle')).toBeVisible({ timeout: 5000 }); // Clear and fill with new values await modal.locator('.template-input').fill('Après modification'); await modal.locator('.template-textarea').fill('Contenu après modification'); // Submit the edit const updatePromise = page.waitForResponse( (resp) => resp.url().includes('/appreciation-templates/') && resp.request().method() === 'PUT', { timeout: 30000 } ); await modal.getByRole('button', { name: 'Modifier' }).first().click(); await updatePromise; // Verify updated template is displayed await expect(modal.getByText('Après modification', { exact: true })).toBeVisible({ timeout: 5000 }); await expect(modal.getByText('Avant modification', { exact: true })).not.toBeVisible({ timeout: 5000 }); }); test('can delete a template', async ({ page }) => { await loginAsTeacher(page); await waitForGradeLoaded(page); // Open appreciation panel then template manager await page.locator('.btn-appreciation').first().click(); await page.locator('.btn-template-manage').click(); const modal = page.getByRole('dialog'); await expect(modal).toBeVisible({ timeout: 5000 }); // Create a template await modal.locator('.template-input').fill('À supprimer'); await modal.locator('.template-textarea').fill('Contenu test'); const createPromise = page.waitForResponse( (resp) => resp.url().includes('/appreciation-templates') && resp.request().method() === 'POST', { timeout: 30000 } ); await modal.getByRole('button', { name: 'Créer' }).click(); await createPromise; // Template should be visible await expect(modal.getByText('À supprimer')).toBeVisible({ timeout: 5000 }); // Delete it const deletePromise = page.waitForResponse( (resp) => resp.url().includes('/appreciation-templates/') && resp.request().method() === 'DELETE', { timeout: 30000 } ); await modal.getByRole('button', { name: 'Supprimer' }).click(); await deletePromise; // Template should disappear await expect(modal.getByText('À supprimer')).not.toBeVisible({ timeout: 5000 }); }); }); });