Files
Classeo/frontend/e2e/dashboard.spec.ts
Mathias STRASSER aedde6707e
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-03-31 16:43:10 +02:00

589 lines
24 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');
// First two action cards should be disabled since hasRealData=false
// "Créer un devoir" navigates to homework page and is always enabled
const actionCards = page.locator('.action-card');
const count = await actionCards.count();
expect(count).toBeGreaterThanOrEqual(3);
await expect(actionCards.nth(0)).toBeDisabled();
await expect(actionCards.nth(1)).toBeDisabled();
await expect(actionCards.nth(2)).toBeEnabled();
});
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('shows import action cards for students and teachers', async ({ page }) => {
await goToDashboard(page);
await switchToDemoRole(page, 'Admin');
const studentImport = page.getByRole('link', { name: /importer des élèves/i });
await expect(studentImport).toBeVisible();
await expect(studentImport).toHaveAttribute('href', '/admin/import/students');
const teacherImport = page.getByRole('link', { name: /importer des enseignants/i });
await expect(teacherImport).toBeVisible();
await expect(teacherImport).toHaveAttribute('href', '/admin/import/teachers');
});
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();
});
test('shows section titles grouping action cards', async ({ page }) => {
await goToDashboard(page);
await switchToDemoRole(page, 'Admin');
await expect(page.getByRole('heading', { name: 'Personnes', exact: true })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Organisation', exact: true })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Année scolaire', exact: true })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Paramètres', exact: true })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Imports', exact: true })).toBeVisible();
});
test('groups correct cards within each section', async ({ page }) => {
await goToDashboard(page);
await switchToDemoRole(page, 'Admin');
// Personnes: Utilisateurs, Invitations parents, Élèves
const personnes = page.locator('section[aria-labelledby="section-personnes"]');
await expect(personnes.locator('.action-card')).toHaveCount(3);
await expect(personnes.locator('.action-card[href="/admin/users"]')).toBeVisible();
await expect(personnes.locator('.action-card[href="/admin/parent-invitations"]')).toBeVisible();
await expect(personnes.locator('.action-card[href="/admin/students"]')).toBeVisible();
// Organisation: Classes, Matières, Affectations, Remplacements, Emploi du temps
const organisation = page.locator('section[aria-labelledby="section-organisation"]');
await expect(organisation.locator('.action-card')).toHaveCount(5);
await expect(organisation.locator('.action-card[href="/admin/classes"]')).toBeVisible();
await expect(organisation.locator('.action-card[href="/admin/subjects"]')).toBeVisible();
await expect(organisation.locator('.action-card[href="/admin/assignments"]')).toBeVisible();
await expect(organisation.locator('.action-card[href="/admin/replacements"]')).toBeVisible();
await expect(organisation.locator('.action-card[href="/admin/schedule"]')).toBeVisible();
// Année scolaire: Périodes scolaires, Calendrier
const anneeScolaire = page.locator('section[aria-labelledby="section-annee-scolaire"]');
await expect(anneeScolaire.locator('.action-card')).toHaveCount(2);
await expect(anneeScolaire.locator('.action-card[href="/admin/academic-year/periods"]')).toBeVisible();
await expect(anneeScolaire.locator('.action-card[href="/admin/calendar"]')).toBeVisible();
// Paramètres: Droit à l'image, Pédagogie, Identité visuelle, Règles de devoirs
const parametres = page.locator('section[aria-labelledby="section-parametres"]');
await expect(parametres.locator('.action-card')).toHaveCount(4);
await expect(parametres.locator('.action-card[href="/admin/image-rights"]')).toBeVisible();
await expect(parametres.locator('.action-card[href="/admin/pedagogy"]')).toBeVisible();
await expect(parametres.locator('.action-card[href="/admin/branding"]')).toBeVisible();
await expect(parametres.locator('.action-card[href="/admin/homework-rules"]')).toBeVisible();
// Imports: Importer des élèves, Importer des enseignants
const imports = page.locator('section[aria-labelledby="section-imports"]');
await expect(imports.locator('.action-card')).toHaveCount(2);
await expect(imports.locator('.action-card[href="/admin/import/students"]')).toBeVisible();
await expect(imports.locator('.action-card[href="/admin/import/teachers"]')).toBeVisible();
});
test('sections are displayed in the expected order', async ({ page }) => {
await goToDashboard(page);
await switchToDemoRole(page, 'Admin');
const sectionTitles = page.locator('.dashboard-section .section-title');
await expect(sectionTitles).toHaveCount(8);
await expect(sectionTitles.nth(0)).toHaveText('Personnes');
await expect(sectionTitles.nth(1)).toHaveText('Organisation');
await expect(sectionTitles.nth(2)).toHaveText('Année scolaire');
await expect(sectionTitles.nth(3)).toHaveText('Paramètres');
await expect(sectionTitles.nth(4)).toHaveText('Imports');
await expect(sectionTitles.nth(5)).toHaveText('Utilisateurs');
await expect(sectionTitles.nth(6)).toHaveText('Configuration');
await expect(sectionTitles.nth(7)).toHaveText('Activité récente');
});
});
// ============================================================================
// 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: 60000 }),
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();
});
});