feat: Permettre la création et modification de l'emploi du temps des classes
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

L'administration a besoin de construire et maintenir les emplois du temps
hebdomadaires pour chaque classe, en s'assurant que les enseignants ne sont
pas en conflit (même créneau, classes différentes) et que les affectations
enseignant-matière-classe sont respectées.

Cette implémentation couvre le CRUD complet des créneaux (ScheduleSlot),
la détection de conflits (classe, enseignant, salle) avec possibilité de
forcer, la validation des affectations côté serveur (AC2), l'intégration
calendrier pour les jours bloqués, une vue mobile-first avec onglets jour
par jour, et le drag-and-drop pour réorganiser les créneaux sur desktop.
This commit is contained in:
2026-03-03 13:54:53 +01:00
parent 1db8a7a0b2
commit d103b34023
53 changed files with 6382 additions and 1 deletions

View File

@@ -0,0 +1,524 @@
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-schedule-admin@example.com';
const ADMIN_PASSWORD = 'ScheduleTest123';
const TEACHER_EMAIL = 'e2e-schedule-teacher@example.com';
const TEACHER_PASSWORD = 'ScheduleTeacher123';
const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
const projectRoot = join(__dirname, '../..');
const composeFile = join(projectRoot, 'compose.yaml');
function runSql(sql: string) {
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "${sql}" 2>&1`,
{ encoding: 'utf-8' }
);
}
function clearCache() {
try {
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear paginated_queries.cache 2>&1`,
{ encoding: 'utf-8' }
);
} catch {
// Cache pool may not exist
}
}
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: schoolId!, academicYearId: academicYearId! };
}
function cleanupScheduleData() {
try {
runSql(`DELETE FROM schedule_slots WHERE tenant_id = '${TENANT_ID}'`);
} catch {
// Table may not exist yet
}
}
function seedTeacherAssignments() {
const { academicYearId } = resolveDeterministicIds();
try {
// Assign test teacher to ALL classes × ALL subjects so any dropdown combo is valid
runSql(
`INSERT INTO teacher_assignments (id, tenant_id, teacher_id, school_class_id, subject_id, academic_year_id, status, start_date, created_at, updated_at) ` +
`SELECT gen_random_uuid(), '${TENANT_ID}', u.id, c.id, s.id, '${academicYearId}', 'active', NOW(), NOW(), NOW() ` +
`FROM users u CROSS JOIN school_classes c CROSS JOIN subjects s ` +
`WHERE u.email = '${TEACHER_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` +
`AND c.tenant_id = '${TENANT_ID}' ` +
`AND s.tenant_id = '${TENANT_ID}' ` +
`ON CONFLICT DO NOTHING`
);
} catch {
// Table may not exist
}
}
function cleanupCalendarEntries() {
try {
runSql(`DELETE FROM school_calendar_entries WHERE tenant_id = '${TENANT_ID}'`);
} catch {
// Table may not exist
}
}
function seedBlockedDate(date: string, label: string, type: string) {
const { academicYearId } = resolveDeterministicIds();
runSql(
`INSERT INTO school_calendar_entries (id, tenant_id, academic_year_id, entry_type, start_date, end_date, label, created_at) ` +
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${academicYearId}', '${type}', '${date}', '${date}', '${label}', NOW()) ` +
`ON CONFLICT DO NOTHING`
);
}
function getWeekdayInCurrentWeek(isoDay: number): string {
const now = new Date();
const monday = new Date(now);
monday.setDate(now.getDate() - ((now.getDay() + 6) % 7));
const target = new Date(monday);
target.setDate(monday.getDate() + (isoDay - 1));
return target.toISOString().split('T')[0]!;
}
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 waitForScheduleReady(page: import('@playwright/test').Page) {
await expect(page.getByRole('heading', { name: /emploi du temps/i })).toBeVisible({
timeout: 15000
});
// Wait for either the grid or the empty state to appear
await expect(page.locator('.schedule-grid, .empty-state, .alert-error')).toBeVisible({
timeout: 15000
});
}
async function fillSlotForm(
dialog: import('@playwright/test').Locator,
options: {
className?: string;
dayValue?: string;
startTime?: string;
endTime?: string;
room?: string;
} = {}
) {
const { className, dayValue = '1', startTime = '09:00', endTime = '10:00', room } = options;
if (className) {
await dialog.locator('#slot-class').selectOption({ label: className });
}
// Wait for assignments to load — only the test teacher is assigned,
// so the teacher dropdown filters down to 1 option
const teacherOptions = dialog.locator('#slot-teacher option:not([value=""])');
await expect(teacherOptions).toHaveCount(1, { timeout: 10000 });
await dialog.locator('#slot-subject').selectOption({ index: 1 });
await dialog.locator('#slot-teacher').selectOption({ index: 1 });
await dialog.locator('#slot-day').selectOption(dayValue);
await dialog.locator('#slot-start').fill(startTime);
await dialog.locator('#slot-end').fill(endTime);
if (room) {
await dialog.locator('#slot-room').fill(room);
}
}
test.describe('Schedule Management - Modification & Conflicts & Calendar (Story 4.1)', () => {
// Tests share database state (same tenant, users, slots) so they must run sequentially
test.describe.configure({ mode: 'serial' });
test.beforeAll(async () => {
// 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' }
);
// Create teacher user
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${TEACHER_EMAIL} --password=${TEACHER_PASSWORD} --role=ROLE_PROF 2>&1`,
{ encoding: 'utf-8' }
);
const { schoolId, academicYearId } = resolveDeterministicIds();
// Ensure test classes exist
try {
runSql(
`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-Schedule-6A', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
);
} catch {
// May already exist
}
try {
runSql(
`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-Schedule-5A', '5ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
);
} catch {
// May already exist
}
// Ensure test subjects exist
try {
runSql(
`INSERT INTO subjects (id, tenant_id, school_id, name, code, color, status, created_at, updated_at) VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', 'E2E-Schedule-Maths', 'E2ESCHEDMATH', '#3b82f6', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
);
} catch {
// May already exist
}
try {
runSql(
`INSERT INTO subjects (id, tenant_id, school_id, name, code, color, status, created_at, updated_at) VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', 'E2E-Schedule-Français', 'E2ESCHEDFRA', '#ef4444', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
);
} catch {
// May already exist
}
cleanupScheduleData();
cleanupCalendarEntries();
clearCache();
});
test.beforeEach(async () => {
cleanupScheduleData();
cleanupCalendarEntries();
try {
runSql(`DELETE FROM teacher_assignments WHERE tenant_id = '${TENANT_ID}'`);
} catch {
// Table may not exist
}
seedTeacherAssignments();
clearCache();
});
// ==========================================================================
// AC3: Slot Modification & Deletion
// ==========================================================================
test.describe('AC3: Slot Modification & Deletion', () => {
test('clicking a slot opens edit modal', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/schedule`);
await waitForScheduleReady(page);
// First create a slot
const timeCell = page.locator('.time-cell').first();
await timeCell.click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 10000 });
await fillSlotForm(dialog);
await dialog.getByRole('button', { name: /créer/i }).click();
await expect(dialog).not.toBeVisible({ timeout: 10000 });
// Click on the created slot
const slotCard = page.locator('.slot-card').first();
await expect(slotCard).toBeVisible({ timeout: 10000 });
await slotCard.click();
// Edit modal should appear
const editDialog = page.getByRole('dialog');
await expect(editDialog).toBeVisible({ timeout: 10000 });
await expect(
editDialog.getByRole('heading', { name: /modifier le créneau/i })
).toBeVisible();
});
test('can delete a slot via edit modal', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/schedule`);
await waitForScheduleReady(page);
// Create a slot
const timeCell = page.locator('.time-cell').first();
await timeCell.click();
let dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 10000 });
await fillSlotForm(dialog, { dayValue: '2', startTime: '14:00', endTime: '15:00' });
await dialog.getByRole('button', { name: /créer/i }).click();
await expect(dialog).not.toBeVisible({ timeout: 10000 });
// Click on the slot to edit
const slotCard = page.locator('.slot-card').first();
await expect(slotCard).toBeVisible({ timeout: 10000 });
await slotCard.click();
dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 10000 });
// Click delete button
await dialog.getByRole('button', { name: /supprimer/i }).click();
// Confirmation modal should appear
const deleteModal = page.getByRole('alertdialog');
await expect(deleteModal).toBeVisible({ timeout: 10000 });
// Confirm deletion
await deleteModal.getByRole('button', { name: /supprimer/i }).click();
// Modal should close and slot should disappear
await expect(deleteModal).not.toBeVisible({ timeout: 10000 });
await expect(page.locator('.slot-card')).not.toBeVisible({ timeout: 10000 });
});
test('can modify a slot and see updated data in grid', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/schedule`);
await waitForScheduleReady(page);
// Create initial slot with room
const timeCell = page.locator('.time-cell').first();
await timeCell.click();
let dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 10000 });
await fillSlotForm(dialog, { room: 'A101' });
await dialog.getByRole('button', { name: /créer/i }).click();
await expect(dialog).not.toBeVisible({ timeout: 10000 });
// Verify initial slot with room A101
const slotCard = page.locator('.slot-card').first();
await expect(slotCard).toBeVisible({ timeout: 10000 });
await expect(slotCard.getByText('A101')).toBeVisible();
// Click to open edit modal
await slotCard.click();
dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 10000 });
await expect(
dialog.getByRole('heading', { name: /modifier le créneau/i })
).toBeVisible();
// Change room to B202
await dialog.locator('#slot-room').clear();
await dialog.locator('#slot-room').fill('B202');
// Submit modification
await dialog.getByRole('button', { name: /modifier/i }).click();
await expect(dialog).not.toBeVisible({ timeout: 10000 });
// Verify success and updated data
await expect(page.getByText('Créneau modifié.')).toBeVisible({ timeout: 5000 });
const updatedSlot = page.locator('.slot-card').first();
await expect(updatedSlot.getByText('B202')).toBeVisible();
});
});
// ==========================================================================
// AC4: Conflict Detection
// ==========================================================================
test.describe('AC4: Conflict Detection', () => {
test('displays conflict warning when creating slot with same teacher at overlapping time', async ({
page
}) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/schedule`);
await waitForScheduleReady(page);
// Step 1: Create first slot (class 6A, Wednesday 10:00-11:00)
const timeCell = page.locator('.time-cell').first();
await timeCell.click();
let dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 10000 });
await fillSlotForm(dialog, { dayValue: '3', startTime: '10:00', endTime: '11:00' });
await dialog.getByRole('button', { name: /créer/i }).click();
await expect(dialog).not.toBeVisible({ timeout: 10000 });
await expect(page.locator('.slot-card')).toBeVisible({ timeout: 10000 });
// Step 2: Create conflicting slot with DIFFERENT class but SAME teacher at same time
const timeCell2 = page.locator('.time-cell').first();
await timeCell2.click();
dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 10000 });
await fillSlotForm(dialog, {
className: 'E2E-Schedule-5A',
dayValue: '3',
startTime: '10:00',
endTime: '11:00'
});
// Submit - should trigger conflict detection
await dialog.getByRole('button', { name: /créer/i }).click();
// Conflict warning should appear inside the dialog
await expect(dialog.locator('.alert-warning')).toBeVisible({ timeout: 10000 });
await expect(dialog.getByText(/conflits détectés/i)).toBeVisible();
// Force checkbox should be available
await expect(dialog.getByText(/forcer la création/i)).toBeVisible();
// Dialog should still be open (not closed)
await expect(dialog).toBeVisible();
});
test('can force creation despite detected conflict', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/schedule`);
await waitForScheduleReady(page);
// Step 1: Create first slot (class 6A, Thursday 14:00-15:00)
const timeCell = page.locator('.time-cell').first();
await timeCell.click();
let dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 10000 });
await fillSlotForm(dialog, { dayValue: '4', startTime: '14:00', endTime: '15:00' });
await dialog.getByRole('button', { name: /créer/i }).click();
await expect(dialog).not.toBeVisible({ timeout: 10000 });
await expect(page.locator('.slot-card')).toBeVisible({ timeout: 10000 });
// Step 2: Create conflicting slot with different class, same teacher, same time
const timeCell2 = page.locator('.time-cell').first();
await timeCell2.click();
dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 10000 });
await fillSlotForm(dialog, {
className: 'E2E-Schedule-5A',
dayValue: '4',
startTime: '14:00',
endTime: '15:00'
});
// First submit - triggers conflict warning
await dialog.getByRole('button', { name: /créer/i }).click();
await expect(dialog.locator('.alert-warning')).toBeVisible({ timeout: 10000 });
// Check force checkbox
await dialog.locator('.force-checkbox input[type="checkbox"]').check();
// Submit again with force enabled
await dialog.getByRole('button', { name: /créer/i }).click();
// Modal should close - slot created despite conflict
await expect(dialog).not.toBeVisible({ timeout: 10000 });
// Success message should appear
await expect(page.getByText('Créneau créé.')).toBeVisible({ timeout: 5000 });
});
});
// ==========================================================================
// AC5: Calendar Respect (Blocked Days)
// ==========================================================================
test.describe('AC5: Calendar Respect', () => {
test('time validation prevents end before start', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/schedule`);
await waitForScheduleReady(page);
// Open creation modal
const timeCell = page.locator('.time-cell').first();
await timeCell.click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 10000 });
// Set end time before start time
await dialog.locator('#slot-start').fill('10:00');
await dialog.locator('#slot-end').fill('09:00');
// Error message should appear
await expect(
dialog.getByText(/l'heure de fin doit être après/i)
).toBeVisible();
// Submit should be disabled
await expect(dialog.getByRole('button', { name: /créer/i })).toBeDisabled();
});
test('blocked day is visually marked in the grid', async ({ page }) => {
// Seed a holiday on Wednesday of current week
const wednesdayDate = getWeekdayInCurrentWeek(3);
seedBlockedDate(wednesdayDate, 'Jour férié test', 'holiday');
clearCache();
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/schedule`);
await waitForScheduleReady(page);
// The third day-column (Wednesday) should have the blocked class
const dayColumns = page.locator('.day-column');
await expect(dayColumns.nth(2)).toHaveClass(/day-blocked/, { timeout: 10000 });
// Should display the reason badge in the header
await expect(page.getByText('Jour férié test')).toBeVisible();
});
test('cannot create a slot on a blocked day', async ({ page }) => {
// Seed a vacation on Tuesday of current week
const tuesdayDate = getWeekdayInCurrentWeek(2);
seedBlockedDate(tuesdayDate, 'Vacances test', 'vacation');
clearCache();
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/schedule`);
await waitForScheduleReady(page);
// Tuesday column should be blocked
const dayColumns = page.locator('.day-column');
await expect(dayColumns.nth(1)).toHaveClass(/day-blocked/, { timeout: 10000 });
// Attempt to click a time cell in the blocked day — dialog should NOT open
// Use dispatchEvent to bypass pointer-events: none
await dayColumns.nth(1).locator('.time-cell').first().dispatchEvent('click');
const dialog = page.getByRole('dialog');
await expect(dialog).not.toBeVisible({ timeout: 3000 });
});
});
});

View File

@@ -0,0 +1,417 @@
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-schedule-admin@example.com';
const ADMIN_PASSWORD = 'ScheduleTest123';
const TEACHER_EMAIL = 'e2e-schedule-teacher@example.com';
const TEACHER_PASSWORD = 'ScheduleTeacher123';
const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
const projectRoot = join(__dirname, '../..');
const composeFile = join(projectRoot, 'compose.yaml');
function runSql(sql: string) {
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "${sql}" 2>&1`,
{ encoding: 'utf-8' }
);
}
function clearCache() {
try {
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear paginated_queries.cache 2>&1`,
{ encoding: 'utf-8' }
);
} catch {
// Cache pool may not exist
}
}
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: schoolId!, academicYearId: academicYearId! };
}
function cleanupScheduleData() {
try {
runSql(`DELETE FROM schedule_slots WHERE tenant_id = '${TENANT_ID}'`);
} catch {
// Table may not exist yet
}
}
function seedTeacherAssignments() {
const { academicYearId } = resolveDeterministicIds();
try {
// Assign test teacher to ALL classes × ALL subjects so any dropdown combo is valid
runSql(
`INSERT INTO teacher_assignments (id, tenant_id, teacher_id, school_class_id, subject_id, academic_year_id, status, start_date, created_at, updated_at) ` +
`SELECT gen_random_uuid(), '${TENANT_ID}', u.id, c.id, s.id, '${academicYearId}', 'active', NOW(), NOW(), NOW() ` +
`FROM users u CROSS JOIN school_classes c CROSS JOIN subjects s ` +
`WHERE u.email = '${TEACHER_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` +
`AND c.tenant_id = '${TENANT_ID}' ` +
`AND s.tenant_id = '${TENANT_ID}' ` +
`ON CONFLICT DO NOTHING`
);
} catch {
// Table may not exist
}
}
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 waitForScheduleReady(page: import('@playwright/test').Page) {
await expect(page.getByRole('heading', { name: /emploi du temps/i })).toBeVisible({
timeout: 15000
});
// Wait for either the grid or the empty state to appear
await expect(page.locator('.schedule-grid, .empty-state, .alert-error')).toBeVisible({
timeout: 15000
});
}
async function fillSlotForm(
dialog: import('@playwright/test').Locator,
options: {
className?: string;
dayValue?: string;
startTime?: string;
endTime?: string;
room?: string;
} = {}
) {
const { className, dayValue = '1', startTime = '09:00', endTime = '10:00', room } = options;
if (className) {
await dialog.locator('#slot-class').selectOption({ label: className });
}
// Wait for assignments to load — only the test teacher is assigned,
// so the teacher dropdown filters down to 1 option
const teacherOptions = dialog.locator('#slot-teacher option:not([value=""])');
await expect(teacherOptions).toHaveCount(1, { timeout: 10000 });
await dialog.locator('#slot-subject').selectOption({ index: 1 });
await dialog.locator('#slot-teacher').selectOption({ index: 1 });
await dialog.locator('#slot-day').selectOption(dayValue);
await dialog.locator('#slot-start').fill(startTime);
await dialog.locator('#slot-end').fill(endTime);
if (room) {
await dialog.locator('#slot-room').fill(room);
}
}
test.describe('Schedule Management - Navigation & Grid & Creation (Story 4.1)', () => {
// Tests share database state (same tenant, users, assignments) so they must run sequentially
test.describe.configure({ mode: 'serial' });
test.beforeAll(async () => {
// 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' }
);
// Create teacher user
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${TEACHER_EMAIL} --password=${TEACHER_PASSWORD} --role=ROLE_PROF 2>&1`,
{ encoding: 'utf-8' }
);
const { schoolId, academicYearId } = resolveDeterministicIds();
// Ensure test class exists
try {
runSql(
`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-Schedule-6A', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
);
} catch {
// May already exist
}
// Ensure second test class exists (for conflict tests across classes)
try {
runSql(
`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-Schedule-5A', '5ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
);
} catch {
// May already exist
}
// Ensure test subjects exist
try {
runSql(
`INSERT INTO subjects (id, tenant_id, school_id, name, code, color, status, created_at, updated_at) VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', 'E2E-Schedule-Maths', 'E2ESCHEDMATH', '#3b82f6', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
);
} catch {
// May already exist
}
try {
runSql(
`INSERT INTO subjects (id, tenant_id, school_id, name, code, color, status, created_at, updated_at) VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', 'E2E-Schedule-Français', 'E2ESCHEDFRA', '#ef4444', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
);
} catch {
// May already exist
}
cleanupScheduleData();
clearCache();
});
test.beforeEach(async () => {
cleanupScheduleData();
try {
runSql(`DELETE FROM teacher_assignments WHERE tenant_id = '${TENANT_ID}'`);
} catch {
// Table may not exist
}
seedTeacherAssignments();
clearCache();
});
// ==========================================================================
// Navigation
// ==========================================================================
test.describe('Navigation', () => {
test('schedule link appears in admin navigation under Organisation', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin`);
const nav = page.locator('.desktop-nav');
await nav.getByRole('button', { name: /organisation/i }).hover();
const navLink = nav.getByRole('menuitem', { name: /emploi du temps/i });
await expect(navLink).toBeVisible({ timeout: 15000 });
});
test('can navigate to schedule page', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/schedule`);
await expect(
page.getByRole('heading', { name: /emploi du temps/i })
).toBeVisible({ timeout: 15000 });
});
});
// ==========================================================================
// AC1: Schedule Grid
// ==========================================================================
test.describe('AC1: Schedule Grid', () => {
test('displays weekly grid with day columns', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/schedule`);
await waitForScheduleReady(page);
// Check day headers are present
await expect(page.getByText('Lundi')).toBeVisible();
await expect(page.getByText('Mardi')).toBeVisible();
await expect(page.getByText('Mercredi')).toBeVisible();
await expect(page.getByText('Jeudi')).toBeVisible();
await expect(page.getByText('Vendredi')).toBeVisible();
});
test('has class filter dropdown', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/schedule`);
await waitForScheduleReady(page);
const classFilter = page.locator('#filter-class');
await expect(classFilter).toBeVisible();
// Should have at least the placeholder option + one class
const options = classFilter.locator('option');
await expect(options).not.toHaveCount(1, { timeout: 10000 });
});
test('has teacher filter dropdown', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/schedule`);
await waitForScheduleReady(page);
const teacherFilter = page.locator('#filter-teacher');
await expect(teacherFilter).toBeVisible();
});
});
// ==========================================================================
// AC2: Slot Creation
// ==========================================================================
test.describe('AC2: Slot Creation', () => {
test('clicking on a time cell opens creation modal', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/schedule`);
await waitForScheduleReady(page);
// Click on a time cell in the grid
const timeCell = page.locator('.time-cell').first();
await timeCell.click();
// Modal should appear
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 10000 });
await expect(
dialog.getByRole('heading', { name: /nouveau créneau/i })
).toBeVisible();
});
test('creation form has required fields', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/schedule`);
await waitForScheduleReady(page);
// Open creation modal
const timeCell = page.locator('.time-cell').first();
await timeCell.click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 10000 });
// Check required form fields
await expect(dialog.locator('#slot-subject')).toBeVisible();
await expect(dialog.locator('#slot-teacher')).toBeVisible();
await expect(dialog.locator('#slot-day')).toBeVisible();
await expect(dialog.locator('#slot-start')).toBeVisible();
await expect(dialog.locator('#slot-end')).toBeVisible();
await expect(dialog.locator('#slot-room')).toBeVisible();
// Submit button should be disabled when fields are empty
const submitButton = dialog.getByRole('button', { name: /créer/i });
await expect(submitButton).toBeDisabled();
});
test('can close creation modal with cancel button', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/schedule`);
await waitForScheduleReady(page);
const timeCell = page.locator('.time-cell').first();
await timeCell.click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 10000 });
// Click cancel
await dialog.getByRole('button', { name: /annuler/i }).click();
await expect(dialog).not.toBeVisible({ timeout: 5000 });
});
test('can close creation modal with Escape key', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/schedule`);
await waitForScheduleReady(page);
const timeCell = page.locator('.time-cell').first();
await timeCell.click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 10000 });
// Press Escape
await page.keyboard.press('Escape');
await expect(dialog).not.toBeVisible({ timeout: 5000 });
});
test('can create a slot and see it in the grid', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/schedule`);
await waitForScheduleReady(page);
// Open creation modal
const timeCell = page.locator('.time-cell').first();
await timeCell.click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 10000 });
await fillSlotForm(dialog, { room: 'A101' });
// Submit
const submitButton = dialog.getByRole('button', { name: /créer/i });
await expect(submitButton).toBeEnabled();
await submitButton.click();
// Modal should close
await expect(dialog).not.toBeVisible({ timeout: 10000 });
// Slot card should appear in the grid
await expect(page.locator('.slot-card')).toBeVisible({ timeout: 10000 });
// Should show room on the slot card
await expect(page.locator('.slot-card').getByText('A101')).toBeVisible();
});
test('filters subjects and teachers by class assignment', async ({ page }) => {
const { academicYearId } = resolveDeterministicIds();
// Clear all assignments, seed exactly one: teacher → class 6A → first subject
runSql(`DELETE FROM teacher_assignments WHERE tenant_id = '${TENANT_ID}'`);
runSql(
`INSERT INTO teacher_assignments (id, tenant_id, teacher_id, school_class_id, subject_id, academic_year_id, status, start_date, created_at, updated_at) ` +
`SELECT gen_random_uuid(), '${TENANT_ID}', u.id, c.id, s.id, '${academicYearId}', 'active', NOW(), NOW(), NOW() ` +
`FROM users u, school_classes c, (SELECT id FROM subjects WHERE tenant_id = '${TENANT_ID}' ORDER BY name LIMIT 1) s ` +
`WHERE u.email = '${TEACHER_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` +
`AND c.name = 'E2E-Schedule-6A' AND c.tenant_id = '${TENANT_ID}' ` +
`ON CONFLICT DO NOTHING`
);
clearCache();
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/schedule`);
await waitForScheduleReady(page);
// Open creation modal
const timeCell = page.locator('.time-cell').first();
await timeCell.click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 10000 });
// Select class E2E-Schedule-6A (triggers loadAssignments for this class)
await dialog.locator('#slot-class').selectOption({ label: 'E2E-Schedule-6A' });
// Subject dropdown should be filtered to only the assigned subject
// (auto-retry handles the async assignment loading)
const subjectOptions = dialog.locator('#slot-subject option:not([value=""])');
await expect(subjectOptions).toHaveCount(1, { timeout: 15000 });
// Teacher dropdown should only show the assigned teacher
const teacherOptions = dialog.locator('#slot-teacher option:not([value=""])');
await expect(teacherOptions).toHaveCount(1, { timeout: 10000 });
});
});
});