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

518 lines
20 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);
// Extract port from PLAYWRIGHT_BASE_URL or use default (4173 matches playwright.config.ts)
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}`;
// Test credentials for authenticated tests
const ADMIN_EMAIL = 'e2e-dashboard-admin@example.com';
const ADMIN_PASSWORD = 'DashboardTest123';
test.describe('Dashboard', () => {
/**
* Navigate to the dashboard and wait for SvelteKit hydration.
* SSR renders the HTML immediately, but event handlers are only
* attached after client-side hydration completes.
*/
async function goToDashboard(page: import('@playwright/test').Page) {
await page.goto('/dashboard', { waitUntil: 'networkidle' });
await expect(page.locator('.demo-controls')).toBeVisible({ timeout: 5000 });
}
/**
* Switch to a demo role with retry logic to handle hydration timing.
* Retries the click until the button's active class confirms the switch.
*/
async function switchToDemoRole(
page: import('@playwright/test').Page,
roleName: string | RegExp
) {
const button = page.locator('.demo-controls button', { hasText: roleName });
await expect(async () => {
await button.click();
await expect(button).toHaveClass(/active/, { timeout: 1000 });
}).toPass({ timeout: 10000 });
}
// ============================================================================
// Demo Mode (unauthenticated) - Role Switcher
// ============================================================================
test.describe('Demo Mode', () => {
test('shows demo role switcher when not authenticated', async ({ page }) => {
await goToDashboard(page);
await expect(page).toHaveURL(/\/dashboard/);
await expect(page.getByText(/Démo - Changer de rôle/i)).toBeVisible();
});
test('page title is set correctly', async ({ page }) => {
await goToDashboard(page);
await expect(page).toHaveTitle(/tableau de bord/i);
});
test('demo role switcher has all 4 role buttons', async ({ page }) => {
await goToDashboard(page);
const demoControls = page.locator('.demo-controls');
await expect(demoControls).toBeVisible();
await expect(demoControls.getByRole('button', { name: 'Parent' })).toBeVisible();
await expect(demoControls.getByRole('button', { name: 'Enseignant' })).toBeVisible();
await expect(demoControls.getByRole('button', { name: /Élève/i })).toBeVisible();
await expect(demoControls.getByRole('button', { name: 'Admin' })).toBeVisible();
});
test('Parent role is selected by default', async ({ page }) => {
await goToDashboard(page);
const parentButton = page.locator('.demo-controls button', { hasText: 'Parent' });
await expect(parentButton).toHaveClass(/active/);
});
});
// ============================================================================
// Parent Dashboard View
// ============================================================================
test.describe('Parent Dashboard', () => {
test('shows Score Serenite card', async ({ page }) => {
await goToDashboard(page);
// Parent is the default demo role
await expect(page.getByText(/score sérénité/i).first()).toBeVisible();
});
test('shows serenity score with numeric value', async ({ page }) => {
await goToDashboard(page);
// The score card should display a number value
const scoreCard = page.locator('.serenity-card');
await expect(scoreCard).toBeVisible();
// Should have a numeric value followed by /100
await expect(scoreCard.locator('.value')).toBeVisible();
await expect(scoreCard.getByText('/100')).toBeVisible();
});
test('serenity score shows demo badge', async ({ page }) => {
await goToDashboard(page);
await expect(page.getByText(/données de démonstration/i)).toBeVisible();
});
test('shows placeholder sections for schedule, notes, and homework', async ({ page }) => {
await goToDashboard(page);
// These sections show as placeholders since hasRealData is false
await expect(page.getByRole('heading', { name: /emploi du temps/i })).toBeVisible();
await expect(page.getByRole('heading', { name: /notes récentes/i })).toBeVisible();
await expect(page.getByRole('heading', { name: /devoirs à venir/i })).toBeVisible();
});
test('placeholder sections show informative messages', async ({ page }) => {
await goToDashboard(page);
await expect(page.getByText(/l'emploi du temps sera disponible/i)).toBeVisible();
await expect(page.getByText(/les notes apparaîtront ici/i)).toBeVisible();
await expect(page.getByText(/les devoirs seront affichés ici/i)).toBeVisible();
});
test('onboarding banner is visible on first login', async ({ page }) => {
await goToDashboard(page);
// The onboarding banner should be visible (isFirstLogin=true initially)
await expect(page.getByText(/bienvenue sur classeo/i)).toBeVisible();
await expect(page.getByText(/score sérénité/i).first()).toBeVisible();
});
test('clicking serenity score opens explainer', async ({ page }) => {
await goToDashboard(page);
// Click the serenity score card
const scoreCard = page.locator('.serenity-card');
await expect(scoreCard).toBeVisible();
await scoreCard.click();
// The explainer modal/overlay should appear
// SerenityScoreExplainer should be visible after click
await expect(page.getByText(/cliquez pour en savoir plus/i)).toBeVisible();
});
});
// ============================================================================
// Teacher Dashboard View
// ============================================================================
test.describe('Teacher Dashboard', () => {
test('shows teacher dashboard header', async ({ page }) => {
await goToDashboard(page);
// Switch to teacher
await switchToDemoRole(page, 'Enseignant');
await expect(page.getByRole('heading', { name: /tableau de bord enseignant/i })).toBeVisible();
await expect(page.getByText(/bienvenue.*voici vos outils du jour/i)).toBeVisible();
});
test('shows quick action cards', async ({ page }) => {
await goToDashboard(page);
await switchToDemoRole(page, 'Enseignant');
await expect(page.getByText(/faire l'appel/i)).toBeVisible();
await expect(page.getByText(/saisir des notes/i)).toBeVisible();
await expect(page.getByText(/créer un devoir/i)).toBeVisible();
});
test('quick action cards are disabled in demo mode', async ({ page }) => {
await goToDashboard(page);
await switchToDemoRole(page, 'Enseignant');
// Action cards should be disabled since hasRealData=false
const actionCards = page.locator('.action-card');
const count = await actionCards.count();
expect(count).toBeGreaterThanOrEqual(3);
for (let i = 0; i < count; i++) {
await expect(actionCards.nth(i)).toBeDisabled();
}
});
test('shows placeholder sections for teacher data', async ({ page }) => {
await goToDashboard(page);
await switchToDemoRole(page, 'Enseignant');
await expect(page.getByRole('heading', { name: /mes classes aujourd'hui/i })).toBeVisible();
await expect(page.getByRole('heading', { name: /notes à saisir/i })).toBeVisible();
await expect(page.getByRole('heading', { name: /appels du jour/i })).toBeVisible();
await expect(page.getByRole('heading', { name: /statistiques rapides/i })).toBeVisible();
});
test('placeholder sections have informative messages', async ({ page }) => {
await goToDashboard(page);
await switchToDemoRole(page, 'Enseignant');
await expect(page.getByText(/vos classes apparaîtront ici/i)).toBeVisible();
await expect(page.getByText(/évaluations en attente de notation/i)).toBeVisible();
await expect(page.getByText(/les appels à effectuer/i)).toBeVisible();
await expect(page.getByText(/les statistiques de vos classes/i)).toBeVisible();
});
});
// ============================================================================
// Student Dashboard View
// ============================================================================
test.describe('Student Dashboard', () => {
test('shows student dashboard header', async ({ page }) => {
await goToDashboard(page);
// Switch to student
await switchToDemoRole(page, /Élève/i);
await expect(page.getByRole('heading', { name: /mon espace/i })).toBeVisible();
// Student is minor by default, so "ton" instead of "votre"
await expect(page.getByText(/bienvenue.*voici ton tableau de bord/i)).toBeVisible();
});
test('shows info banner for student in demo mode', async ({ page }) => {
await goToDashboard(page);
await switchToDemoRole(page, /Élève/i);
await expect(page.getByText(/ton emploi du temps, tes notes et tes devoirs/i)).toBeVisible();
});
test('shows placeholder sections for student data', async ({ page }) => {
await goToDashboard(page);
await switchToDemoRole(page, /Élève/i);
await expect(page.getByRole('heading', { name: /mon emploi du temps/i })).toBeVisible();
await expect(page.getByRole('heading', { name: /mes notes/i })).toBeVisible();
await expect(page.getByRole('heading', { name: /mes devoirs/i })).toBeVisible();
});
test('placeholder sections show minor-appropriate messages', async ({ page }) => {
await goToDashboard(page);
await switchToDemoRole(page, /Élève/i);
// Uses "ton/tes" for minors
await expect(page.getByText(/ton emploi du temps sera bientôt disponible/i)).toBeVisible();
await expect(page.getByText(/tes notes apparaîtront ici/i)).toBeVisible();
await expect(page.getByText(/tes devoirs s'afficheront ici/i)).toBeVisible();
});
});
// ============================================================================
// Admin Dashboard View
// ============================================================================
test.describe('Admin Dashboard', () => {
test('shows admin dashboard header', async ({ page }) => {
await goToDashboard(page);
// Switch to admin
await switchToDemoRole(page, 'Admin');
await expect(page.getByRole('heading', { name: /administration/i })).toBeVisible();
});
test('shows establishment name', async ({ page }) => {
await goToDashboard(page);
await switchToDemoRole(page, 'Admin');
// Demo data uses "École Alpha" as establishment name
await expect(page.getByText(/école alpha/i)).toBeVisible();
});
test('shows quick action links for admin', async ({ page }) => {
await goToDashboard(page);
await switchToDemoRole(page, 'Admin');
await expect(page.getByText(/gérer les utilisateurs/i)).toBeVisible();
await expect(page.getByText(/configurer les classes/i)).toBeVisible();
await expect(page.getByText(/gérer les matières/i)).toBeVisible();
await expect(page.getByText(/périodes scolaires/i)).toBeVisible();
await expect(page.getByText(/pédagogie/i)).toBeVisible();
});
test('admin quick action links have correct hrefs', async ({ page }) => {
await goToDashboard(page);
await switchToDemoRole(page, 'Admin');
// Verify action cards link to correct pages
const usersLink = page.locator('.action-card[href="/admin/users"]');
await expect(usersLink).toBeVisible();
const classesLink = page.locator('.action-card[href="/admin/classes"]');
await expect(classesLink).toBeVisible();
const subjectsLink = page.locator('.action-card[href="/admin/subjects"]');
await expect(subjectsLink).toBeVisible();
const periodsLink = page.locator('.action-card[href="/admin/academic-year/periods"]');
await expect(periodsLink).toBeVisible();
const pedagogyLink = page.locator('.action-card[href="/admin/pedagogy"]');
await expect(pedagogyLink).toBeVisible();
});
test('import action is disabled (bientot disponible)', async ({ page }) => {
await goToDashboard(page);
await switchToDemoRole(page, 'Admin');
await expect(page.getByText(/importer des données/i)).toBeVisible();
await expect(page.getByText(/bientôt disponible/i)).toBeVisible();
const importCard = page.locator('.action-card.disabled');
await expect(importCard).toBeVisible();
});
test('shows placeholder sections for admin stats', async ({ page }) => {
await goToDashboard(page);
await switchToDemoRole(page, 'Admin');
await expect(page.getByRole('heading', { name: /utilisateurs/i })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Configuration', exact: true })).toBeVisible();
await expect(page.getByRole('heading', { name: /activité récente/i })).toBeVisible();
});
});
// ============================================================================
// Role Switching
// ============================================================================
test.describe('Role Switching', () => {
test('switching from parent to teacher changes dashboard content', async ({ page }) => {
await goToDashboard(page);
// Verify parent view
await expect(page.getByText(/score sérénité/i).first()).toBeVisible();
// Switch to teacher
await switchToDemoRole(page, 'Enseignant');
// Parent content should be gone
await expect(page.locator('.serenity-card')).not.toBeVisible();
// Teacher content should appear
await expect(page.getByRole('heading', { name: /tableau de bord enseignant/i })).toBeVisible();
});
test('switching from teacher to student changes dashboard content', async ({ page }) => {
await goToDashboard(page);
// Switch to teacher first
await switchToDemoRole(page, 'Enseignant');
await expect(page.getByRole('heading', { name: /tableau de bord enseignant/i })).toBeVisible();
// Switch to student
await switchToDemoRole(page, /Élève/i);
// Teacher content should be gone
await expect(page.getByRole('heading', { name: /tableau de bord enseignant/i })).not.toBeVisible();
// Student content should appear
await expect(page.getByRole('heading', { name: /mon espace/i })).toBeVisible();
});
test('switching from student to admin changes dashboard content', async ({ page }) => {
await goToDashboard(page);
// Switch to student first
await switchToDemoRole(page, /Élève/i);
await expect(page.getByRole('heading', { name: /mon espace/i })).toBeVisible();
// Switch to admin
await switchToDemoRole(page, 'Admin');
// Student content should be gone
await expect(page.getByRole('heading', { name: /mon espace/i })).not.toBeVisible();
// Admin content should appear
await expect(page.getByRole('heading', { name: /administration/i })).toBeVisible();
});
test('active role button changes visual state', async ({ page }) => {
await goToDashboard(page);
// Parent should be active initially
const parentBtn = page.locator('.demo-controls button', { hasText: 'Parent' });
await expect(parentBtn).toHaveClass(/active/);
// Switch to teacher
await switchToDemoRole(page, 'Enseignant');
// Teacher should now be active, parent should not
const teacherBtn = page.locator('.demo-controls button', { hasText: 'Enseignant' });
await expect(teacherBtn).toHaveClass(/active/);
await expect(parentBtn).not.toHaveClass(/active/);
});
test('onboarding banner disappears after switching roles', async ({ page }) => {
await goToDashboard(page);
// Onboarding banner is visible initially (isFirstLogin=true)
await expect(page.getByText(/bienvenue sur classeo/i)).toBeVisible();
// Switch role - this calls switchDemoRole which sets isFirstLogin=false
await switchToDemoRole(page, 'Enseignant');
// Switch back to parent
await switchToDemoRole(page, 'Parent');
// Onboarding banner should no longer be visible
await expect(page.getByText(/bienvenue sur classeo/i)).not.toBeVisible();
});
});
// ============================================================================
// Admin Dashboard - Navigation from Quick Actions
// ============================================================================
test.describe('Admin Quick Action Navigation', () => {
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' }
);
});
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()
]);
}
test('clicking "Gerer les utilisateurs" navigates to users page', async ({ page }) => {
await loginAsAdmin(page);
// Admin dashboard should show after login (ROLE_ADMIN maps to admin view)
await expect(page.getByRole('heading', { name: /administration/i })).toBeVisible({ timeout: 10000 });
// Click users link
await page.locator('.action-card[href="/admin/users"]').click();
await expect(page).toHaveURL(/\/admin\/users/);
});
test('clicking "Configurer les classes" navigates to classes page', async ({ page }) => {
await loginAsAdmin(page);
await expect(page.getByRole('heading', { name: /administration/i })).toBeVisible({ timeout: 10000 });
await page.locator('.action-card[href="/admin/classes"]').click();
await expect(page).toHaveURL(/\/admin\/classes/);
});
test('clicking "Gerer les matieres" navigates to subjects page', async ({ page }) => {
await loginAsAdmin(page);
await expect(page.getByRole('heading', { name: /administration/i })).toBeVisible({ timeout: 10000 });
await page.locator('.action-card[href="/admin/subjects"]').click();
await expect(page).toHaveURL(/\/admin\/subjects/);
});
test('clicking "Periodes scolaires" navigates to periods page', async ({ page }) => {
await loginAsAdmin(page);
await expect(page.getByRole('heading', { name: /administration/i })).toBeVisible({ timeout: 10000 });
await page.locator('.action-card[href="/admin/academic-year/periods"]').click();
await expect(page).toHaveURL(/\/admin\/academic-year\/periods/);
});
test('clicking "Pedagogie" navigates to pedagogy page', async ({ page }) => {
await loginAsAdmin(page);
await expect(page.getByRole('heading', { name: /administration/i })).toBeVisible({ timeout: 10000 });
await page.locator('.action-card[href="/admin/pedagogy"]').click();
await expect(page).toHaveURL(/\/admin\/pedagogy/);
});
});
// ============================================================================
// Accessibility
// ============================================================================
test.describe('Accessibility', () => {
test('serenity score card has accessible label', async ({ page }) => {
await goToDashboard(page);
const scoreCard = page.locator('[aria-label*="Score Sérénité"]');
await expect(scoreCard).toBeVisible();
});
test('teacher quick actions have a visually hidden heading', async ({ page }) => {
await goToDashboard(page);
await switchToDemoRole(page, 'Enseignant');
// The "Actions rapides" heading exists but is sr-only
const actionsHeading = page.getByRole('heading', { name: /actions rapides/i });
await expect(actionsHeading).toBeAttached();
});
test('admin configuration actions have a visually hidden heading', async ({ page }) => {
await goToDashboard(page);
await switchToDemoRole(page, 'Admin');
const configHeading = page.getByRole('heading', { name: /actions de configuration/i });
await expect(configHeading).toBeAttached();
});
});
});
test.describe('Dashboard Components', () => {
test('demo data JSON is valid and accessible', async ({ page }) => {
// This tests that the demo data file is bundled correctly
await page.goto('/');
// The app should load without errors
await expect(page.locator('body')).toBeVisible();
});
});