Files
Classeo/frontend/e2e/student-schedule.spec.ts
Mathias STRASSER 36ceefb625 feat: Permettre aux élèves de consulter leur emploi du temps
Les élèves n'avaient aucun moyen de voir leur emploi du temps
depuis l'application. Cette fonctionnalité ajoute une page dédiée
avec deux modes de visualisation (jour et semaine), la navigation
temporelle, et le détail des cours au tap.

Le backend résout l'EDT de l'élève en chaînant : affectation classe →
créneaux récurrents + exceptions + calendrier scolaire → enrichissement
des noms (matières/enseignants). Le frontend utilise un cache offline
(Workbox NetworkFirst) pour rester consultable hors connexion.
2026-03-07 21:14:04 +01:00

557 lines
21 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 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 1; // Sunday → use Monday
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 });
// Retry clicking — on webkit, Svelte 5 event delegation needs time to hydrate
const deadline = Date.now() + 15000;
let navigated = false;
while (Date.now() < deadline && !navigated) {
for (let i = 0; i < back; i++) {
await prevBtn.click();
}
await page.waitForTimeout(500);
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
});
}
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: 30000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}
test.describe('Student Schedule Consultation (Story 4.3)', () => {
test.describe.configure({ mode: 'serial' });
test.beforeAll(async () => {
// 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
await page.getByLabel('Suivant').click();
await page.waitForTimeout(300);
// 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
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();
// 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: 15000 });
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: 15000 });
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 });
});
});
});