Files
Classeo/frontend/e2e/competencies.spec.ts
Mathias STRASSER cf76314d0e
Some checks failed
CI / Backend Tests (push) Has been cancelled
CI / Frontend Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Naming Conventions (push) Has been cancelled
CI / Build Check (push) Has been cancelled
feat: Permettre à l'élève de consulter ses notes et moyennes
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).
2026-04-05 16:04:26 +02:00

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: 15000 });
// Click same button immediately to toggle off (no wait for save)
await levelBtn.click();
await expect(levelBtn).not.toHaveClass(/active/, { timeout: 15000 });
});
});
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 });
});
});
});