feat: Permettre la définition d'une semaine type récurrente pour l'emploi du temps
Les administrateurs devaient recréer manuellement l'emploi du temps chaque semaine. Cette implémentation introduit un système de récurrence hebdomadaire avec gestion des exceptions par occurrence, permettant de modifier ou annuler un cours spécifique sans affecter les autres semaines. Le ScheduleResolver calcule dynamiquement l'EDT réel en combinant les créneaux récurrents, les exceptions ponctuelles et le calendrier scolaire (vacances/fériés).
This commit is contained in:
@@ -120,11 +120,18 @@ async function waitForScheduleReady(page: import('@playwright/test').Page) {
|
||||
timeout: 15000
|
||||
});
|
||||
// Wait for either the grid or the empty state to appear
|
||||
await expect(page.locator('.schedule-grid, .empty-state, .alert-error')).toBeVisible({
|
||||
await expect(page.locator('.schedule-grid, .empty-state')).toBeVisible({
|
||||
timeout: 15000
|
||||
});
|
||||
}
|
||||
|
||||
async function chooseScopeAndEdit(page: import('@playwright/test').Page) {
|
||||
// Scope modal appears - choose "this occurrence"
|
||||
const scopeModal = page.getByRole('dialog');
|
||||
await expect(scopeModal).toBeVisible({ timeout: 10000 });
|
||||
await scopeModal.getByText('Cette occurrence uniquement').click();
|
||||
}
|
||||
|
||||
async function fillSlotForm(
|
||||
dialog: import('@playwright/test').Locator,
|
||||
options: {
|
||||
@@ -228,7 +235,7 @@ test.describe('Schedule Management - Modification & Conflicts & Calendar (Story
|
||||
// AC3: Slot Modification & Deletion
|
||||
// ==========================================================================
|
||||
test.describe('AC3: Slot Modification & Deletion', () => {
|
||||
test('clicking a slot opens edit modal', async ({ page }) => {
|
||||
test('clicking a slot opens scope modal then edit modal', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/schedule`);
|
||||
|
||||
@@ -246,11 +253,13 @@ test.describe('Schedule Management - Modification & Conflicts & Calendar (Story
|
||||
await dialog.getByRole('button', { name: /créer/i }).click();
|
||||
await expect(dialog).not.toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Click on the created slot
|
||||
// Click on the created slot — scope modal should appear
|
||||
const slotCard = page.locator('.slot-card').first();
|
||||
await expect(slotCard).toBeVisible({ timeout: 10000 });
|
||||
await slotCard.click();
|
||||
|
||||
await chooseScopeAndEdit(page);
|
||||
|
||||
// Edit modal should appear
|
||||
const editDialog = page.getByRole('dialog');
|
||||
await expect(editDialog).toBeVisible({ timeout: 10000 });
|
||||
@@ -277,17 +286,24 @@ test.describe('Schedule Management - Modification & Conflicts & Calendar (Story
|
||||
await dialog.getByRole('button', { name: /créer/i }).click();
|
||||
await expect(dialog).not.toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Click on the slot to edit
|
||||
// Click on the slot — scope modal then edit modal
|
||||
const slotCard = page.locator('.slot-card').first();
|
||||
await expect(slotCard).toBeVisible({ timeout: 10000 });
|
||||
await slotCard.click();
|
||||
|
||||
await chooseScopeAndEdit(page);
|
||||
|
||||
dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Click delete button
|
||||
// Click delete button — opens scope modal again for delete scope
|
||||
await dialog.getByRole('button', { name: /supprimer/i }).click();
|
||||
|
||||
// Scope modal appears for delete action
|
||||
const scopeModal = page.locator('.modal-scope');
|
||||
await expect(scopeModal).toBeVisible({ timeout: 10000 });
|
||||
await scopeModal.getByText('Cette occurrence uniquement').click();
|
||||
|
||||
// Confirmation modal should appear
|
||||
const deleteModal = page.getByRole('alertdialog');
|
||||
await expect(deleteModal).toBeVisible({ timeout: 10000 });
|
||||
@@ -323,9 +339,11 @@ test.describe('Schedule Management - Modification & Conflicts & Calendar (Story
|
||||
await expect(slotCard).toBeVisible({ timeout: 10000 });
|
||||
await expect(slotCard.getByText('A101')).toBeVisible();
|
||||
|
||||
// Click to open edit modal
|
||||
// Click to open scope modal then edit modal
|
||||
await slotCard.click();
|
||||
|
||||
await chooseScopeAndEdit(page);
|
||||
|
||||
dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 10000 });
|
||||
await expect(
|
||||
@@ -520,5 +538,61 @@ test.describe('Schedule Management - Modification & Conflicts & Calendar (Story
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).not.toBeVisible({ timeout: 3000 });
|
||||
});
|
||||
|
||||
test('recurring slots do not appear on vacation days in next week (AC4)', async ({
|
||||
page
|
||||
}) => {
|
||||
// Compute next week Thursday (use local format to avoid UTC shift)
|
||||
const thisThursday = new Date(getWeekdayInCurrentWeek(4) + 'T00:00:00');
|
||||
const nextThursday = new Date(thisThursday);
|
||||
nextThursday.setDate(thisThursday.getDate() + 7);
|
||||
const y = nextThursday.getFullYear();
|
||||
const m = String(nextThursday.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(nextThursday.getDate()).padStart(2, '0');
|
||||
const nextThursdayStr = `${y}-${m}-${d}`;
|
||||
|
||||
// Clean calendar entries and seed a vacation on next Thursday only
|
||||
cleanupCalendarEntries();
|
||||
seedBlockedDate(nextThursdayStr, 'Vacances AC4', 'vacation');
|
||||
clearCache();
|
||||
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/schedule`);
|
||||
await waitForScheduleReady(page);
|
||||
|
||||
// Create a Thursday slot (if none exists) so we have a recurring slot on Thursday
|
||||
const dayColumns = page.locator('.day-column');
|
||||
// Check if Thursday column (index 3) already has a slot
|
||||
const thursdaySlots = dayColumns.nth(3).locator('.slot-card');
|
||||
const existingCount = await thursdaySlots.count();
|
||||
|
||||
if (existingCount === 0) {
|
||||
// Create a slot on Thursday
|
||||
const thursdayCell = dayColumns.nth(3).locator('.time-cell').first();
|
||||
await thursdayCell.click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 10000 });
|
||||
await fillSlotForm(dialog, { dayValue: '4', startTime: '09:00', endTime: '10:00' });
|
||||
await dialog.getByRole('button', { name: /créer/i }).click();
|
||||
await expect(dialog).not.toBeVisible({ timeout: 10000 });
|
||||
await waitForScheduleReady(page);
|
||||
}
|
||||
|
||||
// Verify slot is visible on current week's Thursday
|
||||
await expect(dayColumns.nth(3).locator('.slot-card').first()).toBeVisible({
|
||||
timeout: 10000
|
||||
});
|
||||
|
||||
// Navigate to next week
|
||||
await page.getByRole('button', { name: 'Semaine suivante' }).click();
|
||||
await waitForScheduleReady(page);
|
||||
|
||||
// Next week Thursday should be blocked
|
||||
await expect(dayColumns.nth(3)).toHaveClass(/day-blocked/, { timeout: 10000 });
|
||||
|
||||
// No slot card should appear on the blocked Thursday
|
||||
await expect(dayColumns.nth(3).locator('.slot-card')).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -94,7 +94,7 @@ async function waitForScheduleReady(page: import('@playwright/test').Page) {
|
||||
timeout: 15000
|
||||
});
|
||||
// Wait for either the grid or the empty state to appear
|
||||
await expect(page.locator('.schedule-grid, .empty-state, .alert-error')).toBeVisible({
|
||||
await expect(page.locator('.schedule-grid, .empty-state')).toBeVisible({
|
||||
timeout: 15000
|
||||
});
|
||||
}
|
||||
@@ -415,3 +415,244 @@ test.describe('Schedule Management - Navigation & Grid & Creation (Story 4.1)',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Schedule Recurring - Week Navigation & Scope (Story 4.2)', () => {
|
||||
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();
|
||||
|
||||
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 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
|
||||
}
|
||||
|
||||
cleanupScheduleData();
|
||||
clearCache();
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
cleanupScheduleData();
|
||||
try {
|
||||
runSql(`DELETE FROM schedule_exceptions WHERE tenant_id = '${TENANT_ID}'`);
|
||||
} catch {
|
||||
// Table may not exist
|
||||
}
|
||||
try {
|
||||
runSql(`DELETE FROM teacher_assignments WHERE tenant_id = '${TENANT_ID}'`);
|
||||
} catch {
|
||||
// Table may not exist
|
||||
}
|
||||
seedTeacherAssignments();
|
||||
clearCache();
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// AC2: Week Navigation
|
||||
// ==========================================================================
|
||||
test.describe('AC2: Week Navigation', () => {
|
||||
test('displays week navigation controls', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/schedule`);
|
||||
await waitForScheduleReady(page);
|
||||
|
||||
// Week navigation should be visible
|
||||
const weekNav = page.locator('.week-nav');
|
||||
await expect(weekNav).toBeVisible();
|
||||
|
||||
// Previous/next buttons
|
||||
await expect(weekNav.getByLabel('Semaine précédente')).toBeVisible();
|
||||
await expect(weekNav.getByLabel('Semaine suivante')).toBeVisible();
|
||||
|
||||
// Week label
|
||||
await expect(weekNav.locator('.week-label')).toBeVisible();
|
||||
});
|
||||
|
||||
test('can navigate to next and previous weeks', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/schedule`);
|
||||
await waitForScheduleReady(page);
|
||||
|
||||
const weekLabel = page.locator('.week-label');
|
||||
const initialLabel = await weekLabel.textContent();
|
||||
|
||||
// Navigate to next week
|
||||
await page.getByLabel('Semaine suivante').click();
|
||||
await expect(weekLabel).not.toHaveText(initialLabel!, { timeout: 5000 });
|
||||
const nextLabel = await weekLabel.textContent();
|
||||
|
||||
// Navigate back
|
||||
await page.getByLabel('Semaine précédente').click();
|
||||
await expect(weekLabel).toHaveText(initialLabel!, { timeout: 5000 });
|
||||
|
||||
// Navigate to next again
|
||||
await page.getByLabel('Semaine suivante').click();
|
||||
await expect(weekLabel).toHaveText(nextLabel!, { timeout: 5000 });
|
||||
});
|
||||
|
||||
test('today button appears when not on current week', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/schedule`);
|
||||
await waitForScheduleReady(page);
|
||||
|
||||
// "Aujourd'hui" button should not be visible on current week
|
||||
await expect(page.locator('.week-nav-today')).not.toBeVisible();
|
||||
|
||||
// Navigate away
|
||||
await page.getByLabel('Semaine suivante').click();
|
||||
|
||||
// "Aujourd'hui" button should appear
|
||||
await expect(page.locator('.week-nav-today')).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Click it to go back
|
||||
await page.locator('.week-nav-today').click();
|
||||
|
||||
// Should disappear again
|
||||
await expect(page.locator('.week-nav-today')).not.toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// AC1/AC2: Recurring indicator
|
||||
// ==========================================================================
|
||||
test.describe('AC1: Recurring Indicator', () => {
|
||||
test('recurring slots show recurring badge', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/schedule`);
|
||||
await waitForScheduleReady(page);
|
||||
|
||||
// Create a slot first
|
||||
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: 'B201' });
|
||||
await dialog.getByRole('button', { name: /créer/i }).click();
|
||||
await expect(dialog).not.toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Slot card should have the recurring badge
|
||||
const slotCard = page.locator('.slot-card');
|
||||
await expect(slotCard).toBeVisible({ timeout: 10000 });
|
||||
await expect(slotCard.locator('.slot-badge-recurring')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// AC3: Scope Choice Modal
|
||||
// ==========================================================================
|
||||
test.describe('AC3: Scope Choice Modal', () => {
|
||||
test('clicking a slot opens scope choice 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();
|
||||
const createDialog = page.getByRole('dialog');
|
||||
await expect(createDialog).toBeVisible({ timeout: 10000 });
|
||||
|
||||
await fillSlotForm(createDialog, { room: 'C301' });
|
||||
await createDialog.getByRole('button', { name: /créer/i }).click();
|
||||
await expect(createDialog).not.toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Click on the slot card
|
||||
const slotCard = page.locator('.slot-card');
|
||||
await expect(slotCard).toBeVisible({ timeout: 10000 });
|
||||
await slotCard.click();
|
||||
|
||||
// Scope modal should appear
|
||||
const scopeModal = page.locator('.modal-scope');
|
||||
await expect(scopeModal).toBeVisible({ timeout: 10000 });
|
||||
await expect(scopeModal.getByText('Cette occurrence uniquement')).toBeVisible();
|
||||
await expect(scopeModal.getByText('Toutes les occurrences futures')).toBeVisible();
|
||||
});
|
||||
|
||||
test('scope modal can be closed with Escape', 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();
|
||||
const createDialog = page.getByRole('dialog');
|
||||
await expect(createDialog).toBeVisible({ timeout: 10000 });
|
||||
|
||||
await fillSlotForm(createDialog);
|
||||
await createDialog.getByRole('button', { name: /créer/i }).click();
|
||||
await expect(createDialog).not.toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Click on the slot card
|
||||
const slotCard = page.locator('.slot-card');
|
||||
await expect(slotCard).toBeVisible({ timeout: 10000 });
|
||||
await slotCard.click();
|
||||
|
||||
// Scope modal appears
|
||||
const scopeModal = page.locator('.modal-scope');
|
||||
await expect(scopeModal).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Close with Escape
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(scopeModal).not.toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test('choosing "this occurrence" opens edit form', 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();
|
||||
const createDialog = page.getByRole('dialog');
|
||||
await expect(createDialog).toBeVisible({ timeout: 10000 });
|
||||
|
||||
await fillSlotForm(createDialog, { room: 'D401' });
|
||||
await createDialog.getByRole('button', { name: /créer/i }).click();
|
||||
await expect(createDialog).not.toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Click on the slot card
|
||||
const slotCard = page.locator('.slot-card');
|
||||
await expect(slotCard).toBeVisible({ timeout: 10000 });
|
||||
await slotCard.click();
|
||||
|
||||
// Choose "this occurrence"
|
||||
const scopeModal = page.locator('.modal-scope');
|
||||
await expect(scopeModal).toBeVisible({ timeout: 10000 });
|
||||
await scopeModal.getByText('Cette occurrence uniquement').click();
|
||||
|
||||
// Edit form 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user