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.
372 lines
16 KiB
TypeScript
372 lines
16 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-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 });
|
|
});
|
|
});
|
|
});
|