feat: Gestion des périodes scolaires

L'administration d'un établissement nécessite de découper l'année
scolaire en trimestres ou semestres avant de pouvoir saisir les notes
et générer les bulletins.

Ce module permet de configurer les périodes par année scolaire
(current/previous/next résolus en UUID v5 déterministes), de modifier
les dates individuelles avec validation anti-chevauchement, et de
consulter la période en cours avec le décompte des jours restants.

Les dates par défaut de février s'adaptent aux années bissextiles.
Le repository utilise UPSERT transactionnel pour garantir l'intégrité
lors du changement de mode (trimestres ↔ semestres). Les domain events
de Subject sont étendus pour couvrir toutes les mutations (code,
couleur, description) en plus du renommage.
This commit is contained in:
2026-02-06 12:00:29 +01:00
parent 0d5a097c4c
commit f19d0ae3ef
69 changed files with 5201 additions and 121 deletions

View File

@@ -433,8 +433,8 @@ test.describe('Classes Management (Story 2.1)', () => {
const classCard = page.locator('.class-card', { hasText: className });
await classCard.getByRole('button', { name: /modifier/i }).click();
// Click breadcrumb to go back
await page.getByRole('link', { name: 'Classes' }).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$/);
});

View File

@@ -0,0 +1,312 @@
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-periods-admin@example.com';
const ADMIN_PASSWORD = 'PeriodsTest123';
const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
// Force serial execution — empty state must run first
test.describe.configure({ mode: 'serial' });
test.describe('Periods Management (Story 2.3)', () => {
test.beforeAll(async () => {
const projectRoot = join(__dirname, '../..');
const composeFile = join(projectRoot, 'compose.yaml');
try {
// Create admin user
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' }
);
console.log('Periods E2E test admin user created');
// Clean up all periods for this tenant
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "DELETE FROM academic_periods WHERE tenant_id = '${TENANT_ID}'" 2>&1`,
{ encoding: 'utf-8' }
);
console.log('Periods cleaned up for E2E tests');
} catch (error) {
console.error('Setup error:', error);
}
});
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 });
}
// ============================================================================
// Empty State
// ============================================================================
test.describe('Empty State', () => {
test('shows empty state when no periods configured', async ({ page }) => {
// Clean up right before test to avoid race conditions
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 academic_periods WHERE tenant_id = '${TENANT_ID}'" 2>&1`,
{ encoding: 'utf-8' }
);
} catch {
// Ignore cleanup errors
}
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/academic-year/periods`);
await expect(page.getByRole('heading', { name: /périodes scolaires/i })).toBeVisible();
await expect(page.getByText(/aucune période configurée/i)).toBeVisible();
await expect(
page.getByRole('button', { name: /configurer les périodes/i })
).toBeVisible();
});
});
// ============================================================================
// Year Selector Tabs
// ============================================================================
test.describe('Year Selector', () => {
test('displays three year tabs', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/academic-year/periods`);
const tabs = page.getByRole('tab');
await expect(tabs).toHaveCount(3);
});
test('current year tab is active by default', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/academic-year/periods`);
const tabs = page.getByRole('tab');
// Middle tab (current) should be active
await expect(tabs.nth(1)).toHaveAttribute('aria-selected', 'true');
});
test('can switch between year tabs', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/academic-year/periods`);
const tabs = page.getByRole('tab');
// Wait for Svelte hydration and initial load to complete
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 });
// Wait for load triggered by tab switch
await page.waitForLoadState('networkidle');
// Click previous year tab
await tabs.nth(0).click();
await expect(tabs.nth(0)).toHaveAttribute('aria-selected', 'true', { timeout: 10000 });
});
});
// ============================================================================
// Period Configuration
// ============================================================================
test.describe('Period Configuration', () => {
test('can configure trimesters', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/academic-year/periods`);
// Click "Configurer les périodes" button
await page.getByRole('button', { name: /configurer les périodes/i }).click();
// Modal should open
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 5000 });
// Select trimester (should be default)
await expect(dialog.locator('#period-type')).toHaveValue('trimester');
// Verify preview shows 3 trimesters
await expect(dialog.getByText(/T1/)).toBeVisible();
await expect(dialog.getByText(/T2/)).toBeVisible();
await expect(dialog.getByText(/T3/)).toBeVisible();
// Submit
await dialog.getByRole('button', { name: /configurer$/i }).click();
// Modal should close
await expect(dialog).not.toBeVisible({ timeout: 10000 });
// Period cards should appear
await expect(page.getByRole('heading', { name: 'T1' })).toBeVisible({ timeout: 10000 });
await expect(page.getByRole('heading', { name: 'T2' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'T3' })).toBeVisible();
});
test('shows trimester badge after configuration', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/academic-year/periods`);
await expect(page.getByText(/trimestres/i)).toBeVisible({ timeout: 10000 });
});
test('shows dates on each period card', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/academic-year/periods`);
// Wait for periods to load
await expect(page.getByRole('heading', { name: 'T1' })).toBeVisible({ timeout: 10000 });
// Each period card should have start and end dates
const periodCards = page.locator('.period-card');
const count = await periodCards.count();
expect(count).toBe(3);
// Verify date labels exist
await expect(page.getByText(/début/i).first()).toBeVisible();
await expect(page.getByText(/fin/i).first()).toBeVisible();
});
test('configure button no longer visible when periods exist', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/academic-year/periods`);
// Wait for periods to load
await expect(page.getByRole('heading', { name: 'T1' })).toBeVisible({ timeout: 10000 });
// Configure button should not be visible
await expect(
page.getByRole('button', { name: /configurer les périodes/i })
).not.toBeVisible();
});
test('can configure semesters on next year', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/academic-year/periods`);
// Wait for initial load to complete before switching tab
const tabs = page.getByRole('tab');
await expect(tabs.nth(1)).toHaveAttribute('aria-selected', 'true', { timeout: 10000 });
await page.waitForLoadState('networkidle');
// Switch to next year tab
await tabs.nth(2).click();
// Should show empty state for next year
await expect(page.getByText(/aucune période configurée/i)).toBeVisible({
timeout: 10000
});
// Configure semesters for next year
await page.getByRole('button', { name: /configurer les périodes/i }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 5000 });
// Select semester
await dialog.locator('#period-type').selectOption('semester');
// Verify preview shows 2 semesters
await expect(dialog.getByText(/S1/)).toBeVisible();
await expect(dialog.getByText(/S2/)).toBeVisible();
// Submit
await dialog.getByRole('button', { name: /configurer$/i }).click();
// Modal should close and period cards appear
await expect(dialog).not.toBeVisible({ timeout: 10000 });
await expect(page.getByRole('heading', { name: 'S1' })).toBeVisible({ timeout: 10000 });
await expect(page.getByRole('heading', { name: 'S2' })).toBeVisible();
});
});
// ============================================================================
// Period Date Modification
// ============================================================================
test.describe('Period Date Modification', () => {
test('each period card has a modify button', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/academic-year/periods`);
await expect(page.getByRole('heading', { name: 'T1' })).toBeVisible({ timeout: 10000 });
const modifyButtons = page.getByRole('button', { name: /modifier les dates/i });
await expect(modifyButtons).toHaveCount(3);
});
test('opens edit modal when clicking modify', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/academic-year/periods`);
await expect(page.getByRole('heading', { name: 'T1' })).toBeVisible({ timeout: 10000 });
// Click modify on first period
const modifyButtons = page.getByRole('button', { name: /modifier les dates/i });
await modifyButtons.first().click();
// Edit modal should open
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 5000 });
await expect(dialog.getByText(/modifier T1/i)).toBeVisible();
// Date fields should be present
await expect(dialog.locator('#edit-start-date')).toBeVisible();
await expect(dialog.locator('#edit-end-date')).toBeVisible();
});
test('can cancel date modification', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/academic-year/periods`);
await expect(page.getByRole('heading', { name: 'T1' })).toBeVisible({ timeout: 10000 });
const modifyButtons = page.getByRole('button', { name: /modifier les dates/i });
await modifyButtons.first().click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 5000 });
// Cancel
await dialog.getByRole('button', { name: /annuler/i }).click();
await expect(dialog).not.toBeVisible({ timeout: 5000 });
});
});
// ============================================================================
// Navigation
// ============================================================================
test.describe('Navigation', () => {
test('can access periods page from admin dashboard', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin`);
// Click on periods card
await page.getByRole('link', { name: /périodes scolaires/i }).click();
await expect(page).toHaveURL(/\/admin\/academic-year\/periods/);
await expect(page.getByRole('heading', { name: /périodes scolaires/i })).toBeVisible();
});
test('can access periods page directly', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/academic-year/periods`);
await expect(page).toHaveURL(/\/admin\/academic-year\/periods/);
await expect(page.getByRole('heading', { name: /périodes scolaires/i })).toBeVisible();
});
});
});