Files
Classeo/frontend/e2e/pedagogy.spec.ts
Mathias STRASSER b7dc27f2a5
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: Calculer automatiquement les moyennes après chaque saisie de notes
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.
2026-04-04 02:25:00 +02:00

288 lines
11 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 ADMIN_EMAIL = 'e2e-pedagogy-admin@example.com';
const ADMIN_PASSWORD = 'PedagogyTest123';
test.describe('Pedagogy - Grading Mode Configuration (Story 2.4)', () => {
test.beforeAll(async () => {
const projectRoot = join(__dirname, '../..');
const composeFile = join(projectRoot, 'compose.yaml');
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${ADMIN_EMAIL} --password=${ADMIN_PASSWORD} --role=ROLE_ADMIN 2>&1`,
{ encoding: 'utf-8' }
);
// Reset grading mode to default (numeric_20) to ensure clean state
try {
execSync(
`docker compose -f "${composeFile}" exec -T php php -r "
require 'vendor/autoload.php';
\\$kernel = new App\\Kernel('dev', true);
\\$kernel->boot();
\\$conn = \\$kernel->getContainer()->get('doctrine')->getConnection();
\\$conn->executeStatement('DELETE FROM school_grading_configurations');
" 2>&1`,
{ encoding: 'utf-8' }
);
} catch {
// Table might not exist yet, that's OK — default mode applies
}
});
async function loginAsAdmin(page: import('@playwright/test').Page) {
await page.goto(`${ALPHA_URL}/login`);
await page.locator('#email').fill(ADMIN_EMAIL);
await page.locator('#password').fill(ADMIN_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}
// =========================================================================
// Navigation
// =========================================================================
test('pedagogy link is visible in admin navigation', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/classes`);
// Hover "Paramètres" category to reveal dropdown
const nav = page.locator('.desktop-nav');
await nav.getByRole('button', { name: /paramètres/i }).hover();
const pedagogyLink = nav.getByRole('menuitem', { name: /pédagogie/i });
await expect(pedagogyLink).toBeVisible();
});
test('pedagogy link navigates to pedagogy page', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/classes`);
// Hover "Paramètres" category to reveal dropdown
const nav = page.locator('.desktop-nav');
await nav.getByRole('button', { name: /paramètres/i }).hover();
await nav.getByRole('menuitem', { name: /pédagogie/i }).click();
await expect(page).toHaveURL(/\/admin\/pedagogy/, { timeout: 10000 });
});
test('pedagogy card is visible on admin dashboard', async ({ page }) => {
await loginAsAdmin(page);
// Authenticated admin sees admin dashboard directly via role context
await page.goto(`${ALPHA_URL}/dashboard`);
await expect(page.getByRole('heading', { name: /administration/i })).toBeVisible({
timeout: 10000
});
const pedagogyCard = page.getByRole('link', { name: /pédagogie/i });
await expect(pedagogyCard).toBeVisible();
});
// =========================================================================
// AC1: Display grading mode options
// =========================================================================
test('shows page title and subtitle', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/pedagogy`);
await expect(page.getByRole('heading', { name: /mode de notation/i })).toBeVisible();
await expect(page.getByText(/système d'évaluation/i)).toBeVisible();
});
test('shows current mode banner', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/pedagogy`);
// Wait for loading to finish
await page.waitForLoadState('networkidle');
// Should show "Mode actuel" banner
await expect(page.getByText(/mode actuel/i)).toBeVisible({ timeout: 10000 });
});
test('displays all five grading modes', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/pedagogy`);
await page.waitForLoadState('networkidle');
// All 5 mode cards should be visible (scope to .mode-card to avoid ambiguous matches)
const modeCards = page.locator('.mode-card');
await expect(modeCards).toHaveCount(5, { timeout: 10000 });
await expect(modeCards.filter({ hasText: /notes \/20/i })).toBeVisible();
await expect(modeCards.filter({ hasText: /notes \/10/i })).toBeVisible();
await expect(modeCards.filter({ hasText: /lettres/i })).toBeVisible();
await expect(modeCards.filter({ hasText: /compétences/i })).toBeVisible();
await expect(modeCards.filter({ hasText: /sans notes/i })).toBeVisible();
});
test('default mode is Notes sur 20', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/pedagogy`);
await page.waitForLoadState('networkidle');
// The "Notes /20" mode card should be selected (has mode-selected class)
const selectedCard = page.locator('.mode-card.mode-selected');
await expect(selectedCard).toBeVisible({ timeout: 10000 });
await expect(selectedCard).toContainText(/notes \/20/i);
});
// =========================================================================
// AC1: Year selector
// =========================================================================
test('can switch between year tabs', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/pedagogy`);
const tabs = page.getByRole('tab');
// Wait for page to load
await expect(tabs.nth(1)).toHaveAttribute('aria-selected', 'true', { timeout: 10000 });
await page.waitForLoadState('networkidle');
// Click next year tab
await tabs.nth(2).click();
await expect(tabs.nth(2)).toHaveAttribute('aria-selected', 'true', { timeout: 10000 });
// Click previous year tab
await tabs.nth(0).click();
await expect(tabs.nth(0)).toHaveAttribute('aria-selected', 'true', { timeout: 10000 });
});
// =========================================================================
// AC2-4: Mode selection and preview
// =========================================================================
test('selecting a different mode shows save button', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/pedagogy`);
await page.waitForLoadState('networkidle');
// Save button should not be visible initially
await expect(page.getByRole('button', { name: /enregistrer/i })).not.toBeVisible();
// Click a different mode
const competenciesCard = page.locator('.mode-card').filter({ hasText: /compétences/i });
await competenciesCard.click();
// Save and cancel buttons should appear
await expect(page.getByRole('button', { name: /enregistrer/i })).toBeVisible();
await expect(page.getByRole('button', { name: /annuler/i })).toBeVisible();
});
test('cancel button reverts mode selection', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/pedagogy`);
await page.waitForLoadState('networkidle');
// Select a different mode
const lettersCard = page.locator('.mode-card').filter({ hasText: /lettres/i });
await lettersCard.click();
// Click cancel
await page.getByRole('button', { name: /annuler/i }).click();
// Save button should disappear
await expect(page.getByRole('button', { name: /enregistrer/i })).not.toBeVisible();
});
test('selecting competencies mode shows competency-specific description', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/pedagogy`);
await page.waitForLoadState('networkidle');
await expect(page.getByText(/mode actuel/i)).toBeVisible({ timeout: 10000 });
const competenciesCard = page.locator('.mode-card').filter({ hasText: /compétences/i });
await competenciesCard.click();
// Check description inside the card (scoped to avoid matching bulletin impact text)
await expect(competenciesCard.locator('.mode-description')).toContainText(
/acquis.*en cours.*non acquis/i
);
});
test('selecting sans notes mode shows no-grades description', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/pedagogy`);
await page.waitForLoadState('networkidle');
await expect(page.getByText(/mode actuel/i)).toBeVisible({ timeout: 10000 });
const noGradesCard = page.locator('.mode-card').filter({ hasText: /sans notes/i });
await noGradesCard.click();
// Check description inside the card (scoped to avoid matching bulletin impact text)
await expect(noGradesCard.locator('.mode-description')).toContainText(
/appréciations textuelles/i
);
});
test('shows bulletin impact preview when mode is selected', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/pedagogy`);
await page.waitForLoadState('networkidle');
// Impact section should be visible
await expect(page.getByText(/impact sur les bulletins/i)).toBeVisible({ timeout: 10000 });
});
// =========================================================================
// AC2: Can change to a different mode
// =========================================================================
test('can save a new grading mode', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/pedagogy`);
await page.waitForLoadState('networkidle');
await expect(page.getByText(/mode actuel/i)).toBeVisible({ timeout: 10000 });
// Select "Notes sur 10"
const numeric10Card = page.locator('.mode-card').filter({ hasText: /notes \/10/i });
await numeric10Card.click();
// Save
await page.getByRole('button', { name: /enregistrer/i }).click();
// Success message should appear
await expect(page.getByText(/mis à jour avec succès/i)).toBeVisible({ timeout: 10000 });
// The selected mode should now be "Notes /10"
const selectedCard = page.locator('.mode-card.mode-selected');
await expect(selectedCard).toContainText(/notes \/10/i);
// Reload the page to verify server-side persistence
await page.reload();
await page.waitForLoadState('networkidle');
await expect(page.locator('.mode-card.mode-selected')).toContainText(/notes \/10/i, {
timeout: 10000
});
// Restore default mode for other tests
const numeric20Card = page.locator('.mode-card').filter({ hasText: /notes \/20/i });
await numeric20Card.click();
await page.getByRole('button', { name: /enregistrer/i }).click();
await expect(page.getByText(/mis à jour avec succès/i)).toBeVisible({ timeout: 10000 });
});
});