L'élève avait accès à ses compétences mais pas à ses notes numériques. Cette fonctionnalité lui donne une vue complète de sa progression scolaire avec moyennes par matière, détail par évaluation, statistiques de classe, et un mode "découverte" pour révéler ses notes à son rythme (FR14, FR15). Les notes ne sont visibles qu'après publication par l'enseignant, ce qui garantit que l'élève les découvre avant ses parents (délai 24h story 6.7).
366 lines
15 KiB
TypeScript
366 lines
15 KiB
TypeScript
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 });
|
|
});
|
|
});
|
|
});
|