feat: Permettre à l'enseignant de créer et gérer ses évaluations
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

Les enseignants avaient besoin de définir les critères de notation
(barème, coefficient) avant de pouvoir saisir des notes. Sans cette
brique, le module Notes & Évaluations (Epic 6) ne pouvait pas démarrer.

L'évaluation est un agrégat du bounded context Scolarité avec deux
Value Objects (GradeScale 1-100, Coefficient 0.1-10). Le barème est
verrouillé dès qu'une note existe pour éviter les incohérences.
Un port EvaluationGradesChecker (stub pour l'instant) sera branché
sur le repository de notes dans la story 6.2.
This commit is contained in:
2026-03-23 23:56:37 +01:00
parent 8d950b0f3c
commit 93baeb1eaa
43 changed files with 4312 additions and 0 deletions

View File

@@ -0,0 +1,481 @@
import { test, expect } from '@playwright/test';
import { execSync } from 'child_process';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
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-eval-teacher@example.com';
const TEACHER_PASSWORD = 'EvalTest123';
const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
const projectRoot = join(__dirname, '../..');
const composeFile = join(projectRoot, 'compose.yaml');
function runSql(sql: string) {
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "${sql}" 2>&1`,
{ encoding: 'utf-8' }
);
}
function clearCache() {
try {
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear paginated_queries.cache 2>&1`,
{ encoding: 'utf-8' }
);
} catch {
// Cache pool may not exist
}
}
function resolveDeterministicIds(): { schoolId: string; academicYearId: string } {
const output = execSync(
`docker compose -f "${composeFile}" exec -T php php -r '` +
`require "/app/vendor/autoload.php"; ` +
`$t="${TENANT_ID}"; $ns="6ba7b814-9dad-11d1-80b4-00c04fd430c8"; ` +
`echo Ramsey\\Uuid\\Uuid::uuid5($ns,"school-$t")->toString()."\\n"; ` +
`$m=(int)date("n"); $s=$m>=9?(int)date("Y"):(int)date("Y")-1; $e=$s+1; ` +
`echo Ramsey\\Uuid\\Uuid::uuid5($ns,"$t:$s-$e")->toString();` +
`' 2>&1`,
{ encoding: 'utf-8' }
).trim();
const [schoolId, academicYearId] = output.split('\n');
return { schoolId: schoolId!, academicYearId: academicYearId! };
}
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: 30000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}
async function navigateToEvaluations(page: import('@playwright/test').Page) {
await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations`);
await expect(page.getByRole('heading', { name: /mes évaluations/i })).toBeVisible({ timeout: 15000 });
}
async function selectClassAndSubject(page: import('@playwright/test').Page) {
const classSelect = page.locator('#ev-class');
await expect(classSelect).toBeVisible();
await classSelect.selectOption({ index: 1 });
// Wait for subject options to appear after class selection
const subjectSelect = page.locator('#ev-subject');
await expect(subjectSelect).toBeEnabled({ timeout: 5000 });
await expect(subjectSelect.locator('option')).not.toHaveCount(1, { timeout: 10000 });
await subjectSelect.selectOption({ index: 1 });
}
function seedTeacherAssignments() {
const { academicYearId } = resolveDeterministicIds();
try {
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, c.id, s.id, '${academicYearId}', 'active', NOW(), NOW(), NOW() ` +
`FROM users u CROSS JOIN school_classes c CROSS JOIN subjects s ` +
`WHERE u.email = '${TEACHER_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` +
`AND c.tenant_id = '${TENANT_ID}' ` +
`AND s.tenant_id = '${TENANT_ID}' ` +
`ON CONFLICT DO NOTHING`
);
} catch {
// Table may not exist
}
}
test.describe('Evaluation Management (Story 6.1)', () => {
test.beforeAll(async () => {
// Create teacher user
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${TEACHER_EMAIL} --password=${TEACHER_PASSWORD} --role=ROLE_PROF 2>&1`,
{ encoding: 'utf-8' }
);
// Ensure classes and subject exist
const { schoolId, academicYearId } = resolveDeterministicIds();
try {
runSql(
`INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, status, created_at, updated_at) ` +
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', '${academicYearId}', 'E2E-EVAL-6A', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
);
runSql(
`INSERT INTO subjects (id, tenant_id, school_id, name, code, status, created_at, updated_at) ` +
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', 'E2E-EVAL-Maths', 'E2EVALM', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
);
} catch {
// May already exist
}
seedTeacherAssignments();
clearCache();
});
test.beforeEach(async () => {
// Clean up evaluation data
try {
runSql(`DELETE FROM evaluations WHERE tenant_id = '${TENANT_ID}' AND teacher_id IN (SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}')`);
} catch {
// Table may not exist
}
const { schoolId, academicYearId } = resolveDeterministicIds();
try {
runSql(
`INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, status, created_at, updated_at) ` +
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', '${academicYearId}', 'E2E-EVAL-6A', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
);
runSql(
`INSERT INTO subjects (id, tenant_id, school_id, name, code, status, created_at, updated_at) ` +
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', 'E2E-EVAL-Maths', 'E2EVALM', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
);
} catch {
// May already exist
}
seedTeacherAssignments();
clearCache();
});
// ============================================================================
// Navigation
// ============================================================================
test.describe('Navigation', () => {
test('evaluations link appears in teacher navigation', async ({ page }) => {
await loginAsTeacher(page);
const nav = page.locator('.desktop-nav');
await expect(nav.getByRole('link', { name: /évaluations/i })).toBeVisible({ timeout: 15000 });
});
test('can navigate to evaluations page', async ({ page }) => {
await loginAsTeacher(page);
await navigateToEvaluations(page);
await expect(page.getByRole('heading', { name: /mes évaluations/i })).toBeVisible();
});
});
// ============================================================================
// Empty State
// ============================================================================
test.describe('Empty State', () => {
test('shows empty state when no evaluations exist', async ({ page }) => {
await loginAsTeacher(page);
await navigateToEvaluations(page);
await expect(page.getByText(/aucune évaluation/i)).toBeVisible({ timeout: 10000 });
});
});
// ============================================================================
// AC1-AC5: Create Evaluation
// ============================================================================
test.describe('AC1-AC5: Create Evaluation', () => {
test('can create a new evaluation with default grade scale and coefficient', async ({ page }) => {
await loginAsTeacher(page);
await navigateToEvaluations(page);
// Open create modal
await page.getByRole('button', { name: /nouvelle évaluation/i }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
// Select class and subject
await selectClassAndSubject(page);
// Fill title
await page.locator('#ev-title').fill('Contrôle chapitre 5');
// Fill date
await page.locator('#ev-date').fill('2026-06-15');
// Submit
await page.getByRole('button', { name: 'Créer', exact: true }).click();
// Wait for modal to close (creation succeeded)
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 15000 });
// Verify evaluation appears in list
await expect(page.getByText('Contrôle chapitre 5')).toBeVisible({ timeout: 10000 });
// Verify default grade scale and coefficient badges
await expect(page.getByText('/20')).toBeVisible();
await expect(page.getByText('x1')).toBeVisible();
});
test('can create evaluation with custom grade scale and coefficient', async ({ page }) => {
await loginAsTeacher(page);
await navigateToEvaluations(page);
await page.getByRole('button', { name: /nouvelle évaluation/i }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
// Select class and subject
await selectClassAndSubject(page);
// Fill form
await page.locator('#ev-title').fill('QCM rapide');
await page.locator('#ev-date').fill('2026-06-20');
// Set custom grade scale /10
await page.locator('#ev-scale').fill('10');
// Set coefficient to 0.5
await page.locator('#ev-coeff').fill('0.5');
// Submit
await page.getByRole('button', { name: 'Créer', exact: true }).click();
// Verify evaluation with custom values
await expect(page.getByText('QCM rapide')).toBeVisible({ timeout: 10000 });
await expect(page.getByText('/10')).toBeVisible();
await expect(page.getByText('x0.5')).toBeVisible();
});
});
// ============================================================================
// AC6: Edit Evaluation
// ============================================================================
test.describe('AC6: Edit Evaluation', () => {
test('can modify title, description, and coefficient', async ({ page }) => {
await loginAsTeacher(page);
await navigateToEvaluations(page);
// Create an evaluation first
await page.getByRole('button', { name: /nouvelle évaluation/i }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
await selectClassAndSubject(page);
await page.locator('#ev-title').fill('Évaluation originale');
await page.locator('#ev-date').fill('2026-06-15');
await page.getByRole('button', { name: 'Créer', exact: true }).click();
await expect(page.getByText('Évaluation originale')).toBeVisible({ timeout: 10000 });
// Open edit modal
await page.getByRole('button', { name: /modifier/i }).first().click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
// Modify title
await page.locator('#edit-title').fill('Évaluation modifiée');
// Modify coefficient
await page.locator('#edit-coeff').fill('2');
// Submit
await page.getByRole('button', { name: /enregistrer/i }).click();
// Verify changes
await expect(page.getByText('Évaluation modifiée')).toBeVisible({ timeout: 10000 });
await expect(page.getByText('x2')).toBeVisible();
});
});
// ============================================================================
// Delete Evaluation
// ============================================================================
test.describe('Delete Evaluation', () => {
test('can delete an evaluation', async ({ page }) => {
await loginAsTeacher(page);
await navigateToEvaluations(page);
// Create an evaluation first
await page.getByRole('button', { name: /nouvelle évaluation/i }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
await selectClassAndSubject(page);
await page.locator('#ev-title').fill('Évaluation à supprimer');
await page.locator('#ev-date').fill('2026-06-15');
await page.getByRole('button', { name: 'Créer', exact: true }).click();
await expect(page.getByText('Évaluation à supprimer')).toBeVisible({ timeout: 10000 });
// Open delete modal
await page.getByRole('button', { name: /supprimer/i }).first().click();
await expect(page.getByRole('alertdialog')).toBeVisible({ timeout: 10000 });
// Confirm deletion
await page.getByRole('alertdialog').getByRole('button', { name: /supprimer/i }).click();
// Verify evaluation is removed
await expect(page.getByText(/aucune évaluation/i)).toBeVisible({ timeout: 10000 });
});
});
// ============================================================================
// T1: Search evaluations by title (P2)
// ============================================================================
test.describe('Search evaluations', () => {
test('filters evaluations when searching by title', async ({ page }) => {
await loginAsTeacher(page);
await navigateToEvaluations(page);
// Create first evaluation: "Contrôle géométrie"
await page.getByRole('button', { name: /nouvelle évaluation/i }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
await selectClassAndSubject(page);
await page.locator('#ev-title').fill('Contrôle géométrie');
await page.locator('#ev-date').fill('2026-06-15');
await page.getByRole('button', { name: 'Créer', exact: true }).click();
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 15000 });
await expect(page.getByText('Contrôle géométrie')).toBeVisible({ timeout: 10000 });
// Create second evaluation: "QCM algèbre"
await page.getByRole('button', { name: /nouvelle évaluation/i }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
await selectClassAndSubject(page);
await page.locator('#ev-title').fill('QCM algèbre');
await page.locator('#ev-date').fill('2026-06-20');
await page.getByRole('button', { name: 'Créer', exact: true }).click();
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 15000 });
await expect(page.getByText('QCM algèbre')).toBeVisible({ timeout: 10000 });
// Both evaluations should be visible
await expect(page.getByText('Contrôle géométrie')).toBeVisible();
await expect(page.getByText('QCM algèbre')).toBeVisible();
// Search for "géométrie"
const searchInput = page.getByRole('searchbox', { name: /rechercher par titre/i });
await searchInput.fill('géométrie');
// Wait for debounced search to trigger and results to update
await expect(page.getByText('Contrôle géométrie')).toBeVisible({ timeout: 10000 });
await expect(page.getByText('QCM algèbre')).not.toBeVisible({ timeout: 10000 });
// Clear search and verify both reappear
await page.getByRole('button', { name: /effacer la recherche/i }).click();
await expect(page.getByText('Contrôle géométrie')).toBeVisible({ timeout: 10000 });
await expect(page.getByText('QCM algèbre')).toBeVisible({ timeout: 10000 });
});
});
// ============================================================================
// T2: Filter evaluations by class (P2)
// ============================================================================
test.describe('Filter by class', () => {
test('class filter dropdown filters the evaluation list', async ({ page }) => {
// Seed a second class and assignment for this test
const { schoolId, academicYearId } = resolveDeterministicIds();
try {
runSql(
`INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, status, created_at, updated_at) ` +
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', '${academicYearId}', 'E2E-EVAL-5B', '5ème', '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, c.id, s.id, '${academicYearId}', 'active', NOW(), NOW(), NOW() ` +
`FROM users u CROSS JOIN school_classes c CROSS JOIN subjects s ` +
`WHERE u.email = '${TEACHER_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` +
`AND c.name = 'E2E-EVAL-5B' AND c.tenant_id = '${TENANT_ID}' ` +
`AND s.tenant_id = '${TENANT_ID}' ` +
`ON CONFLICT DO NOTHING`
);
} catch {
// May already exist
}
clearCache();
await loginAsTeacher(page);
await navigateToEvaluations(page);
// Create evaluation in first class (E2E-EVAL-6A)
await page.getByRole('button', { name: /nouvelle évaluation/i }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
const classSelect = page.locator('#ev-class');
await expect(classSelect).toBeVisible();
await classSelect.selectOption({ label: 'E2E-EVAL-6A' });
const subjectSelect = page.locator('#ev-subject');
await expect(subjectSelect).toBeEnabled({ timeout: 5000 });
await expect(subjectSelect.locator('option')).not.toHaveCount(1, { timeout: 10000 });
await subjectSelect.selectOption({ index: 1 });
await page.locator('#ev-title').fill('Eval classe 6A');
await page.locator('#ev-date').fill('2026-06-15');
await page.getByRole('button', { name: 'Créer', exact: true }).click();
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 15000 });
await expect(page.getByText('Eval classe 6A')).toBeVisible({ timeout: 10000 });
// Create evaluation in second class (E2E-EVAL-5B)
await page.getByRole('button', { name: /nouvelle évaluation/i }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
const classSelect2 = page.locator('#ev-class');
await classSelect2.selectOption({ label: 'E2E-EVAL-5B' });
const subjectSelect2 = page.locator('#ev-subject');
await expect(subjectSelect2).toBeEnabled({ timeout: 5000 });
await expect(subjectSelect2.locator('option')).not.toHaveCount(1, { timeout: 10000 });
await subjectSelect2.selectOption({ index: 1 });
await page.locator('#ev-title').fill('Eval classe 5B');
await page.locator('#ev-date').fill('2026-06-20');
await page.getByRole('button', { name: 'Créer', exact: true }).click();
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 15000 });
await expect(page.getByText('Eval classe 5B')).toBeVisible({ timeout: 10000 });
// Both evaluations visible initially
await expect(page.getByText('Eval classe 6A')).toBeVisible();
await expect(page.getByText('Eval classe 5B')).toBeVisible();
// Filter by E2E-EVAL-6A
const filterSelect = page.getByRole('combobox', { name: /filtrer par classe/i });
await expect(filterSelect).toBeVisible();
await filterSelect.selectOption({ label: 'E2E-EVAL-6A' });
// Wait for filtered results
await expect(page.getByText('Eval classe 6A')).toBeVisible({ timeout: 10000 });
await expect(page.getByText('Eval classe 5B')).not.toBeVisible({ timeout: 10000 });
// Reset filter to "Toutes les classes"
await filterSelect.selectOption({ label: 'Toutes les classes' });
// Both should reappear
await expect(page.getByText('Eval classe 6A')).toBeVisible({ timeout: 10000 });
await expect(page.getByText('Eval classe 5B')).toBeVisible({ timeout: 10000 });
// Cleanup: remove the second class data
try {
runSql(`DELETE FROM evaluations WHERE teacher_id IN (SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}') AND class_id IN (SELECT id FROM school_classes WHERE name = 'E2E-EVAL-5B' AND tenant_id = '${TENANT_ID}')`);
runSql(`DELETE FROM teacher_assignments WHERE school_class_id IN (SELECT id FROM school_classes WHERE name = 'E2E-EVAL-5B' AND tenant_id = '${TENANT_ID}')`);
runSql(`DELETE FROM school_classes WHERE name = 'E2E-EVAL-5B' AND tenant_id = '${TENANT_ID}'`);
} catch {
// Cleanup is best-effort
}
});
});
// ============================================================================
// T3: Grade scale equivalence preview (P2)
// ============================================================================
test.describe('Grade scale preview', () => {
test('shows equivalence preview when barème is not 20', async ({ page }) => {
await loginAsTeacher(page);
await navigateToEvaluations(page);
// Open create modal
await page.getByRole('button', { name: /nouvelle évaluation/i }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 10000 });
// Default barème is 20 - no preview should appear
const scaleInput = dialog.locator('#ev-scale');
await expect(scaleInput).toHaveValue('20');
const previewHint = dialog.locator('#ev-scale ~ .form-hint');
await expect(previewHint).not.toBeVisible();
// Change barème to 10
await scaleInput.fill('10');
// Verify equivalence preview appears: (10/10 = 20.0/20)
await expect(previewHint).toBeVisible({ timeout: 5000 });
await expect(previewHint).toHaveText('(10/10 = 20.0/20)');
// Change barème to 5 -> (10/5 = 40.0/20)
await scaleInput.fill('5');
await expect(previewHint).toHaveText('(10/5 = 40.0/20)');
// Change back to 20 -> preview should disappear
await scaleInput.fill('20');
await expect(previewHint).not.toBeVisible({ timeout: 5000 });
});
});
});