Le menu d'administration contenait 13 liens à plat dans le header, ce qui débordait sur desktop et rendait le drawer mobile trop long à scanner. Les liens sont maintenant regroupés en 4 catégories (Personnes, Organisation, Année scolaire, Paramètres) avec des dropdowns au survol sur desktop et des accordéons repliables dans le drawer mobile. Le nombre d'éléments visibles passe de 13 à 5 (1 lien direct + 4 catégories), la catégorie active s'auto-déplie dans le menu mobile.
282 lines
11 KiB
TypeScript
282 lines
11 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-replacements-admin@example.com';
|
|
const ADMIN_PASSWORD = 'ReplacementsTest123';
|
|
const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
|
|
|
const projectRoot = join(__dirname, '../..');
|
|
const composeFile = join(projectRoot, 'compose.yaml');
|
|
|
|
function runCommand(sql: string) {
|
|
execSync(
|
|
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "${sql}" 2>&1`,
|
|
{ encoding: 'utf-8' }
|
|
);
|
|
}
|
|
|
|
function resolveDeterministicIds(): { schoolId: string; academicYearId: string } {
|
|
const output = execSync(
|
|
`docker compose -f "${composeFile}" exec -T php php -r '` +
|
|
`require "/app/vendor/autoload.php"; ` +
|
|
`$t="${TENANT_ID}"; $ns="6ba7b814-9dad-11d1-80b4-00c04fd430c8"; ` +
|
|
`echo Ramsey\\Uuid\\Uuid::uuid5($ns,"school-$t")->toString()."\\n"; ` +
|
|
`$m=(int)date("n"); $s=$m>=9?(int)date("Y"):(int)date("Y")-1; $e=$s+1; ` +
|
|
`echo Ramsey\\Uuid\\Uuid::uuid5($ns,"$t:$s-$e")->toString();` +
|
|
`' 2>&1`,
|
|
{ encoding: 'utf-8' }
|
|
).trim();
|
|
const [schoolId, academicYearId] = output.split('\n');
|
|
return { schoolId, academicYearId };
|
|
}
|
|
|
|
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()
|
|
]);
|
|
}
|
|
|
|
async function waitForPageReady(page: import('@playwright/test').Page) {
|
|
await expect(
|
|
page.getByRole('heading', { name: /remplacements enseignants/i })
|
|
).toBeVisible({ timeout: 15000 });
|
|
await expect(
|
|
page.locator('.empty-state, .replacements-table, .alert-error')
|
|
).toBeVisible({ timeout: 15000 });
|
|
}
|
|
|
|
async function openCreateDialog(page: import('@playwright/test').Page) {
|
|
const button = page.getByRole('button', { name: /nouveau remplacement/i }).first();
|
|
await expect(button).toBeEnabled();
|
|
await button.click();
|
|
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
|
}
|
|
|
|
function getTodayDate(): string {
|
|
return new Date().toISOString().split('T')[0];
|
|
}
|
|
|
|
function getFutureDate(days: number): string {
|
|
const date = new Date();
|
|
date.setDate(date.getDate() + days);
|
|
return date.toISOString().split('T')[0];
|
|
}
|
|
|
|
test.describe('Teacher Replacements (Story 2.9)', () => {
|
|
test.beforeAll(async () => {
|
|
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 app:dev:create-test-user --tenant=ecole-alpha --email=e2e-replaced-teacher@example.com --password=TeacherTest123 --role=ROLE_PROF 2>&1`,
|
|
{ encoding: 'utf-8' }
|
|
);
|
|
|
|
execSync(
|
|
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=e2e-replacement-teacher@example.com --password=TeacherTest123 --role=ROLE_PROF 2>&1`,
|
|
{ encoding: 'utf-8' }
|
|
);
|
|
|
|
const { schoolId, academicYearId } = resolveDeterministicIds();
|
|
|
|
runCommand(
|
|
`INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, status, created_at, updated_at) VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', '${academicYearId}', 'E2E-Repl-6B', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
|
|
);
|
|
|
|
runCommand(
|
|
`INSERT INTO subjects (id, tenant_id, school_id, name, code, status, created_at, updated_at) VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', 'E2E-Repl-Français', 'E2EFRA', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
|
|
);
|
|
});
|
|
|
|
test.beforeEach(async () => {
|
|
try {
|
|
runCommand(`DELETE FROM replacement_classes WHERE replacement_id IN (SELECT id FROM teacher_replacements WHERE tenant_id = '${TENANT_ID}')`);
|
|
runCommand(`DELETE FROM teacher_replacements WHERE tenant_id = '${TENANT_ID}'`);
|
|
} catch {
|
|
// Tables may not exist yet if migration hasn't run
|
|
}
|
|
});
|
|
|
|
// ============================================================================
|
|
// Navigation
|
|
// ============================================================================
|
|
test.describe('Navigation', () => {
|
|
test('replacements link appears in admin navigation', async ({ page }) => {
|
|
await loginAsAdmin(page);
|
|
await page.goto(`${ALPHA_URL}/admin`);
|
|
|
|
// Hover "Organisation" category to reveal dropdown
|
|
const nav = page.locator('.desktop-nav');
|
|
await nav.getByRole('button', { name: /organisation/i }).hover();
|
|
const navLink = nav.getByRole('menuitem', { name: /remplacements/i });
|
|
await expect(navLink).toBeVisible({ timeout: 15000 });
|
|
});
|
|
|
|
test('can navigate to replacements page', async ({ page }) => {
|
|
await loginAsAdmin(page);
|
|
await page.goto(`${ALPHA_URL}/admin/replacements`);
|
|
|
|
await expect(page.getByRole('heading', { name: /remplacements enseignants/i })).toBeVisible({ timeout: 15000 });
|
|
});
|
|
});
|
|
|
|
// ============================================================================
|
|
// Empty State
|
|
// ============================================================================
|
|
test.describe('Empty State', () => {
|
|
test('shows empty state when no replacements exist', async ({ page }) => {
|
|
await loginAsAdmin(page);
|
|
await page.goto(`${ALPHA_URL}/admin/replacements`);
|
|
|
|
await waitForPageReady(page);
|
|
await expect(page.getByText(/aucun remplacement actif/i)).toBeVisible({ timeout: 10000 });
|
|
});
|
|
});
|
|
|
|
// ============================================================================
|
|
// AC1: Create Replacement
|
|
// ============================================================================
|
|
test.describe('AC1: Create Replacement', () => {
|
|
test('can create a new replacement', async ({ page }) => {
|
|
await loginAsAdmin(page);
|
|
await page.goto(`${ALPHA_URL}/admin/replacements`);
|
|
await waitForPageReady(page);
|
|
|
|
await openCreateDialog(page);
|
|
|
|
// Select replaced teacher
|
|
const replacedSelect = page.locator('#replaced-teacher');
|
|
await expect(replacedSelect).toBeVisible();
|
|
await replacedSelect.selectOption({ index: 1 });
|
|
|
|
// Select replacement teacher (different from replaced)
|
|
const replacementSelect = page.locator('#replacement-teacher');
|
|
await expect(replacementSelect).toBeVisible();
|
|
const replacementOptions = replacementSelect.locator('option');
|
|
const count = await replacementOptions.count();
|
|
// Select a different teacher (index 1 should work since replaced teacher is filtered out)
|
|
if (count > 1) {
|
|
await replacementSelect.selectOption({ index: 1 });
|
|
}
|
|
|
|
// Set dates
|
|
await page.locator('#start-date').fill(getTodayDate());
|
|
await page.locator('#end-date').fill(getFutureDate(30));
|
|
|
|
// Select class and subject
|
|
const firstClassSelect = page.locator('.class-pair-row select').first();
|
|
await firstClassSelect.selectOption({ index: 1 });
|
|
const firstSubjectSelect = page.locator('.class-pair-row select').nth(1);
|
|
await firstSubjectSelect.selectOption({ index: 1 });
|
|
|
|
// Submit
|
|
await page.getByRole('button', { name: /désigner le remplaçant/i }).click();
|
|
|
|
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
|
|
await expect(page.getByText(/remplacement créé/i)).toBeVisible({ timeout: 10000 });
|
|
|
|
// Verify table shows the replacement
|
|
const table = page.locator('.replacements-table');
|
|
await expect(table).toBeVisible({ timeout: 10000 });
|
|
const rows = table.locator('tbody tr');
|
|
const rowCount = await rows.count();
|
|
expect(rowCount).toBeGreaterThanOrEqual(1);
|
|
});
|
|
|
|
test('cancel closes the modal without creating', async ({ page }) => {
|
|
await loginAsAdmin(page);
|
|
await page.goto(`${ALPHA_URL}/admin/replacements`);
|
|
await waitForPageReady(page);
|
|
|
|
await openCreateDialog(page);
|
|
|
|
await page.getByRole('button', { name: /annuler/i }).click();
|
|
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 5000 });
|
|
});
|
|
});
|
|
|
|
// ============================================================================
|
|
// AC3: End Replacement
|
|
// ============================================================================
|
|
test.describe('AC3: End Replacement', () => {
|
|
test('can end an active replacement', async ({ page }) => {
|
|
await loginAsAdmin(page);
|
|
await page.goto(`${ALPHA_URL}/admin/replacements`);
|
|
await waitForPageReady(page);
|
|
|
|
// First create a replacement
|
|
await openCreateDialog(page);
|
|
await page.locator('#replaced-teacher').selectOption({ index: 1 });
|
|
await page.locator('#replacement-teacher').selectOption({ index: 1 });
|
|
await page.locator('#start-date').fill(getTodayDate());
|
|
await page.locator('#end-date').fill(getFutureDate(30));
|
|
const firstClassSelect = page.locator('.class-pair-row select').first();
|
|
await firstClassSelect.selectOption({ index: 1 });
|
|
const firstSubjectSelect = page.locator('.class-pair-row select').nth(1);
|
|
await firstSubjectSelect.selectOption({ index: 1 });
|
|
await page.getByRole('button', { name: /désigner le remplaçant/i }).click();
|
|
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
|
|
|
|
// Now end it
|
|
const endButton = page.locator('.btn-remove').first();
|
|
await endButton.click();
|
|
|
|
const confirmDialog = page.getByRole('alertdialog');
|
|
await expect(confirmDialog).toBeVisible({ timeout: 5000 });
|
|
await expect(page.getByText(/perdra immédiatement l'accès/i)).toBeVisible();
|
|
|
|
await confirmDialog.getByRole('button', { name: /terminer/i }).click();
|
|
|
|
await expect(confirmDialog).not.toBeVisible({ timeout: 10000 });
|
|
await expect(page.getByText(/remplacement terminé/i)).toBeVisible({ timeout: 10000 });
|
|
});
|
|
});
|
|
|
|
// ============================================================================
|
|
// AC4: Active Replacements Display
|
|
// ============================================================================
|
|
test.describe('AC4: Active Replacements Display', () => {
|
|
test('shows countdown for active replacements', async ({ page }) => {
|
|
await loginAsAdmin(page);
|
|
await page.goto(`${ALPHA_URL}/admin/replacements`);
|
|
await waitForPageReady(page);
|
|
|
|
// Create a replacement
|
|
await openCreateDialog(page);
|
|
await page.locator('#replaced-teacher').selectOption({ index: 1 });
|
|
await page.locator('#replacement-teacher').selectOption({ index: 1 });
|
|
await page.locator('#start-date').fill(getTodayDate());
|
|
await page.locator('#end-date').fill(getFutureDate(10));
|
|
const firstClassSelect = page.locator('.class-pair-row select').first();
|
|
await firstClassSelect.selectOption({ index: 1 });
|
|
const firstSubjectSelect = page.locator('.class-pair-row select').nth(1);
|
|
await firstSubjectSelect.selectOption({ index: 1 });
|
|
await page.getByRole('button', { name: /désigner le remplaçant/i }).click();
|
|
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
|
|
|
|
// Verify countdown is displayed
|
|
await expect(page.locator('.countdown')).toBeVisible({ timeout: 10000 });
|
|
await expect(page.getByText(/jours? restants?/i)).toBeVisible();
|
|
|
|
// Verify status badge
|
|
await expect(page.locator('.status-active')).toBeVisible();
|
|
});
|
|
});
|
|
});
|