Files
Classeo/frontend/e2e/grades.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

384 lines
15 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-grade-teacher@example.com';
const TEACHER_PASSWORD = 'GradeTest123';
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()
]);
}
// Deterministic IDs for test data
let evaluationId: string;
let classId: string;
let student1Id: string;
let student2Id: string;
test.describe('Grade Input Grid (Story 6.2)', () => {
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' }
);
const { schoolId, academicYearId } = resolveDeterministicIds();
// Create test class
const classOutput = execSync(
`docker compose -f "${composeFile}" exec -T php php -r '` +
`require "/app/vendor/autoload.php"; ` +
`echo Ramsey\\Uuid\\Uuid::uuid5("6ba7b814-9dad-11d1-80b4-00c04fd430c8","grade-class-${TENANT_ID}")->toString();` +
`' 2>&1`,
{ encoding: 'utf-8' }
).trim();
classId = classOutput;
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-GRADE-6B', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
);
// Create test subject
const subjectOutput = execSync(
`docker compose -f "${composeFile}" exec -T php php -r '` +
`require "/app/vendor/autoload.php"; ` +
`echo Ramsey\\Uuid\\Uuid::uuid5("6ba7b814-9dad-11d1-80b4-00c04fd430c8","grade-subject-${TENANT_ID}")->toString();` +
`' 2>&1`,
{ encoding: 'utf-8' }
).trim();
const subjectId = subjectOutput;
runSql(
`INSERT INTO subjects (id, tenant_id, school_id, name, code, status, created_at, updated_at) ` +
`VALUES ('${subjectId}', '${TENANT_ID}', '${schoolId}', 'E2E-GRADE-Sciences', 'E2GRDSCI', '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`
);
// Create 2 test students
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=e2e-grade-student1@example.com --password=Student123 --role=ROLE_ELEVE --firstName=Alice --lastName=Durand 2>&1`,
{ encoding: 'utf-8' }
);
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=e2e-grade-student2@example.com --password=Student123 --role=ROLE_ELEVE --firstName=Bob --lastName=Martin 2>&1`,
{ encoding: 'utf-8' }
);
// Assign students to class
const studentIds = execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "SELECT id FROM users WHERE email IN ('e2e-grade-student1@example.com','e2e-grade-student2@example.com') AND tenant_id='${TENANT_ID}' ORDER BY email" 2>&1`,
{ encoding: 'utf-8' }
);
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 test evaluation
const evalOutput = execSync(
`docker compose -f "${composeFile}" exec -T php php -r '` +
`require "/app/vendor/autoload.php"; ` +
`echo Ramsey\\Uuid\\Uuid::uuid5("6ba7b814-9dad-11d1-80b4-00c04fd430c8","grade-eval-${TENANT_ID}")->toString();` +
`' 2>&1`,
{ encoding: 'utf-8' }
).trim();
evaluationId = evalOutput;
clearCache();
});
test.beforeEach(async () => {
// Clean grades and recreate evaluation
runSql(`DELETE FROM grade_events WHERE grade_id IN (SELECT id FROM grades WHERE evaluation_id = '${evaluationId}')`);
runSql(`DELETE FROM grades WHERE evaluation_id = '${evaluationId}'`);
runSql(`DELETE FROM evaluations WHERE id = '${evaluationId}'`);
runSql(
`INSERT INTO evaluations (id, tenant_id, class_id, subject_id, teacher_id, title, evaluation_date, grade_scale, coefficient, status, grades_published_at, created_at, updated_at) ` +
`SELECT '${evaluationId}', '${TENANT_ID}', '${classId}', ` +
`(SELECT id FROM subjects WHERE code='E2GRDSCI' AND tenant_id='${TENANT_ID}' LIMIT 1), ` +
`u.id, 'E2E Contrôle Sciences', '2026-04-15', 20, 1.0, 'published', NULL, NOW(), NOW() ` +
`FROM users u WHERE u.email='${TEACHER_EMAIL}' AND u.tenant_id='${TENANT_ID}' ` +
`ON CONFLICT (id) DO UPDATE SET grades_published_at = NULL, updated_at = NOW()`
);
clearCache();
});
test.describe('Grade Grid Display', () => {
test('shows grade input grid with students', async ({ page }) => {
await loginAsTeacher(page);
await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/grades`);
// Should display evaluation title
await expect(page.getByRole('heading', { name: /E2E Contrôle Sciences/i })).toBeVisible({ timeout: 15000 });
// Should show grade inputs
const gradeInputs = page.locator('.grade-input');
await expect(gradeInputs.first()).toBeVisible({ timeout: 10000 });
});
test('"Saisir les notes" button navigates to grades 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 Sciences')).toBeVisible({ timeout: 10000 });
await page.getByRole('link', { name: /saisir les notes/i }).first().click();
await expect(page.getByRole('heading', { name: /E2E Contrôle Sciences/i })).toBeVisible({ timeout: 15000 });
});
});
test.describe('Grade Input', () => {
test('can enter a numeric grade', async ({ page }) => {
await loginAsTeacher(page);
await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/grades`);
await expect(page.locator('.grade-input').first()).toBeVisible({ timeout: 15000 });
const firstInput = page.locator('.grade-input').first();
await firstInput.fill('15.5');
// Should show the grade in status column
await expect(page.locator('.status-graded').first()).toBeVisible({ timeout: 5000 });
});
test('validates grade against scale maximum', async ({ page }) => {
await loginAsTeacher(page);
await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/grades`);
await expect(page.locator('.grade-input').first()).toBeVisible({ timeout: 15000 });
const firstInput = page.locator('.grade-input').first();
await firstInput.fill('25');
// Should show error
await expect(page.locator('.input-error-msg').first()).toBeVisible({ timeout: 5000 });
});
});
test.describe('Slash Commands', () => {
test('/abs marks student as absent', async ({ page }) => {
await loginAsTeacher(page);
await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/grades`);
await expect(page.locator('.grade-input').first()).toBeVisible({ timeout: 15000 });
const firstInput = page.locator('.grade-input').first();
await firstInput.fill('/abs');
await expect(page.locator('.status-absent').first()).toBeVisible({ timeout: 5000 });
});
test('/disp marks student as dispensed', async ({ page }) => {
await loginAsTeacher(page);
await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/grades`);
await expect(page.locator('.grade-input').first()).toBeVisible({ timeout: 15000 });
const firstInput = page.locator('.grade-input').first();
await firstInput.fill('/disp');
await expect(page.locator('.status-dispensed').first()).toBeVisible({ timeout: 5000 });
});
});
test.describe('Keyboard Navigation', () => {
test('Tab moves to next student', async ({ page }) => {
await loginAsTeacher(page);
await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/grades`);
await expect(page.locator('.grade-input').first()).toBeVisible({ timeout: 15000 });
const firstInput = page.locator('.grade-input').first();
await firstInput.focus();
await firstInput.press('Tab');
// Second input should be focused
const secondInput = page.locator('.grade-input').nth(1);
await expect(secondInput).toBeFocused({ timeout: 3000 });
});
test('Enter moves to next student', async ({ page }) => {
await loginAsTeacher(page);
await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/grades`);
await expect(page.locator('.grade-input').first()).toBeVisible({ timeout: 15000 });
const firstInput = page.locator('.grade-input').first();
await firstInput.fill('15');
await firstInput.press('Enter');
// Second input should be focused
const secondInput = page.locator('.grade-input').nth(1);
await expect(secondInput).toBeFocused({ timeout: 3000 });
});
test('Shift+Tab moves to previous student', async ({ page }) => {
await loginAsTeacher(page);
await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/grades`);
await expect(page.locator('.grade-input').first()).toBeVisible({ timeout: 15000 });
const secondInput = page.locator('.grade-input').nth(1);
await secondInput.focus();
await secondInput.press('Shift+Tab');
const firstInput = page.locator('.grade-input').first();
await expect(firstInput).toBeFocused({ timeout: 3000 });
});
});
test.describe('Publication', () => {
test('publish requires confirmation via modal', async ({ page }) => {
clearCache();
await loginAsTeacher(page);
await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/grades`);
await page.waitForLoadState('networkidle');
await expect(page.locator('.grade-input').first()).toBeVisible({ timeout: 15000 });
// Start listening for the PUT response BEFORE triggering the save
const savePromise = page.waitForResponse(
(resp) => resp.url().includes('/grades') && resp.request().method() === 'PUT',
{ timeout: 30000 }
);
// Enter grades
const firstInput = page.locator('.grade-input').first();
await expect(firstInput).toBeVisible({ timeout: 5000 });
await firstInput.fill('18');
await expect(page.locator('.status-graded').first()).toContainText('18/20', { timeout: 10000 });
// Wait for the PUT to complete
await savePromise;
// Click publish button — opens confirmation modal
await page.getByRole('button', { name: /publier les notes/i }).click();
// Confirmation dialog should appear
const dialog = page.getByRole('alertdialog');
await expect(dialog).toBeVisible({ timeout: 5000 });
await expect(dialog.getByText(/cette action est irréversible/i)).toBeVisible();
// Start listening for POST before clicking confirm
const publishPromise = page.waitForResponse(
(resp) => resp.url().includes('/publish') && resp.request().method() === 'POST',
{ timeout: 30000 }
);
// Confirm publication
await dialog.getByRole('button', { name: /confirmer la publication/i }).click();
// Wait for the POST to complete
await publishPromise;
// Should show published badge
await expect(page.getByText(/notes publiées/i)).toBeVisible({ timeout: 10000 });
});
test('publish modal can be cancelled', async ({ page }) => {
clearCache();
await loginAsTeacher(page);
await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/grades`);
await page.waitForLoadState('networkidle');
await expect(page.locator('.grade-input').first()).toBeVisible({ timeout: 15000 });
// Start listening for PUT before triggering
const savePromise = page.waitForResponse(
(resp) => resp.url().includes('/grades') && resp.request().method() === 'PUT',
{ timeout: 30000 }
);
// Enter a grade
const firstInput = page.locator('.grade-input').first();
await expect(firstInput).toBeVisible({ timeout: 5000 });
await firstInput.fill('14');
await expect(page.locator('.status-graded').first()).toContainText('14/20', { timeout: 10000 });
// Wait for save to complete
await savePromise;
const publishBtn = page.getByRole('button', { name: /publier les notes/i });
// Open modal then cancel
await publishBtn.click();
const dialog = page.getByRole('alertdialog');
await expect(dialog).toBeVisible({ timeout: 5000 });
await expect(dialog.getByText(/cette action est irréversible/i)).toBeVisible();
await dialog.getByRole('button', { name: /annuler/i }).click();
// Modal should close, publish button still visible
await expect(dialog).not.toBeVisible({ timeout: 3000 });
await expect(publishBtn).toBeVisible();
});
});
});