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:
371
frontend/e2e/competencies.spec.ts
Normal file
371
frontend/e2e/competencies.spec.ts
Normal file
@@ -0,0 +1,371 @@
|
||||
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-comp-teacher@example.com';
|
||||
const TEACHER_PASSWORD = 'CompTest123';
|
||||
const STUDENT1_EMAIL = 'e2e-comp-student1@example.com';
|
||||
const STUDENT2_EMAIL = 'e2e-comp-student2@example.com';
|
||||
const STUDENT_PASSWORD = 'Student123';
|
||||
const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
||||
|
||||
const UUID_NS = '6ba7b814-9dad-11d1-80b4-00c04fd430c8';
|
||||
|
||||
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("${UUID_NS}","${name}")->toString();` +
|
||||
`' 2>&1`
|
||||
).trim();
|
||||
}
|
||||
|
||||
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()
|
||||
]);
|
||||
}
|
||||
|
||||
async function loginAsStudent(page: import('@playwright/test').Page, email: string) {
|
||||
await page.goto(`${ALPHA_URL}/login`);
|
||||
await page.locator('#email').fill(email);
|
||||
await page.locator('#password').fill(STUDENT_PASSWORD);
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
|
||||
page.getByRole('button', { name: /se connecter/i }).click()
|
||||
]);
|
||||
}
|
||||
|
||||
// Deterministic IDs for test data
|
||||
let classId: string;
|
||||
let subjectId: string;
|
||||
let evaluationId: string;
|
||||
let frameworkId: string;
|
||||
let competency1Id: string;
|
||||
let competency2Id: string;
|
||||
let ce1Id: string;
|
||||
let ce2Id: string;
|
||||
let student1Id: string;
|
||||
let student2Id: string;
|
||||
|
||||
test.describe('Competencies Mode (Story 6.5)', () => {
|
||||
test.beforeAll(async () => {
|
||||
// Create test users
|
||||
createTestUser('ecole-alpha', TEACHER_EMAIL, TEACHER_PASSWORD, 'ROLE_PROF');
|
||||
createTestUser('ecole-alpha', STUDENT1_EMAIL, STUDENT_PASSWORD, 'ROLE_ELEVE --firstName=Clara --lastName=Dupont');
|
||||
createTestUser('ecole-alpha', STUDENT2_EMAIL, STUDENT_PASSWORD, 'ROLE_ELEVE --firstName=Hugo --lastName=Leroy');
|
||||
|
||||
const { schoolId, academicYearId } = resolveDeterministicIds(TENANT_ID);
|
||||
|
||||
// Create deterministic IDs
|
||||
classId = uuid5(`comp-class-${TENANT_ID}`);
|
||||
subjectId = uuid5(`comp-subject-${TENANT_ID}`);
|
||||
evaluationId = uuid5(`comp-eval-${TENANT_ID}`);
|
||||
frameworkId = uuid5(`comp-framework-${TENANT_ID}`);
|
||||
competency1Id = uuid5(`comp-c1-${TENANT_ID}`);
|
||||
competency2Id = uuid5(`comp-c2-${TENANT_ID}`);
|
||||
ce1Id = uuid5(`comp-ce1-${TENANT_ID}`);
|
||||
ce2Id = uuid5(`comp-ce2-${TENANT_ID}`);
|
||||
|
||||
// Create test 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-COMP-5A', '5ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
|
||||
);
|
||||
|
||||
// Create test subject
|
||||
runSql(
|
||||
`INSERT INTO subjects (id, tenant_id, school_id, name, code, status, created_at, updated_at) ` +
|
||||
`VALUES ('${subjectId}', '${TENANT_ID}', '${schoolId}', 'E2E-COMP-Maths', 'E2CMPMAT', '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`
|
||||
);
|
||||
|
||||
// Resolve student IDs
|
||||
const studentIds = execWithRetry(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "SELECT id FROM users WHERE email IN ('${STUDENT1_EMAIL}','${STUDENT2_EMAIL}') AND tenant_id='${TENANT_ID}' ORDER BY email" 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 >= 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 competency framework
|
||||
runSql(
|
||||
`INSERT INTO competency_frameworks (id, tenant_id, name, is_default, created_at) ` +
|
||||
`VALUES ('${frameworkId}', '${TENANT_ID}', 'Socle commun E2E', true, NOW()) ON CONFLICT DO NOTHING`
|
||||
);
|
||||
|
||||
// Create 2 competencies
|
||||
runSql(
|
||||
`INSERT INTO competencies (id, framework_id, code, name, description, sort_order) ` +
|
||||
`VALUES ('${competency1Id}', '${frameworkId}', 'D1.1', 'Comprendre et s''exprimer', 'Langue française', 0) ON CONFLICT DO NOTHING`
|
||||
);
|
||||
runSql(
|
||||
`INSERT INTO competencies (id, framework_id, code, name, description, sort_order) ` +
|
||||
`VALUES ('${competency2Id}', '${frameworkId}', 'D2.1', 'Méthodes et outils', 'Organisation du travail', 1) ON CONFLICT DO NOTHING`
|
||||
);
|
||||
|
||||
clearCache();
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
// Clean competency results and competency evaluations, then recreate evaluation + links
|
||||
runSql(`DELETE FROM student_competency_results WHERE competency_evaluation_id IN ('${ce1Id}', '${ce2Id}')`);
|
||||
runSql(`DELETE FROM competency_evaluations WHERE evaluation_id = '${evaluationId}'`);
|
||||
runSql(`DELETE FROM evaluations WHERE id = '${evaluationId}'`);
|
||||
|
||||
// Create evaluation
|
||||
runSql(
|
||||
`INSERT INTO evaluations (id, tenant_id, class_id, subject_id, teacher_id, title, evaluation_date, grade_scale, coefficient, status, created_at, updated_at) ` +
|
||||
`SELECT '${evaluationId}', '${TENANT_ID}', '${classId}', '${subjectId}', ` +
|
||||
`u.id, 'E2E Contrôle Compétences', '2026-04-15', 20, 1.0, 'published', NOW(), NOW() ` +
|
||||
`FROM users u WHERE u.email='${TEACHER_EMAIL}' AND u.tenant_id='${TENANT_ID}' ` +
|
||||
`ON CONFLICT (id) DO UPDATE SET updated_at = NOW()`
|
||||
);
|
||||
|
||||
// Link competencies to evaluation
|
||||
runSql(
|
||||
`INSERT INTO competency_evaluations (id, evaluation_id, competency_id) ` +
|
||||
`VALUES ('${ce1Id}', '${evaluationId}', '${competency1Id}') ON CONFLICT DO NOTHING`
|
||||
);
|
||||
runSql(
|
||||
`INSERT INTO competency_evaluations (id, evaluation_id, competency_id) ` +
|
||||
`VALUES ('${ce2Id}', '${evaluationId}', '${competency2Id}') ON CONFLICT DO NOTHING`
|
||||
);
|
||||
|
||||
clearCache();
|
||||
});
|
||||
|
||||
test.describe('Competency Grid Display', () => {
|
||||
test('shows competency grid with students and competencies', async ({ page }) => {
|
||||
await loginAsTeacher(page);
|
||||
await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/competencies`);
|
||||
|
||||
// Should display evaluation title
|
||||
await expect(page.getByRole('heading', { name: /E2E Contrôle Compétences/i })).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Should show competency column headers
|
||||
await expect(page.getByText('D1.1')).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByText('D2.1')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Should show student names
|
||||
await expect(page.getByText('Dupont Clara')).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByText('Leroy Hugo')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Should show level-cell elements
|
||||
const levelCells = page.locator('.level-cell');
|
||||
await expect(levelCells.first()).toBeVisible({ timeout: 10000 });
|
||||
// 2 students x 2 competencies = 4 cells
|
||||
await expect(levelCells).toHaveCount(4);
|
||||
});
|
||||
|
||||
test('shows level legend with standard levels', async ({ page }) => {
|
||||
await loginAsTeacher(page);
|
||||
await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/competencies`);
|
||||
|
||||
// Should show level legend
|
||||
await expect(page.getByText('Niveaux :')).toBeVisible({ timeout: 15000 });
|
||||
const legendItems = page.locator('.legend-item');
|
||||
await expect(legendItems).toHaveCount(4, { timeout: 10000 });
|
||||
await expect(legendItems.nth(0)).toContainText('Non acquis');
|
||||
await expect(legendItems.nth(1)).toContainText("En cours d'acquisition");
|
||||
await expect(legendItems.nth(2)).toContainText('Acquis');
|
||||
await expect(legendItems.nth(3)).toContainText('Dépassé');
|
||||
});
|
||||
|
||||
test('"Compétences" link navigates from evaluations 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 Compétences')).toBeVisible({ timeout: 10000 });
|
||||
await page.getByRole('link', { name: /compétences/i }).first().click();
|
||||
|
||||
await expect(page.getByRole('heading', { name: /E2E Contrôle Compétences/i })).toBeVisible({ timeout: 15000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Competency Level Input', () => {
|
||||
test('can set a competency level by clicking button', async ({ page }) => {
|
||||
await loginAsTeacher(page);
|
||||
await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/competencies`);
|
||||
await expect(page.locator('.level-cell').first()).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Click the first level button (1 = "Non acquis") in the first cell
|
||||
const firstCell = page.locator('.level-cell').first();
|
||||
const firstLevelBtn = firstCell.locator('.level-btn').first();
|
||||
await firstLevelBtn.click();
|
||||
|
||||
// The button should become active
|
||||
await expect(firstLevelBtn).toHaveClass(/active/, { timeout: 5000 });
|
||||
});
|
||||
|
||||
test('toggle same level clears selection', async ({ page }) => {
|
||||
await loginAsTeacher(page);
|
||||
await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/competencies`);
|
||||
await expect(page.locator('.level-cell').first()).toBeVisible({ timeout: 15000 });
|
||||
|
||||
const firstCell = page.locator('.level-cell').first();
|
||||
const levelBtn = firstCell.locator('.level-btn').nth(1);
|
||||
|
||||
// Click to set level
|
||||
await levelBtn.click();
|
||||
await expect(levelBtn).toHaveClass(/active/, { timeout: 5000 });
|
||||
|
||||
// Click same button immediately to toggle off (no wait for save)
|
||||
await levelBtn.click();
|
||||
await expect(levelBtn).not.toHaveClass(/active/, { timeout: 5000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Keyboard Navigation', () => {
|
||||
test('keyboard shortcut 1-4 sets level', async ({ page }) => {
|
||||
await loginAsTeacher(page);
|
||||
await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/competencies`);
|
||||
await expect(page.locator('.level-cell').first()).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Focus the first cell
|
||||
const firstCell = page.locator('.level-cell').first();
|
||||
await firstCell.focus();
|
||||
|
||||
// Press '3' to set level 3 (Acquis)
|
||||
await firstCell.press('3');
|
||||
|
||||
// The third level button should become active
|
||||
await expect(firstCell.locator('.level-btn').nth(2)).toHaveClass(/active/, { timeout: 5000 });
|
||||
|
||||
// Press '1' to set level 1 (Non acquis)
|
||||
await firstCell.press('1');
|
||||
|
||||
// The first level button should become active, third should not
|
||||
await expect(firstCell.locator('.level-btn').first()).toHaveClass(/active/, { timeout: 5000 });
|
||||
await expect(firstCell.locator('.level-btn').nth(2)).not.toHaveClass(/active/, { timeout: 5000 });
|
||||
});
|
||||
|
||||
test('Tab moves to next cell', async ({ page }) => {
|
||||
await loginAsTeacher(page);
|
||||
await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/competencies`);
|
||||
await expect(page.locator('.level-cell').first()).toBeVisible({ timeout: 15000 });
|
||||
|
||||
const firstCell = page.locator('.level-cell').first();
|
||||
await firstCell.focus();
|
||||
await firstCell.press('Tab');
|
||||
|
||||
// Second cell should be focused (next competency for the same student)
|
||||
const secondCell = page.locator('.level-cell').nth(1);
|
||||
await expect(secondCell).toBeFocused({ timeout: 3000 });
|
||||
});
|
||||
|
||||
test('Arrow keys navigate the grid', async ({ page }) => {
|
||||
await loginAsTeacher(page);
|
||||
await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/competencies`);
|
||||
await expect(page.locator('.level-cell').first()).toBeVisible({ timeout: 15000 });
|
||||
|
||||
const firstCell = page.locator('.level-cell').first();
|
||||
await firstCell.focus();
|
||||
|
||||
// ArrowRight moves to the next competency (same row)
|
||||
await firstCell.press('ArrowRight');
|
||||
const secondCell = page.locator('.level-cell').nth(1);
|
||||
await expect(secondCell).toBeFocused({ timeout: 3000 });
|
||||
|
||||
// ArrowDown moves to the same competency for the next student
|
||||
// Cell index 1 => student 0, comp 1. ArrowDown => student 1, comp 1 => cell index 3
|
||||
await secondCell.press('ArrowDown');
|
||||
const cellBelow = page.locator('.level-cell').nth(3);
|
||||
await expect(cellBelow).toBeFocused({ timeout: 3000 });
|
||||
|
||||
// ArrowLeft moves back
|
||||
await cellBelow.press('ArrowLeft');
|
||||
const cellLeft = page.locator('.level-cell').nth(2);
|
||||
await expect(cellLeft).toBeFocused({ timeout: 3000 });
|
||||
|
||||
// ArrowUp moves back up
|
||||
await cellLeft.press('ArrowUp');
|
||||
await expect(firstCell).toBeFocused({ timeout: 3000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Student Competency View', () => {
|
||||
test.beforeEach(async () => {
|
||||
// Seed competency results so the student has data to view
|
||||
runSql(
|
||||
`INSERT INTO student_competency_results (id, tenant_id, competency_evaluation_id, student_id, level_code, created_at, updated_at) ` +
|
||||
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${ce1Id}', '${student1Id}', 'acquired', NOW(), NOW()) ` +
|
||||
`ON CONFLICT (competency_evaluation_id, student_id) DO UPDATE SET level_code = 'acquired', updated_at = NOW()`
|
||||
);
|
||||
runSql(
|
||||
`INSERT INTO student_competency_results (id, tenant_id, competency_evaluation_id, student_id, level_code, created_at, updated_at) ` +
|
||||
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${ce2Id}', '${student1Id}', 'in_progress', NOW(), NOW()) ` +
|
||||
`ON CONFLICT (competency_evaluation_id, student_id) DO UPDATE SET level_code = 'in_progress', updated_at = NOW()`
|
||||
);
|
||||
clearCache();
|
||||
});
|
||||
|
||||
test('student sees competency progress page', async ({ page }) => {
|
||||
await loginAsStudent(page, STUDENT1_EMAIL);
|
||||
await page.goto(`${ALPHA_URL}/dashboard/student-competencies`);
|
||||
|
||||
// Should display heading
|
||||
await expect(page.getByRole('heading', { name: /mes compétences/i })).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Should show competency cards
|
||||
const cards = page.locator('.competency-cards');
|
||||
await expect(page.locator('.competency-card').first()).toBeVisible({ timeout: 10000 });
|
||||
await expect(cards.getByText('D1.1')).toBeVisible({ timeout: 10000 });
|
||||
await expect(cards.getByText('D2.1')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Should show current level badges
|
||||
await expect(page.locator('.card-badge').first()).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Should show the hint text
|
||||
await expect(page.getByText(/cliquez sur une compétence pour voir l'historique/i)).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test('student can view history by clicking a competency card', async ({ page }) => {
|
||||
await loginAsStudent(page, STUDENT1_EMAIL);
|
||||
await page.goto(`${ALPHA_URL}/dashboard/student-competencies`);
|
||||
|
||||
await expect(page.locator('.competency-card').first()).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Click the first competency card
|
||||
await page.locator('.competency-card').first().click();
|
||||
|
||||
// The card should be selected
|
||||
await expect(page.locator('.competency-card.selected')).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// The history panel should appear
|
||||
await expect(page.locator('.history-panel')).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Should show evaluation title in history
|
||||
await expect(page.locator('.history-panel').getByText('E2E Contrôle Compétences')).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user