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 STUDENT_EMAIL = 'e2e-student-schedule@example.com'; const STUDENT_PASSWORD = 'StudentSchedule123'; const TEACHER_EMAIL = 'e2e-student-sched-teacher@example.com'; const TEACHER_PASSWORD = 'TeacherSchedule123'; 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! }; } /** * Returns ISO day of week (1=Monday ... 5=Friday) for the current day, * clamped to weekdays for schedule slot seeding. */ function currentWeekdayIso(): number { const jsDay = new Date().getDay(); // 0=Sun, 1=Mon...6=Sat if (jsDay === 0) return 5; // Sunday → use Friday if (jsDay === 6) return 5; // Saturday → use Friday return jsDay; } /** * Returns the number of day-back navigations needed to reach the seeded weekday. * 0 on weekdays, 1 on Saturday (→ Friday), 2 on Sunday (→ Friday). */ function daysBackToSeededWeekday(): number { const jsDay = new Date().getDay(); if (jsDay === 6) return 1; // Saturday → go back 1 day to Friday if (jsDay === 0) return 2; // Sunday → go back 2 days to Friday return 0; } /** * Returns the French day name for the target seeded weekday. */ function seededDayName(): string { const jsDay = new Date().getDay(); // Saturday → Friday, Sunday → Friday, else today const target = jsDay === 6 ? 5 : jsDay === 0 ? 5 : jsDay; return ['dimanche', 'lundi', 'mardi', 'mercredi', 'jeudi', 'vendredi', 'samedi'][target]!; } /** * On weekends, navigate back to the weekday where schedule slots were seeded. * Must be called after the schedule page has loaded. * * Webkit needs time after page render for Svelte 5 event delegation to hydrate. * We retry clicking until the day title changes, with a timeout. */ async function navigateToSeededDay(page: import('@playwright/test').Page) { const back = daysBackToSeededWeekday(); if (back === 0) return; const targetDay = seededDayName(); const targetPattern = new RegExp(targetDay, 'i'); const prevBtn = page.getByLabel('Précédent'); await expect(prevBtn).toBeVisible({ timeout: 10000 }); // Navigate one day at a time, waiting for each load to complete before clicking again const deadline = Date.now() + 20000; let navigated = false; while (Date.now() < deadline && !navigated) { await prevBtn.click(); // Wait for the schedule API call to complete before checking/clicking again await page.waitForTimeout(1500); const title = await page.locator('.day-title').textContent(); if (title && targetPattern.test(title)) { navigated = true; } } await expect(page.locator('.day-title').getByText(targetPattern)).toBeVisible({ timeout: 5000 }); // Wait for any in-flight schedule loads to settle after reaching target day await page.waitForTimeout(2000); } async function loginAsStudent(page: import('@playwright/test').Page) { await page.goto(`${ALPHA_URL}/login`); await page.locator('#email').fill(STUDENT_EMAIL); await page.locator('#password').fill(STUDENT_PASSWORD); await Promise.all([ page.waitForURL(/\/dashboard/, { timeout: 60000 }), page.getByRole('button', { name: /se connecter/i }).click() ]); } test.describe('Student Schedule Consultation (Story 4.3)', () => { test.describe.configure({ mode: 'serial' }); test.beforeAll(async () => { // Clear caches to prevent stale data from previous runs try { execSync( `docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear cache.rate_limiter users.cache --env=dev 2>&1`, { encoding: 'utf-8' } ); } catch { // Cache pools may not exist } // Create student user execSync( `docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${STUDENT_EMAIL} --password=${STUDENT_PASSWORD} --role=ROLE_ELEVE 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 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-StudentSched-6A', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` ); } catch { // May already exist } // Ensure subject exists 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-StudentSched-Maths', 'E2ESTUMATH', '#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-StudentSched-Français', 'E2ESTUFRA', '#ef4444', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` ); } catch { // May already exist } // Clean up schedule data for this tenant try { runSql(`DELETE FROM schedule_slots WHERE tenant_id = '${TENANT_ID}' AND class_id IN (SELECT id FROM school_classes WHERE name = 'E2E-StudentSched-6A' AND tenant_id = '${TENANT_ID}')`); } catch { // Table may not exist } // Clean up calendar entries to prevent holidays/vacations from blocking schedule resolution try { runSql(`DELETE FROM school_calendar_entries WHERE tenant_id = '${TENANT_ID}'`); } catch { // Table may not exist } // Assign student to class runSql( `INSERT INTO class_assignments (id, tenant_id, user_id, school_class_id, academic_year_id, assigned_at, created_at, updated_at) ` + `SELECT gen_random_uuid(), '${TENANT_ID}', u.id, c.id, '${academicYearId}', NOW(), NOW(), NOW() ` + `FROM users u, school_classes c ` + `WHERE u.email = '${STUDENT_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` + `AND c.name = 'E2E-StudentSched-6A' AND c.tenant_id = '${TENANT_ID}' ` + `ON CONFLICT DO NOTHING` ); // Create schedule slots for the class on today's weekday const dayOfWeek = currentWeekdayIso(); runSql( `INSERT INTO schedule_slots (id, tenant_id, class_id, subject_id, teacher_id, day_of_week, start_time, end_time, room, is_recurring, created_at, updated_at) ` + `SELECT gen_random_uuid(), '${TENANT_ID}', c.id, s.id, t.id, ${dayOfWeek}, '09:00', '10:00', 'A101', true, NOW(), NOW() ` + `FROM school_classes c, ` + `(SELECT id FROM subjects WHERE code = 'E2ESTUMATH' AND tenant_id = '${TENANT_ID}' LIMIT 1) s, ` + `(SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}') t ` + `WHERE c.name = 'E2E-StudentSched-6A' AND c.tenant_id = '${TENANT_ID}'` ); runSql( `INSERT INTO schedule_slots (id, tenant_id, class_id, subject_id, teacher_id, day_of_week, start_time, end_time, room, is_recurring, created_at, updated_at) ` + `SELECT gen_random_uuid(), '${TENANT_ID}', c.id, s.id, t.id, ${dayOfWeek}, '10:15', '11:15', 'B202', true, NOW(), NOW() ` + `FROM school_classes c, ` + `(SELECT id FROM subjects WHERE code = 'E2ESTUFRA' AND tenant_id = '${TENANT_ID}' LIMIT 1) s, ` + `(SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}') t ` + `WHERE c.name = 'E2E-StudentSched-6A' AND c.tenant_id = '${TENANT_ID}'` ); clearCache(); }); // ====================================================================== // AC1: Day View // ====================================================================== test.describe('AC1: Day View', () => { test('student can navigate to schedule page', async ({ page }) => { await loginAsStudent(page); await page.goto(`${ALPHA_URL}/dashboard/schedule`); await expect( page.getByRole('heading', { name: /mon emploi du temps/i }) ).toBeVisible({ timeout: 15000 }); }); test('day view is the default view', async ({ page }) => { await loginAsStudent(page); await page.goto(`${ALPHA_URL}/dashboard/schedule`); await expect( page.getByRole('heading', { name: /mon emploi du temps/i }) ).toBeVisible({ timeout: 15000 }); // Day toggle should be active const dayButton = page.locator('.view-toggle button', { hasText: 'Jour' }); await expect(dayButton).toHaveClass(/active/, { timeout: 5000 }); }); test('day view shows schedule slots for today', async ({ page }) => { await loginAsStudent(page); await page.goto(`${ALPHA_URL}/dashboard/schedule`); await expect( page.getByRole('heading', { name: /mon emploi du temps/i }) ).toBeVisible({ timeout: 15000 }); await navigateToSeededDay(page); // Wait for slots to load const slots = page.locator('[data-testid="schedule-slot"]'); await expect(slots.first()).toBeVisible({ timeout: 20000 }); // Should see both slots await expect(slots).toHaveCount(2); // Verify slot content await expect(page.getByText('E2E-StudentSched-Maths')).toBeVisible(); await expect(page.getByText('E2E-StudentSched-Français')).toBeVisible(); await expect(page.getByText('A101')).toBeVisible(); await expect(page.getByText('B202')).toBeVisible(); }); }); // ====================================================================== // AC2: Day Navigation // ====================================================================== test.describe('AC2: Day Navigation', () => { test('navigating to a day with no courses shows empty message', async ({ page }) => { await loginAsStudent(page); await page.goto(`${ALPHA_URL}/dashboard/schedule`); await expect( page.getByRole('heading', { name: /mon emploi du temps/i }) ).toBeVisible({ timeout: 15000 }); // On weekends, the current day already has no courses — verify directly const back = daysBackToSeededWeekday(); if (back > 0) { await expect(page.getByText('Aucun cours ce jour')).toBeVisible({ timeout: 10000 }); return; } // Wait for today's slots to fully load before navigating await expect(page.locator('[data-testid="schedule-slot"]').first()).toBeVisible({ timeout: 15000 }); // Navigate forward enough days to reach a day with no seeded slots. // Slots are only seeded on today's weekday, so +1 day is guaranteed empty. await page.getByLabel('Suivant').click(); // The day view should show the empty-state message await expect(page.getByText('Aucun cours ce jour')).toBeVisible({ timeout: 10000 }); }); test('can navigate to next day and back', async ({ page }) => { await loginAsStudent(page); await page.goto(`${ALPHA_URL}/dashboard/schedule`); await expect( page.getByRole('heading', { name: /mon emploi du temps/i }) ).toBeVisible({ timeout: 15000 }); await navigateToSeededDay(page); // Wait for slots to load on the seeded day await expect(page.locator('[data-testid="schedule-slot"]').first()).toBeVisible({ timeout: 15000 }); // Navigate to next day — wait for the load to settle before navigating back await page.getByLabel('Suivant').click(); await page.waitForTimeout(1500); // Then navigate back await page.getByLabel('Précédent').click(); // Slots should be visible again const slots = page.locator('[data-testid="schedule-slot"]'); await expect(slots.first()).toBeVisible({ timeout: 20000 }); }); }); // ====================================================================== // AC3: Week View // ====================================================================== test.describe('AC3: Week View', () => { test('can switch to week view and see grid', async ({ page }) => { await loginAsStudent(page); await page.goto(`${ALPHA_URL}/dashboard/schedule`); await navigateToSeededDay(page); // Wait for day view to load (may need extra time for navigation on slow CI) await expect(page.locator('[data-testid="schedule-slot"]').first()).toBeVisible({ timeout: 30000 }); // Switch to week view const weekButton = page.locator('.view-toggle button', { hasText: 'Semaine' }); await weekButton.click(); // Week grid should show day headers (proves week view rendered) // Use exact match to avoid strict mode violation with mobile list labels ("Lun 2" etc.) await expect(page.getByText('Lun', { exact: true })).toBeVisible({ timeout: 15000 }); await expect(page.getByText('Mar', { exact: true })).toBeVisible(); await expect(page.getByText('Mer', { exact: true })).toBeVisible(); await expect(page.getByText('Jeu', { exact: true })).toBeVisible(); await expect(page.getByText('Ven', { exact: true })).toBeVisible(); // Week slots should be visible (scope to desktop grid to avoid hidden mobile slots) const weekSlots = page.locator('.week-slot-desktop[data-testid="week-slot"]'); await expect(weekSlots.first()).toBeVisible({ timeout: 15000 }); }); test('week view shows mobile list layout on small viewport', async ({ page }) => { // Resize to mobile await page.setViewportSize({ width: 375, height: 667 }); await loginAsStudent(page); await page.goto(`${ALPHA_URL}/dashboard/schedule`); await navigateToSeededDay(page); // Wait for day view to load await expect(page.locator('[data-testid="schedule-slot"]').first()).toBeVisible({ timeout: 15000 }); // Switch to week view const weekButton = page.locator('.view-toggle button', { hasText: 'Semaine' }); await weekButton.click(); // Mobile list should be visible, desktop grid should be hidden const weekList = page.locator('.week-list'); const weekGrid = page.locator('.week-grid'); await expect(weekList).toBeVisible({ timeout: 15000 }); await expect(weekGrid).not.toBeVisible(); // Should show day sections with slot count await expect(page.getByText(/\d+ cours/).first()).toBeVisible(); // Week slots should be visible in mobile layout const weekSlots = page.locator('[data-testid="week-slot"]'); await expect(weekSlots.first()).toBeVisible({ timeout: 15000 }); }); test('week view shows desktop grid on large viewport', async ({ page }) => { await loginAsStudent(page); await page.goto(`${ALPHA_URL}/dashboard/schedule`); await navigateToSeededDay(page); // Wait for day view to load await expect(page.locator('[data-testid="schedule-slot"]').first()).toBeVisible({ timeout: 15000 }); // Switch to week view const weekButton = page.locator('.view-toggle button', { hasText: 'Semaine' }); await weekButton.click(); // Desktop grid should be visible, mobile list should be hidden const weekList = page.locator('.week-list'); const weekGrid = page.locator('.week-grid'); await expect(weekGrid).toBeVisible({ timeout: 30000 }); await expect(weekList).not.toBeVisible(); }); test('can switch back to day view from week view', async ({ page }) => { await loginAsStudent(page); await page.goto(`${ALPHA_URL}/dashboard/schedule`); await navigateToSeededDay(page); // Wait for day view to load await expect(page.locator('[data-testid="schedule-slot"]').first()).toBeVisible({ timeout: 15000 }); // Switch to week await page.locator('.view-toggle button', { hasText: 'Semaine' }).click(); await expect(page.locator('.week-slot-desktop[data-testid="week-slot"]').first()).toBeVisible({ timeout: 15000 }); // Switch back to day await page.locator('.view-toggle button', { hasText: 'Jour' }).click(); // Day slots should be visible again (proves day view rendered) await expect(page.locator('[data-testid="schedule-slot"]').first()).toBeVisible({ timeout: 15000 }); }); }); // ====================================================================== // AC4: Slot Details // ====================================================================== test.describe('AC4: Slot Details', () => { test('clicking a slot opens details modal', async ({ page }) => { await loginAsStudent(page); await page.goto(`${ALPHA_URL}/dashboard/schedule`); await navigateToSeededDay(page); // Wait for slots to load const firstSlot = page.locator('[data-testid="schedule-slot"]').first(); await expect(firstSlot).toBeVisible({ timeout: 15000 }); // Click the slot await firstSlot.click(); // Modal should appear with course details const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible({ timeout: 5000 }); // Should show subject, teacher, room, time await expect(dialog.getByText('E2E-StudentSched-Maths')).toBeVisible(); await expect(dialog.getByText('09:00 - 10:00')).toBeVisible(); await expect(dialog.getByText('A101')).toBeVisible(); }); test('details modal closes with Escape key', async ({ page }) => { await loginAsStudent(page); await page.goto(`${ALPHA_URL}/dashboard/schedule`); await navigateToSeededDay(page); const firstSlot = page.locator('[data-testid="schedule-slot"]').first(); await expect(firstSlot).toBeVisible({ timeout: 20000 }); await firstSlot.click(); const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible({ timeout: 5000 }); // Close with Escape await page.keyboard.press('Escape'); await expect(dialog).not.toBeVisible({ timeout: 5000 }); }); test('details modal closes when clicking overlay', async ({ page }) => { await loginAsStudent(page); await page.goto(`${ALPHA_URL}/dashboard/schedule`); await navigateToSeededDay(page); const firstSlot = page.locator('[data-testid="schedule-slot"]').first(); await expect(firstSlot).toBeVisible({ timeout: 15000 }); await firstSlot.click(); const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible({ timeout: 5000 }); // Close by clicking the overlay (outside the card) await page.locator('.overlay').click({ position: { x: 10, y: 10 } }); await expect(dialog).not.toBeVisible({ timeout: 5000 }); }); }); // ====================================================================== // AC5: Offline Mode // ====================================================================== test.describe('AC5: Offline Mode', () => { test('shows offline banner when network is lost', async ({ page, context }) => { await loginAsStudent(page); await page.goto(`${ALPHA_URL}/dashboard/schedule`); await navigateToSeededDay(page); // Wait for schedule to load (data is now cached by the browser) await expect( page.locator('[data-testid="schedule-slot"]').first() ).toBeVisible({ timeout: 15000 }); // Simulate going offline — triggers window 'offline' event await context.setOffline(true); // The offline banner should appear const offlineBanner = page.locator('.offline-banner[role="status"]'); await expect(offlineBanner).toBeVisible({ timeout: 5000 }); await expect(offlineBanner.getByText('Hors ligne')).toBeVisible(); await expect(offlineBanner.getByText(/Dernière sync/)).toBeVisible(); // Restore online await context.setOffline(false); // Banner should disappear await expect(offlineBanner).not.toBeVisible({ timeout: 5000 }); }); }); // ====================================================================== // Navigation link // ====================================================================== test.describe('Navigation', () => { test('schedule link is visible in dashboard header', async ({ page }) => { await loginAsStudent(page); const navLink = page.locator('.desktop-nav a', { hasText: 'Mon EDT' }); await expect(navLink).toBeVisible({ timeout: 10000 }); }); test('clicking schedule nav link navigates to schedule page', async ({ page }) => { await loginAsStudent(page); const navLink = page.locator('.desktop-nav a', { hasText: 'Mon EDT' }); await expect(navLink).toBeVisible({ timeout: 10000 }); await navLink.click(); await expect(page).toHaveURL(/\/dashboard\/schedule/); await expect( page.getByRole('heading', { name: /mon emploi du temps/i }) ).toBeVisible({ timeout: 15000 }); }); }); });