Les enseignants avaient besoin de consignes plus claires pour les élèves : le champ description en texte brut ne permettait ni mise en forme ni partage de documents. Cette limitation obligeait à décrire verbalement les ressources au lieu de les joindre directement. L'éditeur WYSIWYG (TipTap) remplace le textarea avec gras, italique, listes et liens. Le contenu HTML est sanitisé côté backend via symfony/html-sanitizer pour prévenir les injections XSS. Les pièces jointes (PDF, JPEG, PNG, max 10 Mo) sont uploadées via une API dédiée avec validation MIME côté domaine et protection path-traversal sur le téléchargement. Les descriptions en texte brut existantes restent lisibles sans migration de données.
720 lines
29 KiB
TypeScript
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('.modal .rich-text-content').click(); await page.locator('.modal .rich-text-content').pressSequentially('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('.modal .rich-text-content').click(); await page.locator('.modal .rich-text-content').pressSequentially('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('.modal .rich-text-content').click(); await page.locator('.modal .rich-text-content').pressSequentially('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('.modal .rich-text-content')).toContainText('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();
|
|
});
|
|
});
|
|
});
|