Files
Classeo/frontend/e2e/student-schedule.spec.ts
Mathias STRASSER 98be1951bf
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
fix: Corriger les 84 échecs E2E pré-existants
Les tests E2E échouaient pour trois raisons principales :

1. Initialisation asynchrone TipTap — L'éditeur rich-text s'initialise
   via des imports dynamiques dans onMount(). Les tests interagissaient
   avec .rich-text-content avant que l'élément n'existe dans le DOM.
   Ajout d'attentes explicites avant chaque interaction avec l'éditeur.

2. Pollution inter-tests — Les fonctions de nettoyage (classes, subjects)
   ne supprimaient pas les tables dépendantes (homework, evaluations,
   schedule_slots), provoquant des erreurs FK silencieuses dans les
   try/catch. De plus, homework_submissions n'a pas de ON DELETE CASCADE
   sur homework_id, nécessitant une suppression explicite.

3. État partagé du tenant — Les règles de devoirs (homework_rules) et le
   calendrier scolaire (school_calendar_entries avec Vacances de Printemps)
   persistaient entre les fichiers de test, bloquant la création de devoirs
   dans des tests non liés aux règles.
2026-03-27 14:17:17 +01:00

569 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 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
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 });
});
});
});