feat: Permettre la création manuelle d'élèves et leur affectation aux classes

Les administrateurs et secrétaires avaient besoin de pouvoir inscrire un
élève en cours d'année sans passer par un import CSV. Cette fonctionnalité
pose aussi les fondations du modèle élève↔classe (ClassAssignment) qui
sera réutilisé par l'import CSV en masse (Story 3.1).

L'email est désormais optionnel pour les élèves : si fourni, une invitation
est envoyée (User::inviter) ; sinon l'élève est créé avec le statut
INSCRIT sans accès compte (User::inscrire). La création de l'utilisateur
et l'affectation à la classe sont atomiques (transaction DBAL).

Côté frontend, la page /admin/students offre liste paginée, recherche,
filtrage par classe, création via modale (avec détection de doublons
côté serveur), et changement de classe avec optimistic update.
This commit is contained in:
2026-02-23 19:12:21 +01:00
parent e5203097ef
commit 560b941821
49 changed files with 5184 additions and 65 deletions

View File

@@ -0,0 +1,642 @@
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 TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
const ADMIN_EMAIL = 'e2e-student-creation-admin@example.com';
const ADMIN_PASSWORD = 'StudentCreationTest123';
const UNIQUE_SUFFIX = Date.now();
const projectRoot = join(__dirname, '../..');
const composeFile = join(projectRoot, 'compose.yaml');
function runCommand(sql: string) {
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "${sql}" 2>&1`,
{ encoding: 'utf-8' }
);
}
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, academicYearId };
}
async function loginAsAdmin(page: import('@playwright/test').Page) {
await page.goto(`${ALPHA_URL}/login`);
await page.locator('#email').fill(ADMIN_EMAIL);
await page.locator('#password').fill(ADMIN_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}
async function waitForStudentsPage(page: import('@playwright/test').Page) {
await expect(
page.getByRole('heading', { name: /gestion des élèves/i })
).toBeVisible({ timeout: 15000 });
await expect(
page.locator('.empty-state, .students-table, .alert-error')
).toBeVisible({ timeout: 15000 });
}
let testClassId: string;
let testClassId2: string;
function createBulkStudents(count: number, tenantId: string, classId: string, academicYearId: string) {
for (let i = 0; i < count; i++) {
const suffix = UNIQUE_SUFFIX.toString().slice(-8);
const paddedI = String(i).padStart(4, '0');
const userId = `00000000-e2e0-4000-8000-${suffix}${paddedI}`;
const assignmentId = `00000001-e2e0-4000-8000-${suffix}${paddedI}`;
try {
runCommand(
`INSERT INTO users (id, tenant_id, email, first_name, last_name, roles, hashed_password, statut, school_name, date_naissance, created_at, activated_at, invited_at, blocked_at, blocked_reason, consentement_parent_id, consentement_eleve_id, consentement_date, consentement_ip, image_rights_status, image_rights_updated_at, image_rights_updated_by, student_number, updated_at) VALUES ('${userId}', '${tenantId}', NULL, 'Pagination${i}', 'Student-${UNIQUE_SUFFIX}', '[\\"ROLE_ELEVE\\"]', NULL, 'inscrit', 'E2E Test School', NULL, NOW(), NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'not_specified', NULL, NULL, NULL, NOW()) ON CONFLICT (id) DO NOTHING`
);
runCommand(
`INSERT INTO class_assignments (id, tenant_id, user_id, school_class_id, academic_year_id, assigned_at, created_at, updated_at) VALUES ('${assignmentId}', '${tenantId}', '${userId}', '${classId}', '${academicYearId}', NOW(), NOW(), NOW()) ON CONFLICT (id) DO NOTHING`
);
} catch {
// Student may already exist
}
}
}
test.describe('Student Creation & Management (Story 3.0)', () => {
test.describe.configure({ mode: 'serial' });
test.beforeAll(async () => {
// Create admin user
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${ADMIN_EMAIL} --password=${ADMIN_PASSWORD} --role=ROLE_ADMIN 2>&1`,
{ encoding: 'utf-8' }
);
// Create a test class for student assignment
const { schoolId, academicYearId } = resolveDeterministicIds();
testClassId = `e2e-class-${UNIQUE_SUFFIX}`.substring(0, 36).padEnd(36, '0');
// Use a valid UUID format
testClassId = `00000000-0000-0000-0000-${UNIQUE_SUFFIX.toString().padStart(12, '0')}`;
try {
runCommand(
`INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, capacity, status, created_at, updated_at) VALUES ('${testClassId}', '${TENANT_ID}', '${schoolId}', '${academicYearId}', 'E2E Test Class ${UNIQUE_SUFFIX}', 'CM2', 30, 'active', NOW(), NOW()) ON CONFLICT (id) DO NOTHING`
);
} catch {
// Class may already exist
}
// Create a second test class for change-class tests
testClassId2 = `00000001-0000-0000-0000-${UNIQUE_SUFFIX.toString().padStart(12, '0')}`;
try {
runCommand(
`INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, capacity, status, created_at, updated_at) VALUES ('${testClassId2}', '${TENANT_ID}', '${schoolId}', '${academicYearId}', 'E2E Test Class 2 ${UNIQUE_SUFFIX}', 'CE2', 30, 'active', NOW(), NOW()) ON CONFLICT (id) DO NOTHING`
);
} catch {
// Class may already exist
}
// Create 31 students for pagination tests (itemsPerPage = 30)
createBulkStudents(31, TENANT_ID, testClassId, academicYearId);
});
// ============================================================================
// Navigation
// ============================================================================
test.describe('Navigation', () => {
test('students page is accessible from admin nav', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/students`);
await expect(page.getByRole('heading', { name: /gestion des élèves/i })).toBeVisible({
timeout: 10000
});
});
test('page title is set correctly', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/students`);
await expect(page).toHaveTitle(/gestion des élèves/i);
});
test('nav menu shows Élèves link', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/students`);
// The nav should have an active "Élèves" link
await expect(page.locator('nav a', { hasText: /élèves/i })).toBeVisible({
timeout: 10000
});
});
});
// ============================================================================
// AC1: Create Student Form
// ============================================================================
test.describe('AC1 - Create Student Modal', () => {
test('can open create student modal', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/students`);
await waitForStudentsPage(page);
await page.getByRole('button', { name: /nouvel élève/i }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 5000 });
await expect(
dialog.getByRole('heading', { name: /nouvel élève/i })
).toBeVisible();
});
test('modal has all required fields', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/students`);
await waitForStudentsPage(page);
await page.getByRole('button', { name: /nouvel élève/i }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 5000 });
// Required fields
await expect(dialog.locator('#student-lastname')).toBeVisible();
await expect(dialog.locator('#student-firstname')).toBeVisible();
await expect(dialog.locator('#student-class')).toBeVisible();
// Optional fields
await expect(dialog.locator('#student-email')).toBeVisible();
await expect(dialog.locator('#student-dob')).toBeVisible();
await expect(dialog.locator('#student-ine')).toBeVisible();
});
test('class dropdown uses optgroup by level', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/students`);
await waitForStudentsPage(page);
await page.getByRole('button', { name: /nouvel élève/i }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 5000 });
// Check optgroups exist in the class select
const optgroups = dialog.locator('#student-class optgroup');
const count = await optgroups.count();
expect(count).toBeGreaterThanOrEqual(1);
});
test('can close modal with cancel button', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/students`);
await waitForStudentsPage(page);
await page.getByRole('button', { name: /nouvel élève/i }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 5000 });
await dialog.getByRole('button', { name: /annuler/i }).click();
await expect(dialog).not.toBeVisible();
});
test('can close modal with Escape key', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/students`);
await waitForStudentsPage(page);
await page.getByRole('button', { name: /nouvel élève/i }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 5000 });
// Focus the modal so the keydown handler receives the event
await dialog.focus();
await page.keyboard.press('Escape');
await expect(dialog).not.toBeVisible();
});
});
// ============================================================================
// AC2: Create student with class assignment
// ============================================================================
test.describe('AC2 - Student Creation', () => {
test('can create a student without email', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/students`);
await waitForStudentsPage(page);
await page.getByRole('button', { name: /nouvel élève/i }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 5000 });
// Fill required fields
await dialog.locator('#student-lastname').fill(`Dupont-${UNIQUE_SUFFIX}`);
await dialog.locator('#student-firstname').fill('Marie');
// Select first available class
await dialog.locator('#student-class').selectOption({ index: 1 });
// Submit
await dialog.getByRole('button', { name: /créer l'élève/i }).click();
// Success message should appear
await expect(page.locator('.alert-success')).toBeVisible({ timeout: 10000 });
await expect(page.locator('.alert-success')).toContainText(/inscrit/i);
// Student should appear in the list
await expect(page.locator('.students-table')).toBeVisible({ timeout: 5000 });
await expect(
page.locator('td', { hasText: `Dupont-${UNIQUE_SUFFIX}` })
).toBeVisible();
});
test('can create a student with email', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/students`);
await waitForStudentsPage(page);
await page.getByRole('button', { name: /nouvel élève/i }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 5000 });
await dialog.locator('#student-lastname').fill(`Martin-${UNIQUE_SUFFIX}`);
await dialog.locator('#student-firstname').fill('Jean');
await dialog.locator('#student-email').fill(`jean.martin.${UNIQUE_SUFFIX}@example.com`);
await dialog.locator('#student-class').selectOption({ index: 1 });
await dialog.getByRole('button', { name: /créer l'élève/i }).click();
await expect(page.locator('.alert-success')).toBeVisible({ timeout: 10000 });
await expect(page.locator('.alert-success')).toContainText(/invitation/i);
});
test('"Créer un autre" keeps modal open', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/students`);
await waitForStudentsPage(page);
await page.getByRole('button', { name: /nouvel élève/i }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 5000 });
// Check "Créer un autre élève"
await dialog.locator('input[type="checkbox"]').check();
await dialog.locator('#student-lastname').fill(`Bernard-${UNIQUE_SUFFIX}`);
await dialog.locator('#student-firstname').fill('Luc');
await dialog.locator('#student-class').selectOption({ index: 1 });
await dialog.getByRole('button', { name: /créer l'élève/i }).click();
// Success should appear but modal stays open
await expect(page.locator('.alert-success')).toBeVisible({ timeout: 10000 });
await expect(dialog).toBeVisible();
// Form fields should be cleared
await expect(dialog.locator('#student-lastname')).toHaveValue('');
await expect(dialog.locator('#student-firstname')).toHaveValue('');
// Close the modal
await dialog.getByRole('button', { name: /annuler/i }).click();
});
});
// ============================================================================
// AC3: Data validation
// ============================================================================
test.describe('AC3 - Validation', () => {
test('[P0] INE validation shows error for invalid format', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/students`);
await waitForStudentsPage(page);
await page.getByRole('button', { name: /nouvel élève/i }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 5000 });
// Fill required fields
await dialog.locator('#student-firstname').fill('Test');
await dialog.locator('#student-lastname').fill('INEValidation');
await dialog.locator('#student-class').selectOption({ index: 1 });
// Enter invalid INE (too short)
await dialog.locator('#student-ine').fill('ABC');
// Error message should appear
await expect(dialog.locator('.field-error')).toBeVisible();
await expect(dialog.locator('.field-error')).toContainText(/11 caractères/i);
// Submit button should be disabled
await expect(
dialog.getByRole('button', { name: /créer l'élève/i })
).toBeDisabled();
// Fix INE to valid format (11 alphanumeric chars)
await dialog.locator('#student-ine').fill('12345678901');
// Error should disappear
await expect(dialog.locator('.field-error')).not.toBeVisible();
// Submit button should be enabled
await expect(
dialog.getByRole('button', { name: /créer l'élève/i })
).toBeEnabled();
// Close modal without creating
await dialog.getByRole('button', { name: /annuler/i }).click();
});
test('[P0] shows error when email is already used', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/students`);
await waitForStudentsPage(page);
await page.getByRole('button', { name: /nouvel élève/i }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 5000 });
// Use the same email as the student created in AC2
await dialog.locator('#student-firstname').fill('Doublon');
await dialog.locator('#student-lastname').fill('Email');
await dialog
.locator('#student-email')
.fill(`jean.martin.${UNIQUE_SUFFIX}@example.com`);
await dialog.locator('#student-class').selectOption({ index: 1 });
await dialog.getByRole('button', { name: /créer l'élève/i }).click();
// Error should appear (from API)
await expect(page.locator('.alert-error')).toBeVisible({ timeout: 10000 });
await expect(page.locator('.alert-error')).toContainText(/email/i);
// Close modal
await dialog.getByRole('button', { name: /annuler/i }).click();
});
test('[P0] shows duplicate warning for same name in same class', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/students`);
await waitForStudentsPage(page);
await page.getByRole('button', { name: /nouvel élève/i }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 5000 });
// Use same name as student created in AC2
await dialog.locator('#student-firstname').fill('Marie');
await dialog.locator('#student-lastname').fill(`Dupont-${UNIQUE_SUFFIX}`);
await dialog.locator('#student-class').selectOption({ index: 1 });
// Submit — should trigger duplicate check
await dialog.getByRole('button', { name: /créer l'élève/i }).click();
// Duplicate warning should appear
await expect(dialog.locator('.duplicate-warning')).toBeVisible({
timeout: 10000
});
await expect(dialog.locator('.duplicate-warning')).toContainText(/existe déjà/i);
// Click "Annuler" — warning disappears
await dialog
.locator('.duplicate-warning')
.getByRole('button', { name: /annuler/i })
.click();
await expect(dialog.locator('.duplicate-warning')).not.toBeVisible();
// Submit again — warning reappears
await dialog.getByRole('button', { name: /créer l'élève/i }).click();
await expect(dialog.locator('.duplicate-warning')).toBeVisible({
timeout: 10000
});
// Click "Continuer" — creation succeeds despite duplicate
await dialog
.locator('.duplicate-warning')
.getByRole('button', { name: /continuer/i })
.click();
await expect(page.locator('.alert-success')).toBeVisible({ timeout: 10000 });
});
});
// ============================================================================
// AC4: Students listing page
// ============================================================================
test.describe('AC4 - Students List', () => {
test('displays students in a table', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/students`);
await waitForStudentsPage(page);
// Table should have headers
await expect(page.locator('.students-table th', { hasText: /nom/i })).toBeVisible();
await expect(page.locator('.students-table th', { hasText: /classe/i })).toBeVisible();
await expect(page.locator('.students-table th', { hasText: /statut/i })).toBeVisible();
});
test('search filters students by name', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/students`);
await waitForStudentsPage(page);
// Search for a student created earlier
const searchInput = page.locator('input[type="search"]');
await searchInput.fill(`Dupont-${UNIQUE_SUFFIX}`);
await page.waitForTimeout(500);
await page.waitForLoadState('networkidle');
// Should find the student (use .first() because AC3 duplicate test creates a second one)
await expect(
page.locator('td', { hasText: `Dupont-${UNIQUE_SUFFIX}` }).first()
).toBeVisible({ timeout: 10000 });
});
test('rows are clickable and navigate to student detail', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/students`);
await waitForStudentsPage(page);
// Search for specific student
const searchInput = page.locator('input[type="search"]');
await searchInput.fill(`Dupont-${UNIQUE_SUFFIX}`);
await page.waitForTimeout(500);
await page.waitForLoadState('networkidle');
// Click on the row (use .first() because AC3 duplicate test creates a second one)
const row = page.locator('.clickable-row', {
hasText: `Dupont-${UNIQUE_SUFFIX}`
}).first();
await row.click();
// Should navigate to student detail page
await expect(page).toHaveURL(/\/admin\/students\/[a-f0-9-]+/);
await expect(
page.getByRole('heading', { name: /fiche élève/i })
).toBeVisible({ timeout: 10000 });
});
test('[P1] pagination appears when more than 30 students and navigation works', async ({
page
}) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/students`);
await waitForStudentsPage(page);
// Pagination nav should be visible (31 students created in beforeAll)
const paginationNav = page.locator('nav[aria-label="Pagination"]');
await expect(paginationNav).toBeVisible({ timeout: 10000 });
// "Précédent" button should be disabled on page 1
await expect(
paginationNav.getByRole('button', { name: /précédent/i })
).toBeDisabled();
// Page 1 button should be active
await expect(
paginationNav.getByRole('button', { name: 'Page 1', exact: true })
).toHaveAttribute('aria-current', 'page');
// Click "Suivant" to go to page 2
await paginationNav.getByRole('button', { name: /suivant/i }).click();
await page.waitForLoadState('networkidle');
// URL should contain page=2
await expect(page).toHaveURL(/page=2/);
// Page 2 button should now be active
await expect(
paginationNav.getByRole('button', { name: 'Page 2', exact: true })
).toHaveAttribute('aria-current', 'page');
// "Précédent" should now be enabled
await expect(
paginationNav.getByRole('button', { name: /précédent/i })
).toBeEnabled();
// Table or content should still be visible (not error)
await expect(
page.locator('.students-table, .empty-state')
).toBeVisible({ timeout: 10000 });
});
});
// ============================================================================
// AC5: Change student class
// ============================================================================
test.describe('AC5 - Change Student Class', () => {
test('[P1] can change class via modal with confirmation and optimistic update', async ({
page
}) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/students`);
await waitForStudentsPage(page);
// Search for a student created earlier
const searchInput = page.locator('input[type="search"]');
await searchInput.fill(`Dupont-${UNIQUE_SUFFIX}`);
await page.waitForTimeout(500);
await page.waitForLoadState('networkidle');
// Find the student row and click "Changer de classe" (use .first() because AC3 duplicate test creates a second one)
const row = page.locator('.clickable-row', {
hasText: `Dupont-${UNIQUE_SUFFIX}`
}).first();
await expect(row).toBeVisible({ timeout: 10000 });
await row.locator('button', { hasText: /changer de classe/i }).click();
// Change class modal should open
const dialog = page.locator('[role="alertdialog"]');
await expect(dialog).toBeVisible({ timeout: 5000 });
await expect(
dialog.getByRole('heading', { name: /changer de classe/i })
).toBeVisible();
// Description should mention the student name
await expect(dialog.locator('#change-class-description')).toContainText(
`Dupont-${UNIQUE_SUFFIX}`
);
// Select a different class
await dialog.locator('#change-class-select').selectOption({ index: 1 });
// Confirmation text should appear
await expect(dialog.locator('.change-confirm-info')).toBeVisible();
await expect(dialog.locator('.change-confirm-info')).toContainText(/transférer/i);
// Click "Confirmer le transfert"
await dialog.getByRole('button', { name: /confirmer le transfert/i }).click();
// Success message should appear
await expect(page.locator('.alert-success')).toBeVisible({ timeout: 10000 });
await expect(page.locator('.alert-success')).toContainText(/transféré/i);
// Modal should close
await expect(dialog).not.toBeVisible();
});
});
// ============================================================================
// AC6: Filter by class
// ============================================================================
test.describe('AC6 - Class Filter', () => {
test('class filter dropdown exists with optgroups', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/students`);
await waitForStudentsPage(page);
const filterSelect = page.locator('#filter-class');
await expect(filterSelect).toBeVisible();
// Should have optgroups
const optgroups = filterSelect.locator('optgroup');
const count = await optgroups.count();
expect(count).toBeGreaterThanOrEqual(1);
});
test('[P2] selecting a class filters the student list', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/students`);
await waitForStudentsPage(page);
// Select a class in the filter
const filterSelect = page.locator('#filter-class');
await filterSelect.selectOption({ index: 1 });
// Wait for the list to reload
await page.waitForLoadState('networkidle');
// URL should contain classId parameter
await expect(page).toHaveURL(/classId=/);
// The page should still show the table or empty state (not an error)
await expect(
page.locator('.students-table, .empty-state')
).toBeVisible({ timeout: 10000 });
// Reset filter
await filterSelect.selectOption({ value: '' });
await page.waitForLoadState('networkidle');
// classId should be removed from URL (polling assertion for reliability)
await expect(page).not.toHaveURL(/classId=/, { timeout: 10000 });
});
});
});