Files
Classeo/frontend/e2e/pedagogy.spec.ts
Mathias STRASSER 76e16db0d8 feat: Pagination et recherche des sections admin
Les listes admin (utilisateurs, classes, matières, affectations) chargeaient
toutes les données d'un coup, ce qui dégradait l'expérience avec un volume
croissant. La pagination côté serveur existait dans la config API Platform
mais aucun Provider ne l'exploitait.

Cette implémentation ajoute la pagination serveur (30 items/page, max 100)
avec recherche textuelle sur toutes les sections, des composants frontend
réutilisables (Pagination + SearchInput avec debounce), et la synchronisation
URL pour le partage de liens filtrés.

Les Query valident leurs paramètres (clamp page/limit, trim search) pour
éviter les abus. Les affectations utilisent des lookup maps pour résoudre
les noms sans N+1 queries. Les pages admin gèrent les race conditions
via AbortController.
2026-02-15 13:54:51 +01:00

282 lines
10 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: 30000 }),
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`);
const pedagogyLink = page.getByRole('link', { 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`);
await page.getByRole('link', { 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 });
});
});