Files
Classeo/frontend/e2e/evaluations.spec.ts
Mathias STRASSER b70d5ec2ad
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'enseignant de saisir les notes dans une grille inline
L'enseignant avait besoin d'un moyen rapide de saisir les notes après
une évaluation. La grille inline permet de compléter 30 élèves en moins
de 3 minutes grâce à la navigation clavier (Tab/Enter/Shift+Tab),
la validation temps réel, l'auto-save debounced (500ms) et les
raccourcis /abs et /disp pour marquer absents/dispensés.

Les notes restent en brouillon jusqu'à publication explicite (avec
confirmation modale). Une fois publiées, les élèves les voient
immédiatement ; les parents après un délai de 24h (VisibiliteNotesPolicy).
Le mode offline stocke les notes en IndexedDB et synchronise
automatiquement au retour de la connexion.

Chaque modification est auditée dans grade_events via un event
subscriber qui écoute NoteSaisie/NoteModifiee sur le bus d'événements.
2026-03-29 10:02:03 +02:00

487 lines
22 KiB
TypeScript

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 ALL evaluations for this teacher (not just by tenant, to avoid
// stale data from parallel test files with different teachers)
try {
runSql(`DELETE FROM grade_events WHERE grade_id IN (SELECT g.id FROM grades g JOIN evaluations e ON g.evaluation_id = e.id WHERE e.tenant_id = '${TENANT_ID}' AND e.teacher_id IN (SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}'))`);
runSql(`DELETE FROM grades WHERE evaluation_id IN (SELECT id FROM evaluations WHERE tenant_id = '${TENANT_ID}' AND teacher_id IN (SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}'))`);
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 grade_events WHERE grade_id IN (SELECT g.id FROM grades g JOIN evaluations e ON g.evaluation_id = e.id WHERE e.teacher_id IN (SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}') AND e.class_id IN (SELECT id FROM school_classes WHERE name = 'E2E-EVAL-5B' AND tenant_id = '${TENANT_ID}'))`);
runSql(`DELETE FROM grades WHERE evaluation_id IN (SELECT id 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 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 });
});
});
});