feat: Calculer automatiquement les moyennes après chaque saisie de notes
Les enseignants ont besoin de moyennes à jour immédiatement après la publication ou modification des notes, sans attendre un batch nocturne. Le système recalcule via Domain Events synchrones : statistiques d'évaluation (min/max/moyenne/médiane), moyennes matières pondérées (normalisation /20), et moyenne générale par élève. Les résultats sont stockés dans des tables dénormalisées avec cache Redis (TTL 5 min). Trois endpoints API exposent les données avec contrôle d'accès par rôle. Une commande console permet le backfill des données historiques au déploiement.
This commit is contained in:
361
frontend/e2e/appreciations.spec.ts
Normal file
361
frontend/e2e/appreciations.spec.ts
Normal file
@@ -0,0 +1,361 @@
|
||||
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
|
||||
await page.locator('.appreciation-textarea').fill('Très bon travail ce trimestre');
|
||||
|
||||
// Wait for auto-save by checking the UI status indicator
|
||||
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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user