Files
Classeo/frontend/e2e/homework.spec.ts
Mathias STRASSER a708af3a8f
Some checks failed
CI / Naming Conventions (push) Has been cancelled
CI / Build Check (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Frontend Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
feat: Paralléliser les appels API de la page devoirs
La page devoirs enchaînait séquentiellement les appels classes, subjects,
assignments et homework, produisant un waterfall de ~2.5s. En les lançant
dans un seul Promise.all, le temps de chargement correspond désormais au
plus lent des appels (~600ms) au lieu de leur somme.

Pour résoudre la dépendance de assignments sur le userId (nécessaire dans
l'URL), un nouveau helper getAuthenticatedUserId() encapsule le mécanisme
de token refresh côté module auth, évitant aux pages d'importer
refreshToken directement.

Chaque branche side-effect (loadAssignments, loadHomeworks) gère ses
erreurs via .catch() local pour éviter l'état partiel si l'une échoue
pendant que les autres réussissent.
2026-03-17 00:39:53 +01:00

720 lines
29 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 TEACHER_EMAIL = 'e2e-homework-teacher@example.com';
const TEACHER_PASSWORD = 'HomeworkTest123';
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 a future weekday date string (YYYY-MM-DD).
* Skips weekends to satisfy DueDateValidator.
*/
function getNextWeekday(daysFromNow: number): string {
const date = new Date();
date.setDate(date.getDate() + daysFromNow);
// Skip to Monday if weekend
const day = date.getDay();
if (day === 0) date.setDate(date.getDate() + 1); // Sunday → Monday
if (day === 6) date.setDate(date.getDate() + 2); // Saturday → Monday
// Use local date components to avoid UTC timezone shift from toISOString()
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
async function loginAsTeacher(page: import('@playwright/test').Page) {
await page.goto(`${ALPHA_URL}/login`);
await page.locator('#email').fill(TEACHER_EMAIL);
await page.locator('#password').fill(TEACHER_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}
async function navigateToHomework(page: import('@playwright/test').Page) {
await page.goto(`${ALPHA_URL}/dashboard/teacher/homework`);
await expect(page.getByRole('heading', { name: /mes devoirs/i })).toBeVisible({ timeout: 15000 });
}
function seedTeacherAssignments() {
const { academicYearId } = resolveDeterministicIds();
try {
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
}
}
test.describe('Homework Management (Story 5.1)', () => {
test.beforeAll(async () => {
// 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' }
);
// Ensure classes and subject exist
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-HW-6A', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
);
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-HW-6B', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
);
runSql(
`INSERT INTO subjects (id, tenant_id, school_id, name, code, status, created_at, updated_at) ` +
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', 'E2E-HW-Maths', 'E2EMAT', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
);
} catch {
// May already exist
}
seedTeacherAssignments();
clearCache();
});
test.beforeEach(async () => {
// Clean up homework data
try {
runSql(`DELETE FROM homework WHERE tenant_id = '${TENANT_ID}' AND teacher_id IN (SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}')`);
} catch {
// Table may not exist
}
// Re-ensure data exists
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-HW-6A', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
);
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-HW-6B', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
);
runSql(
`INSERT INTO subjects (id, tenant_id, school_id, name, code, status, created_at, updated_at) ` +
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', 'E2E-HW-Maths', 'E2EMAT', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
);
} catch {
// May already exist
}
seedTeacherAssignments();
clearCache();
});
// ============================================================================
// Navigation
// ============================================================================
test.describe('Navigation', () => {
test('homework link appears in teacher navigation', async ({ page }) => {
await loginAsTeacher(page);
const nav = page.locator('.desktop-nav');
await expect(nav.getByRole('link', { name: /devoirs/i })).toBeVisible({ timeout: 15000 });
});
test('can navigate to homework page', async ({ page }) => {
await loginAsTeacher(page);
await navigateToHomework(page);
await expect(page.getByRole('heading', { name: /mes devoirs/i })).toBeVisible();
});
});
// ============================================================================
// Empty State
// ============================================================================
test.describe('Empty State', () => {
test('shows empty state when no homework exists', async ({ page }) => {
await loginAsTeacher(page);
await navigateToHomework(page);
await expect(page.getByText(/aucun devoir/i)).toBeVisible({ timeout: 10000 });
});
});
// ============================================================================
// AC1: Create Homework
// ============================================================================
test.describe('AC1: Create Homework', () => {
test('can create a new homework', async ({ page }) => {
await loginAsTeacher(page);
await navigateToHomework(page);
// Open create modal
await page.getByRole('button', { name: /nouveau devoir/i }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
// Select class
const classSelect = page.locator('#hw-class');
await expect(classSelect).toBeVisible();
await classSelect.selectOption({ index: 1 });
// Select subject (AC2: filtered by class)
const subjectSelect = page.locator('#hw-subject');
await expect(subjectSelect).toBeEnabled();
await subjectSelect.selectOption({ index: 1 });
// Fill title
await page.locator('#hw-title').fill('Exercices chapitre 5');
// Fill description
await page.locator('#hw-description').fill('Pages 42-45, exercices 1 à 10');
// Set due date (next weekday, at least 2 days from now)
await page.locator('#hw-due-date').fill(getNextWeekday(3));
// Submit
await page.getByRole('button', { name: /créer le devoir/i }).click();
// Modal closes and homework appears in list
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
await expect(page.getByText('Exercices chapitre 5')).toBeVisible({ timeout: 10000 });
});
test('cancel closes the modal without creating', async ({ page }) => {
await loginAsTeacher(page);
await navigateToHomework(page);
await page.getByRole('button', { name: /nouveau devoir/i }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
await page.getByRole('button', { name: /annuler/i }).click();
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 5000 });
});
});
// ============================================================================
// AC2: Subject Filtering
// ============================================================================
test.describe('AC2: Subject Filtering', () => {
test('subject dropdown is disabled until class is selected', async ({ page }) => {
await loginAsTeacher(page);
await navigateToHomework(page);
await page.getByRole('button', { name: /nouveau devoir/i }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
// Subject should be disabled initially
await expect(page.locator('#hw-subject')).toBeDisabled();
// Select class
await page.locator('#hw-class').selectOption({ index: 1 });
// Subject should now be enabled
await expect(page.locator('#hw-subject')).toBeEnabled();
});
});
// ============================================================================
// AC5: Published Homework Appears in List
// ============================================================================
test.describe('AC5: Published Homework', () => {
test('created homework appears in list with correct info', async ({ page }) => {
await loginAsTeacher(page);
await navigateToHomework(page);
// Create a homework
await page.getByRole('button', { name: /nouveau devoir/i }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 10000 });
await page.locator('#hw-class').selectOption({ index: 1 });
await page.locator('#hw-subject').selectOption({ index: 1 });
await page.locator('#hw-title').fill('Devoir de mathématiques');
await page.locator('#hw-due-date').fill(getNextWeekday(5));
await page.getByRole('button', { name: /créer le devoir/i }).click();
await expect(dialog).not.toBeVisible({ timeout: 10000 });
// Verify homework card shows
await expect(page.getByText('Devoir de mathématiques')).toBeVisible({ timeout: 10000 });
await expect(page.getByText(/publié/i)).toBeVisible();
});
});
// ============================================================================
// AC6: Edit Homework
// ============================================================================
test.describe('AC6: Edit Homework', () => {
test('can modify an existing homework', async ({ page }) => {
await loginAsTeacher(page);
await navigateToHomework(page);
// First create a homework
await page.getByRole('button', { name: /nouveau devoir/i }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
await page.locator('#hw-class').selectOption({ index: 1 });
await page.locator('#hw-subject').selectOption({ index: 1 });
await page.locator('#hw-title').fill('Devoir à modifier');
await page.locator('#hw-due-date').fill(getNextWeekday(5));
await page.getByRole('button', { name: /créer le devoir/i }).click();
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
await expect(page.getByText('Devoir à modifier')).toBeVisible({ timeout: 10000 });
// Click edit
await page.getByRole('button', { name: /modifier/i }).first().click();
const editDialog = page.getByRole('dialog');
await expect(editDialog).toBeVisible({ timeout: 10000 });
// Change title
await page.locator('#edit-title').clear();
await page.locator('#edit-title').fill('Devoir modifié');
// Save
await page.getByRole('button', { name: /enregistrer/i }).click();
await expect(editDialog).not.toBeVisible({ timeout: 10000 });
// Verify updated title
await expect(page.getByText('Devoir modifié')).toBeVisible({ timeout: 10000 });
});
test('can delete an existing homework', async ({ page }) => {
await loginAsTeacher(page);
await navigateToHomework(page);
// First create a homework
await page.getByRole('button', { name: /nouveau devoir/i }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
await page.locator('#hw-class').selectOption({ index: 1 });
await page.locator('#hw-subject').selectOption({ index: 1 });
await page.locator('#hw-title').fill('Devoir à supprimer');
await page.locator('#hw-due-date').fill(getNextWeekday(5));
await page.getByRole('button', { name: /créer le devoir/i }).click();
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
await expect(page.getByText('Devoir à supprimer')).toBeVisible({ timeout: 10000 });
// Click delete
await page.getByRole('button', { name: /supprimer/i }).first().click();
// Confirm in alertdialog
const confirmDialog = page.getByRole('alertdialog');
await expect(confirmDialog).toBeVisible({ timeout: 5000 });
await expect(page.getByText(/êtes-vous sûr/i)).toBeVisible();
await confirmDialog.getByRole('button', { name: /supprimer/i }).click();
await expect(confirmDialog).not.toBeVisible({ timeout: 10000 });
// Homework should be gone from the list (or show as deleted)
await expect(page.getByText('Devoir à supprimer')).not.toBeVisible({ timeout: 10000 });
});
});
// ============================================================================
// Date Validation
// ============================================================================
test.describe('Date Validation', () => {
test('due date input has min attribute set to tomorrow', async ({ page }) => {
await loginAsTeacher(page);
await navigateToHomework(page);
await page.getByRole('button', { name: /nouveau devoir/i }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
const dueDateInput = page.locator('#hw-due-date');
const minValue = await dueDateInput.getAttribute('min');
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const ey = tomorrow.getFullYear();
const em = String(tomorrow.getMonth() + 1).padStart(2, '0');
const ed = String(tomorrow.getDate()).padStart(2, '0');
expect(minValue).toBe(`${ey}-${em}-${ed}`);
});
test('backend rejects a past due date', async ({ page }) => {
await loginAsTeacher(page);
await navigateToHomework(page);
await page.getByRole('button', { name: /nouveau devoir/i }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
await page.locator('#hw-class').selectOption({ index: 1 });
await page.locator('#hw-subject').selectOption({ index: 1 });
await page.locator('#hw-title').fill('Devoir date passée');
await page.locator('#hw-description').fill('Test validation');
// Set a past date — fill() works with Svelte 5 bind:value
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
const y = yesterday.getFullYear();
const m = String(yesterday.getMonth() + 1).padStart(2, '0');
const d = String(yesterday.getDate()).padStart(2, '0');
const pastDate = `${y}-${m}-${d}`;
await page.locator('#hw-due-date').fill(pastDate);
// Bypass HTML native validation (min attribute) to test backend validation
await page.locator('form.modal-body').evaluate((el) => el.setAttribute('novalidate', ''));
await page.getByRole('button', { name: /créer le devoir/i }).click();
// Backend should reject — an error alert appears
await expect(page.locator('.alert-error')).toBeVisible({ timeout: 10000 });
});
});
// ============================================================================
// AC: Duplicate Homework (Story 5.2)
// ============================================================================
test.describe('Story 5.2: Duplicate Homework', () => {
test.beforeAll(async () => {
// Ensure a second class exists for duplication
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-HW-6B', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
);
} catch {
// May already exist
}
seedTeacherAssignments();
clearCache();
});
async function createHomework(page: import('@playwright/test').Page, title: string) {
await createHomeworkInClass(page, title, { index: 1 });
}
async function createHomeworkInClass(
page: import('@playwright/test').Page,
title: string,
classOption: { index: number } | { label: string },
) {
await page.getByRole('button', { name: /nouveau devoir/i }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
await page.locator('#hw-class').selectOption(classOption);
await page.locator('#hw-subject').selectOption({ index: 1 });
await page.locator('#hw-title').fill(title);
await page.locator('#hw-due-date').fill(getNextWeekday(5));
await page.getByRole('button', { name: /créer le devoir/i }).click();
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
await expect(page.getByText(title)).toBeVisible({ timeout: 10000 });
}
test('duplicate button is visible on homework card', async ({ page }) => {
await loginAsTeacher(page);
await navigateToHomework(page);
await createHomework(page, 'Devoir dupliquer test');
await expect(page.getByRole('button', { name: /dupliquer/i })).toBeVisible();
});
test('opens duplicate modal with class selection', async ({ page }) => {
await loginAsTeacher(page);
await navigateToHomework(page);
await createHomework(page, 'Devoir à dupliquer');
await page.getByRole('button', { name: /dupliquer/i }).first().click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 10000 });
await expect(dialog.getByText(/dupliquer le devoir/i)).toBeVisible();
await expect(dialog.getByText('Devoir à dupliquer')).toBeVisible();
});
test('shows target classes checkboxes excluding source class', async ({ page }) => {
await loginAsTeacher(page);
await navigateToHomework(page);
await createHomework(page, 'Devoir classes cibles');
await page.getByRole('button', { name: /dupliquer/i }).first().click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 10000 });
// Should show checkboxes for target classes
const checkboxes = dialog.locator('input[type="checkbox"]');
await expect(checkboxes.first()).toBeVisible({ timeout: 5000 });
});
test('can duplicate homework to a target class', async ({ page }) => {
await loginAsTeacher(page);
await navigateToHomework(page);
await createHomework(page, 'Devoir duplication réussie');
await page.getByRole('button', { name: /dupliquer/i }).first().click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 10000 });
// Select first target class
const firstCheckbox = dialog.locator('input[type="checkbox"]').first();
await firstCheckbox.check();
// Click duplicate button
await page.getByRole('button', { name: /dupliquer \(1 classe\)/i }).click();
await expect(dialog).not.toBeVisible({ timeout: 10000 });
// Homework should now appear twice (original + duplicate)
await expect(page.getByText('Devoir duplication réussie')).toHaveCount(2, { timeout: 10000 });
});
test('duplicate button is disabled when no class selected', async ({ page }) => {
await loginAsTeacher(page);
await navigateToHomework(page);
await createHomework(page, 'Devoir sans sélection');
await page.getByRole('button', { name: /dupliquer/i }).first().click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 10000 });
// Duplicate button should be disabled
const duplicateBtn = dialog.getByRole('button', { name: /dupliquer/i }).last();
await expect(duplicateBtn).toBeDisabled();
});
test('cancel closes the duplicate modal', async ({ page }) => {
await loginAsTeacher(page);
await navigateToHomework(page);
await createHomework(page, 'Devoir annulation dupli');
await page.getByRole('button', { name: /dupliquer/i }).first().click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 10000 });
await dialog.getByRole('button', { name: /annuler/i }).click();
await expect(dialog).not.toBeVisible({ timeout: 5000 });
});
test('can duplicate homework to multiple classes', async ({ page }) => {
await loginAsTeacher(page);
await navigateToHomework(page);
await createHomework(page, 'Devoir multi-dupli');
await page.getByRole('button', { name: /dupliquer/i }).first().click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 10000 });
// Select ALL available checkboxes (at least 2 target classes)
const checkboxes = dialog.locator('input[type="checkbox"]');
const count = await checkboxes.count();
expect(count).toBeGreaterThanOrEqual(1);
for (let i = 0; i < count; i++) {
await checkboxes.nth(i).check();
}
// Verify button text reflects the count
const duplicateButton = dialog.getByRole('button', {
name: new RegExp(`dupliquer \\(${count} classes?\\)`, 'i')
});
await expect(duplicateButton).toBeVisible();
await expect(duplicateButton).toBeEnabled();
// Click duplicate
await duplicateButton.click();
await expect(dialog).not.toBeVisible({ timeout: 10000 });
// Verify the homework title appears count+1 times (original + duplicates)
await expect(page.getByText('Devoir multi-dupli')).toHaveCount(count + 1, { timeout: 10000 });
});
test('can customize due date per target class', async ({ page }) => {
await loginAsTeacher(page);
await navigateToHomework(page);
await createHomework(page, 'Devoir date custom');
await page.getByRole('button', { name: /dupliquer/i }).first().click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 10000 });
// Select the first checkbox
const firstCheckbox = dialog.locator('input[type="checkbox"]').first();
await firstCheckbox.check();
// A date input should appear after checking the checkbox
const dueDateInput = dialog.locator('input[type="date"]').first();
await expect(dueDateInput).toBeVisible({ timeout: 5000 });
// Change the due date to a custom value
await dueDateInput.fill(getNextWeekday(10));
// Click duplicate
await page.getByRole('button', { name: /dupliquer \(1 classe\)/i }).click();
await expect(dialog).not.toBeVisible({ timeout: 10000 });
// Verify the homework appears twice (original + 1 duplicate)
await expect(page.getByText('Devoir date custom')).toHaveCount(2, { timeout: 10000 });
});
test('shows validation errors per class when duplication rules fail', async ({ page }) => {
await loginAsTeacher(page);
await navigateToHomework(page);
await createHomework(page, 'Devoir validation règles');
await page.getByRole('button', { name: /dupliquer/i }).first().click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 10000 });
// Select first target class
const firstCheckbox = dialog.locator('input[type="checkbox"]').first();
await firstCheckbox.check();
// Intercept the POST duplicate API call to simulate a validation error
await page.route(
'**/api/homework/*/duplicate',
async (route, request) => {
// Extract targetClassIds from the request body
const body = request.postDataJSON();
const targetClassIds: string[] = body.targetClassIds ?? [];
await route.fulfill({
status: 400,
contentType: 'application/json',
body: JSON.stringify({
error: 'Validation échouée pour certaines classes.',
results: targetClassIds.map((classId: string) => ({
classId,
valid: false,
error: "L'enseignant n'est pas affecté à cette classe pour cette matière.",
})),
}),
});
},
);
// Click duplicate
await page.getByRole('button', { name: /dupliquer \(1 classe\)/i }).click();
// Verify: error alert appears in the modal
await expect(dialog.locator('.alert-error')).toBeVisible({ timeout: 10000 });
await expect(dialog.getByText(/certaines classes ne passent pas la validation/i)).toBeVisible();
// Verify: validation error message appears next to the class
await expect(dialog.locator('.validation-error')).toBeVisible();
await expect(
dialog.getByText(/l'enseignant n'est pas affecté à cette classe/i),
).toBeVisible();
// Verify: modal stays open (not closed on validation error)
await expect(dialog).toBeVisible();
});
test('can filter homework list by class', async ({ page }) => {
await loginAsTeacher(page);
await navigateToHomework(page);
// Create homework in two different classes
await createHomeworkInClass(page, 'Devoir classe A', { label: 'E2E-HW-6A' });
await createHomeworkInClass(page, 'Devoir classe B', { label: 'E2E-HW-6B' });
// Both visible initially
await expect(page.getByText('Devoir classe A')).toBeVisible({ timeout: 5000 });
await expect(page.getByText('Devoir classe B')).toBeVisible({ timeout: 5000 });
// Filter by class A — class B homework must disappear
const classFilter = page.locator('select[aria-label="Filtrer par classe"]');
await classFilter.selectOption({ label: 'E2E-HW-6A' });
await expect(page.getByText('Devoir classe A')).toBeVisible({ timeout: 5000 });
await expect(page.getByText('Devoir classe B')).not.toBeVisible({ timeout: 5000 });
// Reset — both visible again
await classFilter.selectOption({ index: 0 });
await expect(page.getByText('Devoir classe A')).toBeVisible({ timeout: 5000 });
await expect(page.getByText('Devoir classe B')).toBeVisible({ timeout: 5000 });
});
});
// ============================================================================
// Partial Update
// ============================================================================
test.describe('Partial Update', () => {
test('can update only the title without changing other fields', async ({ page }) => {
await loginAsTeacher(page);
await navigateToHomework(page);
const dueDate = getNextWeekday(5);
// Create a homework with description
await page.getByRole('button', { name: /nouveau devoir/i }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
await page.locator('#hw-class').selectOption({ index: 1 });
await page.locator('#hw-subject').selectOption({ index: 1 });
await page.locator('#hw-title').fill('Titre original');
await page.locator('#hw-description').fill('Description inchangée');
await page.locator('#hw-due-date').fill(dueDate);
await page.getByRole('button', { name: /créer le devoir/i }).click();
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
await expect(page.getByText('Titre original')).toBeVisible({ timeout: 10000 });
// Open edit modal
await page.getByRole('button', { name: /modifier/i }).first().click();
const editDialog = page.getByRole('dialog');
await expect(editDialog).toBeVisible({ timeout: 10000 });
// Verify pre-filled values
await expect(page.locator('#edit-description')).toHaveValue('Description inchangée');
await expect(page.locator('#edit-due-date')).toHaveValue(dueDate);
// Change only the title
await page.locator('#edit-title').clear();
await page.locator('#edit-title').fill('Titre mis à jour');
await page.getByRole('button', { name: /enregistrer/i }).click();
await expect(editDialog).not.toBeVisible({ timeout: 10000 });
// Verify title changed
await expect(page.getByText('Titre mis à jour')).toBeVisible({ timeout: 10000 });
await expect(page.getByText('Titre original')).not.toBeVisible();
// Verify description still visible
await expect(page.getByText('Description inchangée')).toBeVisible();
});
});
});