Files
Classeo/frontend/e2e/schedule.spec.ts
Mathias STRASSER d103b34023
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
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.
2026-03-03 19:55:11 +01:00

418 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 });
});
});
});