feat: Permettre aux enseignants de dupliquer un devoir vers plusieurs classes
Un enseignant qui donne le même travail à plusieurs classes devait jusqu'ici recréer manuellement chaque devoir. La duplication permet de sélectionner les classes cibles, d'ajuster les dates d'échéance par classe, et de créer tous les devoirs en une seule opération atomique (transaction). La validation s'effectue par classe (affectation enseignant, date d'échéance) avec un rapport d'erreurs détaillé. L'infrastructure de warnings est prête pour les règles de timing de la Story 5.3. Le filtrage par classe dans la liste des devoirs passe côté serveur pour rester compatible avec la pagination.
This commit is contained in:
@@ -401,6 +401,202 @@ test.describe('Homework Management (Story 5.1)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// 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 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(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('can filter homework list by class', async ({ page }) => {
|
||||
await loginAsTeacher(page);
|
||||
await navigateToHomework(page);
|
||||
await createHomework(page, 'Devoir filtre classe');
|
||||
|
||||
// Class filter should be visible
|
||||
const classFilter = page.locator('select[aria-label="Filtrer par classe"]');
|
||||
await expect(classFilter).toBeVisible();
|
||||
|
||||
// Select a class to filter
|
||||
await classFilter.selectOption({ index: 1 });
|
||||
|
||||
// Should still show homework (it matches the filter)
|
||||
await expect(page.getByText('Devoir filtre classe')).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Select "Toutes les classes" to reset
|
||||
await classFilter.selectOption({ index: 0 });
|
||||
await expect(page.getByText('Devoir filtre classe')).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Partial Update
|
||||
// ============================================================================
|
||||
|
||||
Reference in New Issue
Block a user