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).
659 lines
24 KiB
TypeScript
659 lines
24 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-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')).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 });
|
||
});
|
||
});
|
||
});
|
||
|
||
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();
|
||
});
|
||
});
|
||
});
|