Les établissements scolaires utilisent des systèmes d'évaluation variés (notes /20, /10, lettres, compétences, sans notes). Jusqu'ici l'application imposait implicitement le mode notes /20, ce qui ne correspondait pas à la réalité pédagogique de nombreuses écoles. Cette configuration permet à chaque établissement de choisir son mode de notation par année scolaire, avec verrouillage automatique dès que des notes ont été saisies pour éviter les incohérences. Le Score Sérénité adapte ses pondérations selon le mode choisi (les compétences sont converties via un mapping, le mode sans notes exclut la composante notes).
458 lines
18 KiB
TypeScript
458 lines
18 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
|
|
const ADMIN_EMAIL = 'e2e-classes-admin@example.com';
|
|
const ADMIN_PASSWORD = 'ClassesTest123';
|
|
|
|
// Force serial execution to ensure Empty State runs first
|
|
test.describe.configure({ mode: 'serial' });
|
|
|
|
test.describe('Classes Management (Story 2.1)', () => {
|
|
// Create admin user and clean up classes before running tests
|
|
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' }
|
|
);
|
|
|
|
execSync(
|
|
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "DELETE FROM school_classes WHERE tenant_id = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'" 2>&1`,
|
|
{ encoding: 'utf-8' }
|
|
);
|
|
});
|
|
|
|
// Helper to login as admin
|
|
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 page.getByRole('button', { name: /se connecter/i }).click();
|
|
await expect(page).toHaveURL(/\/dashboard/, { timeout: 10000 });
|
|
}
|
|
|
|
// Helper to open "Nouvelle classe" dialog with proper wait
|
|
async function openNewClassDialog(page: import('@playwright/test').Page) {
|
|
const button = page.getByRole('button', { name: /nouvelle classe/i });
|
|
await button.waitFor({ state: 'visible' });
|
|
|
|
// Wait for any pending network requests to finish before clicking
|
|
await page.waitForLoadState('networkidle');
|
|
|
|
// Click the button
|
|
await button.click();
|
|
|
|
// Wait for dialog to appear - retry click if needed (webkit timing issue)
|
|
const dialog = page.getByRole('dialog');
|
|
try {
|
|
await expect(dialog).toBeVisible({ timeout: 5000 });
|
|
} catch {
|
|
// Retry once - webkit sometimes needs a second click
|
|
await button.click();
|
|
await expect(dialog).toBeVisible({ timeout: 10000 });
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// EMPTY STATE - Must run FIRST before any class is created
|
|
// ============================================================================
|
|
test.describe('Empty State', () => {
|
|
test('shows empty state message when no classes exist', async ({ page }) => {
|
|
// Clean up classes right before this specific test to avoid race conditions with parallel browsers
|
|
const projectRoot = join(__dirname, '../..');
|
|
const composeFile = join(projectRoot, 'compose.yaml');
|
|
try {
|
|
execSync(
|
|
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "DELETE FROM school_classes WHERE tenant_id = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'" 2>&1`,
|
|
{ encoding: 'utf-8' }
|
|
);
|
|
} catch {
|
|
// Ignore cleanup errors
|
|
}
|
|
|
|
await loginAsAdmin(page);
|
|
await page.goto(`${ALPHA_URL}/admin/classes`);
|
|
|
|
// Wait for page to load
|
|
await expect(page.getByRole('heading', { name: /gestion des classes/i })).toBeVisible();
|
|
|
|
// Should show empty state
|
|
await expect(page.locator('.empty-state')).toBeVisible();
|
|
await expect(page.getByText(/aucune classe/i)).toBeVisible();
|
|
await expect(page.getByRole('button', { name: /créer une classe/i })).toBeVisible();
|
|
});
|
|
});
|
|
|
|
// ============================================================================
|
|
// List Display
|
|
// ============================================================================
|
|
test.describe('List Display', () => {
|
|
test('displays all created classes in the list', async ({ page }) => {
|
|
await loginAsAdmin(page);
|
|
await page.goto(`${ALPHA_URL}/admin/classes`);
|
|
|
|
// Create multiple classes
|
|
const classNames = [
|
|
`Liste-6emeA-${Date.now()}`,
|
|
`Liste-6emeB-${Date.now()}`,
|
|
`Liste-5emeA-${Date.now()}`,
|
|
];
|
|
|
|
for (const name of classNames) {
|
|
await openNewClassDialog(page);
|
|
await page.locator('#class-name').fill(name);
|
|
await page.getByRole('button', { name: /créer la classe/i }).click();
|
|
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
|
|
}
|
|
|
|
// Verify ALL classes appear in the list
|
|
for (const name of classNames) {
|
|
await expect(page.getByText(name)).toBeVisible();
|
|
}
|
|
|
|
// Verify the number of class cards matches (at least the ones we created)
|
|
const classCards = page.locator('.class-card');
|
|
const count = await classCards.count();
|
|
expect(count).toBeGreaterThanOrEqual(classNames.length);
|
|
});
|
|
|
|
test('displays class details correctly (level, capacity)', async ({ page }) => {
|
|
await loginAsAdmin(page);
|
|
await page.goto(`${ALPHA_URL}/admin/classes`);
|
|
|
|
// Create a class with all details
|
|
const className = `Details-${Date.now()}`;
|
|
await openNewClassDialog(page);
|
|
await page.locator('#class-name').fill(className);
|
|
await page.locator('#class-level').selectOption('CM2');
|
|
await page.locator('#class-capacity').fill('25');
|
|
await page.getByRole('button', { name: /créer la classe/i }).click();
|
|
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
|
|
|
|
// Find the class card
|
|
const classCard = page.locator('.class-card', { hasText: className });
|
|
await expect(classCard).toBeVisible();
|
|
|
|
// Verify details are displayed
|
|
await expect(classCard.getByText('CM2')).toBeVisible();
|
|
await expect(classCard.getByText('25 places')).toBeVisible();
|
|
await expect(classCard.getByText('Active')).toBeVisible();
|
|
});
|
|
});
|
|
|
|
// ============================================================================
|
|
// AC1: Class Creation
|
|
// ============================================================================
|
|
test.describe('AC1: Class Creation', () => {
|
|
test('can create a new class with all fields', async ({ page }) => {
|
|
await loginAsAdmin(page);
|
|
|
|
// Navigate to classes page
|
|
await page.goto(`${ALPHA_URL}/admin/classes`);
|
|
await expect(page.getByRole('heading', { name: /gestion des classes/i })).toBeVisible();
|
|
|
|
// Click "Nouvelle classe" button
|
|
await openNewClassDialog(page);
|
|
await expect(page.getByRole('heading', { name: /nouvelle classe/i })).toBeVisible();
|
|
|
|
// Fill form
|
|
const uniqueName = `Test-E2E-${Date.now()}`;
|
|
await page.locator('#class-name').fill(uniqueName);
|
|
await page.locator('#class-level').selectOption('6ème');
|
|
await page.locator('#class-capacity').fill('30');
|
|
|
|
// Submit
|
|
await page.getByRole('button', { name: /créer la classe/i }).click();
|
|
|
|
// Modal should close and class should appear in list
|
|
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
|
|
await expect(page.getByText(uniqueName)).toBeVisible({ timeout: 10000 });
|
|
});
|
|
|
|
test('can create a class with only required fields (name)', async ({ page }) => {
|
|
await loginAsAdmin(page);
|
|
await page.goto(`${ALPHA_URL}/admin/classes`);
|
|
|
|
await openNewClassDialog(page);
|
|
|
|
// Fill only the name (required)
|
|
const uniqueName = `Minimal-${Date.now()}`;
|
|
await page.locator('#class-name').fill(uniqueName);
|
|
|
|
// Submit button should be enabled
|
|
const submitButton = page.getByRole('button', { name: /créer la classe/i });
|
|
await expect(submitButton).toBeEnabled();
|
|
|
|
await submitButton.click();
|
|
|
|
// Class should be created
|
|
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
|
|
await expect(page.getByText(uniqueName)).toBeVisible({ timeout: 10000 });
|
|
});
|
|
|
|
test('submit button is disabled when name is empty', async ({ page }) => {
|
|
await loginAsAdmin(page);
|
|
await page.goto(`${ALPHA_URL}/admin/classes`);
|
|
|
|
await openNewClassDialog(page);
|
|
|
|
// Don't fill the name
|
|
const submitButton = page.getByRole('button', { name: /créer la classe/i });
|
|
await expect(submitButton).toBeDisabled();
|
|
|
|
// Fill level and capacity but not name
|
|
await page.locator('#class-level').selectOption('CE1');
|
|
await page.locator('#class-capacity').fill('25');
|
|
await expect(submitButton).toBeDisabled();
|
|
|
|
// Fill name - button should enable
|
|
await page.locator('#class-name').fill('Test');
|
|
await expect(submitButton).toBeEnabled();
|
|
});
|
|
|
|
test('can cancel class creation', async ({ page }) => {
|
|
await loginAsAdmin(page);
|
|
await page.goto(`${ALPHA_URL}/admin/classes`);
|
|
|
|
await openNewClassDialog(page);
|
|
|
|
// Fill form
|
|
await page.locator('#class-name').fill('Should-Not-Be-Created');
|
|
|
|
// Click cancel
|
|
await page.getByRole('button', { name: /annuler/i }).click();
|
|
|
|
// Modal should close
|
|
await expect(page.getByRole('dialog')).not.toBeVisible();
|
|
|
|
// Class should not appear in list
|
|
await expect(page.getByText('Should-Not-Be-Created')).not.toBeVisible();
|
|
});
|
|
});
|
|
|
|
// ============================================================================
|
|
// AC2: Class Modification
|
|
// ============================================================================
|
|
test.describe('AC2: Class Modification', () => {
|
|
test('can modify an existing class', async ({ page }) => {
|
|
await loginAsAdmin(page);
|
|
await page.goto(`${ALPHA_URL}/admin/classes`);
|
|
|
|
// First create a class to modify
|
|
await openNewClassDialog(page);
|
|
const originalName = `ToModify-${Date.now()}`;
|
|
await page.locator('#class-name').fill(originalName);
|
|
await page.locator('#class-level').selectOption('CM1');
|
|
await page.getByRole('button', { name: /créer la classe/i }).click();
|
|
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
|
|
|
|
// Find the class card and click modify
|
|
const classCard = page.locator('.class-card', { hasText: originalName });
|
|
await classCard.getByRole('button', { name: /modifier/i }).click();
|
|
|
|
// Should navigate to edit page
|
|
await expect(page).toHaveURL(/\/admin\/classes\/[\w-]+/);
|
|
await expect(page.getByRole('heading', { name: /modifier la classe/i })).toBeVisible();
|
|
|
|
// Modify the name
|
|
const newName = `Modified-${Date.now()}`;
|
|
await page.locator('#class-name').fill(newName);
|
|
await page.locator('#class-level').selectOption('CM2');
|
|
await page.locator('#class-capacity').fill('28');
|
|
|
|
// Save
|
|
await page.getByRole('button', { name: /enregistrer/i }).click();
|
|
|
|
// Should show success message
|
|
await expect(page.getByText(/modifiée avec succès/i)).toBeVisible({ timeout: 10000 });
|
|
|
|
// Go back to list and verify
|
|
await page.goto(`${ALPHA_URL}/admin/classes`);
|
|
await expect(page.getByText(newName)).toBeVisible();
|
|
});
|
|
|
|
test('can cancel modification', async ({ page }) => {
|
|
await loginAsAdmin(page);
|
|
await page.goto(`${ALPHA_URL}/admin/classes`);
|
|
|
|
// Create a class
|
|
await openNewClassDialog(page);
|
|
const originalName = `NoChange-${Date.now()}`;
|
|
await page.locator('#class-name').fill(originalName);
|
|
await page.getByRole('button', { name: /créer la classe/i }).click();
|
|
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
|
|
|
|
// Click modify
|
|
const classCard = page.locator('.class-card', { hasText: originalName });
|
|
await classCard.getByRole('button', { name: /modifier/i }).click();
|
|
|
|
// Modify but cancel
|
|
await page.locator('#class-name').fill('Should-Not-Change');
|
|
await page.getByRole('button', { name: /annuler/i }).click();
|
|
|
|
// Should go back to list
|
|
await expect(page).toHaveURL(/\/admin\/classes$/);
|
|
|
|
// Original name should still be there
|
|
await expect(page.getByText(originalName)).toBeVisible();
|
|
await expect(page.getByText('Should-Not-Change')).not.toBeVisible();
|
|
});
|
|
});
|
|
|
|
// ============================================================================
|
|
// AC3: Deletion blocked if students assigned
|
|
// ============================================================================
|
|
test.describe('AC3: Deletion blocked if students assigned', () => {
|
|
// SKIP REASON: The Students module is not yet implemented.
|
|
// HasStudentsInClassHandler currently returns 0 (stub), so all classes
|
|
// appear empty and can be deleted. This test will be enabled once the
|
|
// Students module allows assigning students to classes.
|
|
//
|
|
// When enabled, this test should:
|
|
// 1. Create a class
|
|
// 2. Assign at least one student to it
|
|
// 3. Attempt to delete the class
|
|
// 4. Verify the error message "Vous devez d'abord réaffecter les élèves"
|
|
// 5. Verify the class still exists
|
|
test.skip('shows warning when trying to delete class with students', async ({ page }) => {
|
|
await loginAsAdmin(page);
|
|
await page.goto(`${ALPHA_URL}/admin/classes`);
|
|
// Implementation pending Students module
|
|
});
|
|
});
|
|
|
|
// ============================================================================
|
|
// AC4: Empty class deletion (soft delete)
|
|
// ============================================================================
|
|
test.describe('AC4: Empty class deletion (soft delete)', () => {
|
|
test('can delete an empty class', async ({ page }) => {
|
|
await loginAsAdmin(page);
|
|
await page.goto(`${ALPHA_URL}/admin/classes`);
|
|
|
|
// Create a class to delete
|
|
await openNewClassDialog(page);
|
|
const className = `ToDelete-${Date.now()}`;
|
|
await page.locator('#class-name').fill(className);
|
|
await page.getByRole('button', { name: /créer la classe/i }).click();
|
|
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
|
|
await expect(page.getByText(className)).toBeVisible();
|
|
|
|
// Find and click delete button
|
|
const classCard = page.locator('.class-card', { hasText: className });
|
|
await classCard.getByRole('button', { name: /supprimer/i }).click();
|
|
|
|
// Confirmation modal should appear
|
|
const deleteModal = page.getByRole('alertdialog');
|
|
await expect(deleteModal).toBeVisible({ timeout: 10000 });
|
|
await expect(deleteModal.getByText(className)).toBeVisible();
|
|
|
|
// Confirm deletion
|
|
await deleteModal.getByRole('button', { name: /supprimer/i }).click();
|
|
|
|
// Modal should close and class should no longer appear in list
|
|
await expect(deleteModal).not.toBeVisible({ timeout: 10000 });
|
|
await expect(page.getByText(className)).not.toBeVisible({ timeout: 10000 });
|
|
});
|
|
|
|
test('can cancel deletion', async ({ page }) => {
|
|
await loginAsAdmin(page);
|
|
await page.goto(`${ALPHA_URL}/admin/classes`);
|
|
|
|
// Create a class
|
|
await openNewClassDialog(page);
|
|
const className = `NoDelete-${Date.now()}`;
|
|
await page.locator('#class-name').fill(className);
|
|
await page.getByRole('button', { name: /créer la classe/i }).click();
|
|
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
|
|
|
|
// Find and click delete
|
|
const classCard = page.locator('.class-card', { hasText: className });
|
|
await classCard.getByRole('button', { name: /supprimer/i }).click();
|
|
|
|
// Confirmation modal should appear
|
|
const deleteModal = page.getByRole('alertdialog');
|
|
await expect(deleteModal).toBeVisible({ timeout: 10000 });
|
|
|
|
// Cancel deletion
|
|
await deleteModal.getByRole('button', { name: /annuler/i }).click();
|
|
|
|
// Modal should close and class should still be there
|
|
await expect(deleteModal).not.toBeVisible({ timeout: 10000 });
|
|
await expect(page.getByText(className)).toBeVisible();
|
|
});
|
|
});
|
|
|
|
// ============================================================================
|
|
// Navigation
|
|
// ============================================================================
|
|
test.describe('Navigation', () => {
|
|
test('can access classes page directly', async ({ page }) => {
|
|
await loginAsAdmin(page);
|
|
|
|
// Navigate directly to classes page
|
|
await page.goto(`${ALPHA_URL}/admin/classes`);
|
|
|
|
await expect(page).toHaveURL(/\/admin\/classes/);
|
|
await expect(page.getByRole('heading', { name: /gestion des classes/i })).toBeVisible();
|
|
});
|
|
|
|
test('breadcrumb navigation works on edit page', async ({ page }) => {
|
|
await loginAsAdmin(page);
|
|
await page.goto(`${ALPHA_URL}/admin/classes`);
|
|
|
|
// Create a class
|
|
await openNewClassDialog(page);
|
|
const className = `Breadcrumb-${Date.now()}`;
|
|
await page.locator('#class-name').fill(className);
|
|
await page.getByRole('button', { name: /créer la classe/i }).click();
|
|
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
|
|
|
|
// Go to edit page
|
|
const classCard = page.locator('.class-card', { hasText: className });
|
|
await classCard.getByRole('button', { name: /modifier/i }).click();
|
|
|
|
// Click breadcrumb to go back (scoped to main to avoid matching nav link)
|
|
await page.getByRole('main').getByRole('link', { name: 'Classes' }).click();
|
|
|
|
await expect(page).toHaveURL(/\/admin\/classes$/);
|
|
});
|
|
});
|
|
|
|
// ============================================================================
|
|
// Validation
|
|
// ============================================================================
|
|
test.describe('Validation', () => {
|
|
test('shows validation for class name length', async ({ page }) => {
|
|
await loginAsAdmin(page);
|
|
await page.goto(`${ALPHA_URL}/admin/classes`);
|
|
|
|
await openNewClassDialog(page);
|
|
|
|
// Try a name that's too short (1 char)
|
|
await page.locator('#class-name').fill('A');
|
|
|
|
// The HTML5 minlength validation should prevent submission
|
|
// or show an error
|
|
const nameInput = page.locator('#class-name');
|
|
const isInvalid = await nameInput.evaluate(
|
|
(el: HTMLInputElement) => !el.validity.valid
|
|
);
|
|
expect(isInvalid).toBe(true);
|
|
});
|
|
});
|
|
});
|