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.
288 lines
11 KiB
TypeScript
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 });
|
|
});
|
|
});
|