feat: Permettre la création et modification de l'emploi du temps des classes
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:
417
frontend/e2e/schedule.spec.ts
Normal file
417
frontend/e2e/schedule.spec.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user