Les enseignants ont besoin de moyennes à jour immédiatement après la publication ou modification des notes, sans attendre un batch nocturne. Le système recalcule via Domain Events synchrones : statistiques d'évaluation (min/max/moyenne/médiane), moyennes matières pondérées (normalisation /20), et moyenne générale par élève. Les résultats sont stockés dans des tables dénormalisées avec cache Redis (TTL 5 min). Trois endpoints API exposent les données avec contrôle d'accès par rôle. Une commande console permet le backfill des données historiques au déploiement.
727 lines
28 KiB
TypeScript
727 lines
28 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 ADMIN_EMAIL = 'e2e-schedule-admin@example.com';
|
||
const ADMIN_PASSWORD = 'ScheduleTest123';
|
||
const TEACHER_EMAIL = 'e2e-schedule-teacher@example.com';
|
||
const TEACHER_PASSWORD = 'ScheduleTeacher123';
|
||
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}"; ` +
|
||
`$dns="6ba7b810-9dad-11d1-80b4-00c04fd430c8"; ` +
|
||
`$ay="6ba7b814-9dad-11d1-80b4-00c04fd430c8"; ` +
|
||
`echo Ramsey\\Uuid\\Uuid::uuid5($dns,"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($ay,"$t:$s-$e")->toString();` +
|
||
`' 2>&1`,
|
||
{ encoding: 'utf-8' }
|
||
).trim();
|
||
const [schoolId, academicYearId] = output.split('\n');
|
||
return { schoolId: schoolId!, academicYearId: academicYearId! };
|
||
}
|
||
|
||
function cleanupScheduleData() {
|
||
try {
|
||
runSql(`DELETE FROM schedule_slots WHERE tenant_id = '${TENANT_ID}'`);
|
||
} catch {
|
||
// Table may not exist yet
|
||
}
|
||
}
|
||
|
||
function seedTeacherAssignments() {
|
||
const { academicYearId } = resolveDeterministicIds();
|
||
try {
|
||
// Assign test teacher to ALL classes × ALL subjects so any dropdown combo is valid
|
||
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
|
||
}
|
||
}
|
||
|
||
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: 60000 }),
|
||
page.getByRole('button', { name: /se connecter/i }).click()
|
||
]);
|
||
}
|
||
|
||
async function waitForScheduleReady(page: import('@playwright/test').Page) {
|
||
await expect(page.getByRole('heading', { name: /emploi du temps/i })).toBeVisible({
|
||
timeout: 15000
|
||
});
|
||
// Wait for either the grid or the empty state to appear
|
||
await expect(page.locator('.schedule-grid, .empty-state')).toBeVisible({
|
||
timeout: 15000
|
||
});
|
||
}
|
||
|
||
async function fillSlotForm(
|
||
dialog: import('@playwright/test').Locator,
|
||
options: {
|
||
className?: string;
|
||
dayValue?: string;
|
||
startTime?: string;
|
||
endTime?: string;
|
||
room?: string;
|
||
} = {}
|
||
) {
|
||
const { className, dayValue = '1', startTime = '09:00', endTime = '10:00', room } = options;
|
||
|
||
if (className) {
|
||
await dialog.locator('#slot-class').selectOption({ label: className });
|
||
}
|
||
// Wait for assignments to load, then select subject first (filters teachers)
|
||
const subjectOptions = dialog.locator('#slot-subject option:not([value=""])');
|
||
await expect(subjectOptions.first()).toBeAttached({ timeout: 10000 });
|
||
await dialog.locator('#slot-subject').selectOption({ index: 1 });
|
||
// After subject selection, wait for teacher dropdown to be filtered
|
||
const teacherOptions = dialog.locator('#slot-teacher option:not([value=""])');
|
||
await expect(teacherOptions).toHaveCount(1, { timeout: 10000 });
|
||
await dialog.locator('#slot-teacher').selectOption({ index: 1 });
|
||
await dialog.locator('#slot-day').selectOption(dayValue);
|
||
await dialog.locator('#slot-start').fill(startTime);
|
||
await dialog.locator('#slot-end').fill(endTime);
|
||
if (room) {
|
||
await dialog.locator('#slot-room').fill(room);
|
||
}
|
||
}
|
||
|
||
test.describe('Schedule Management - Navigation & Grid & Creation (Story 4.1)', () => {
|
||
// Tests share database state (same tenant, users, assignments) so they must run sequentially
|
||
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 teacher user
|
||
execSync(
|
||
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${TEACHER_EMAIL} --password=${TEACHER_PASSWORD} --role=ROLE_PROF 2>&1`,
|
||
{ encoding: 'utf-8' }
|
||
);
|
||
|
||
const { schoolId, academicYearId } = resolveDeterministicIds();
|
||
|
||
// Clean up stale test data (e.g. from previous runs with wrong school_id)
|
||
try {
|
||
runSql(`DELETE FROM schedule_slots WHERE class_id IN (SELECT id FROM school_classes WHERE name IN ('E2E-Schedule-6A','E2E-Schedule-5A') AND tenant_id = '${TENANT_ID}')`);
|
||
runSql(`DELETE FROM teacher_assignments WHERE school_class_id IN (SELECT id FROM school_classes WHERE name IN ('E2E-Schedule-6A','E2E-Schedule-5A') AND tenant_id = '${TENANT_ID}')`);
|
||
runSql(`DELETE FROM school_classes WHERE name IN ('E2E-Schedule-6A','E2E-Schedule-5A') AND tenant_id = '${TENANT_ID}'`);
|
||
runSql(`DELETE FROM subjects WHERE code IN ('E2SMATH','E2SFRA') AND tenant_id = '${TENANT_ID}'`);
|
||
} catch {
|
||
// Tables may not exist
|
||
}
|
||
|
||
// Create test classes
|
||
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-Schedule-6A', '6ème', 'active', NOW(), NOW())`
|
||
);
|
||
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-Schedule-5A', '5ème', 'active', NOW(), NOW())`
|
||
);
|
||
|
||
// Create test subjects
|
||
runSql(
|
||
`INSERT INTO subjects (id, tenant_id, school_id, name, code, color, status, created_at, updated_at) VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', 'E2E-Schedule-Maths', 'E2SMATH', '#3b82f6', 'active', NOW(), NOW())`
|
||
);
|
||
runSql(
|
||
`INSERT INTO subjects (id, tenant_id, school_id, name, code, color, status, created_at, updated_at) VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', 'E2E-Schedule-Français', 'E2SFRA', '#ef4444', 'active', NOW(), NOW())`
|
||
);
|
||
|
||
cleanupScheduleData();
|
||
clearCache();
|
||
});
|
||
|
||
test.beforeEach(async () => {
|
||
cleanupScheduleData();
|
||
try {
|
||
runSql(`DELETE FROM teacher_assignments WHERE tenant_id = '${TENANT_ID}'`);
|
||
} catch {
|
||
// Table may not exist
|
||
}
|
||
seedTeacherAssignments();
|
||
clearCache();
|
||
});
|
||
|
||
// ==========================================================================
|
||
// Navigation
|
||
// ==========================================================================
|
||
test.describe('Navigation', () => {
|
||
test('schedule link appears in admin navigation under Organisation', async ({ page }) => {
|
||
await loginAsAdmin(page);
|
||
await page.goto(`${ALPHA_URL}/admin`);
|
||
|
||
const nav = page.locator('.desktop-nav');
|
||
await nav.getByRole('button', { name: /organisation/i }).hover();
|
||
const navLink = nav.getByRole('menuitem', { name: /emploi du temps/i });
|
||
await expect(navLink).toBeVisible({ timeout: 15000 });
|
||
});
|
||
|
||
test('can navigate to schedule page', async ({ page }) => {
|
||
await loginAsAdmin(page);
|
||
await page.goto(`${ALPHA_URL}/admin/schedule`);
|
||
|
||
await expect(
|
||
page.getByRole('heading', { name: /emploi du temps/i })
|
||
).toBeVisible({ timeout: 15000 });
|
||
});
|
||
});
|
||
|
||
// ==========================================================================
|
||
// AC1: Schedule Grid
|
||
// ==========================================================================
|
||
test.describe('AC1: Schedule Grid', () => {
|
||
test('displays weekly grid with day columns', async ({ page }) => {
|
||
await loginAsAdmin(page);
|
||
await page.goto(`${ALPHA_URL}/admin/schedule`);
|
||
|
||
await waitForScheduleReady(page);
|
||
|
||
// Check day headers are present
|
||
await expect(page.getByText('Lundi')).toBeVisible();
|
||
await expect(page.getByText('Mardi')).toBeVisible();
|
||
await expect(page.getByText('Mercredi')).toBeVisible();
|
||
await expect(page.getByText('Jeudi')).toBeVisible();
|
||
await expect(page.getByText('Vendredi')).toBeVisible();
|
||
});
|
||
|
||
test('has class filter dropdown', async ({ page }) => {
|
||
await loginAsAdmin(page);
|
||
await page.goto(`${ALPHA_URL}/admin/schedule`);
|
||
|
||
await waitForScheduleReady(page);
|
||
|
||
const classFilter = page.locator('#filter-class');
|
||
await expect(classFilter).toBeVisible();
|
||
// Should have at least the placeholder option + one class
|
||
const options = classFilter.locator('option');
|
||
await expect(options).not.toHaveCount(1, { timeout: 10000 });
|
||
});
|
||
|
||
test('has teacher filter dropdown', async ({ page }) => {
|
||
await loginAsAdmin(page);
|
||
await page.goto(`${ALPHA_URL}/admin/schedule`);
|
||
|
||
await waitForScheduleReady(page);
|
||
|
||
const teacherFilter = page.locator('#filter-teacher');
|
||
await expect(teacherFilter).toBeVisible();
|
||
});
|
||
});
|
||
|
||
// ==========================================================================
|
||
// AC2: Slot Creation
|
||
// ==========================================================================
|
||
test.describe('AC2: Slot Creation', () => {
|
||
test('clicking on a time cell opens creation modal', async ({ page }) => {
|
||
await loginAsAdmin(page);
|
||
await page.goto(`${ALPHA_URL}/admin/schedule`);
|
||
|
||
await waitForScheduleReady(page);
|
||
|
||
// Click on a time cell in the grid
|
||
const timeCell = page.locator('.time-cell').first();
|
||
await timeCell.click();
|
||
|
||
// Modal should appear
|
||
const dialog = page.getByRole('dialog');
|
||
await expect(dialog).toBeVisible({ timeout: 10000 });
|
||
await expect(
|
||
dialog.getByRole('heading', { name: /nouveau créneau/i })
|
||
).toBeVisible();
|
||
});
|
||
|
||
test('creation form has required fields', async ({ page }) => {
|
||
await loginAsAdmin(page);
|
||
await page.goto(`${ALPHA_URL}/admin/schedule`);
|
||
|
||
await waitForScheduleReady(page);
|
||
|
||
// Open creation modal
|
||
const timeCell = page.locator('.time-cell').first();
|
||
await timeCell.click();
|
||
|
||
const dialog = page.getByRole('dialog');
|
||
await expect(dialog).toBeVisible({ timeout: 10000 });
|
||
|
||
// Check required form fields
|
||
await expect(dialog.locator('#slot-subject')).toBeVisible();
|
||
await expect(dialog.locator('#slot-teacher')).toBeVisible();
|
||
await expect(dialog.locator('#slot-day')).toBeVisible();
|
||
await expect(dialog.locator('#slot-start')).toBeVisible();
|
||
await expect(dialog.locator('#slot-end')).toBeVisible();
|
||
await expect(dialog.locator('#slot-room')).toBeVisible();
|
||
|
||
// Submit button should be disabled when fields are empty
|
||
const submitButton = dialog.getByRole('button', { name: /créer/i });
|
||
await expect(submitButton).toBeDisabled();
|
||
});
|
||
|
||
test('can close creation modal with cancel button', async ({ page }) => {
|
||
await loginAsAdmin(page);
|
||
await page.goto(`${ALPHA_URL}/admin/schedule`);
|
||
|
||
await waitForScheduleReady(page);
|
||
|
||
const timeCell = page.locator('.time-cell').first();
|
||
await timeCell.click();
|
||
|
||
const dialog = page.getByRole('dialog');
|
||
await expect(dialog).toBeVisible({ timeout: 10000 });
|
||
|
||
// Click cancel
|
||
await dialog.getByRole('button', { name: /annuler/i }).click();
|
||
await expect(dialog).not.toBeVisible({ timeout: 5000 });
|
||
});
|
||
|
||
test('can close creation modal with Escape key', async ({ page }) => {
|
||
await loginAsAdmin(page);
|
||
await page.goto(`${ALPHA_URL}/admin/schedule`);
|
||
|
||
await waitForScheduleReady(page);
|
||
|
||
const timeCell = page.locator('.time-cell').first();
|
||
await timeCell.click();
|
||
|
||
const dialog = page.getByRole('dialog');
|
||
await expect(dialog).toBeVisible({ timeout: 10000 });
|
||
|
||
// Press Escape
|
||
await page.keyboard.press('Escape');
|
||
await expect(dialog).not.toBeVisible({ timeout: 5000 });
|
||
});
|
||
|
||
test('can create a slot and see it in the grid', async ({ page }) => {
|
||
await loginAsAdmin(page);
|
||
await page.goto(`${ALPHA_URL}/admin/schedule`);
|
||
|
||
await waitForScheduleReady(page);
|
||
|
||
// Open creation modal
|
||
const timeCell = page.locator('.time-cell').first();
|
||
await timeCell.click();
|
||
|
||
const dialog = page.getByRole('dialog');
|
||
await expect(dialog).toBeVisible({ timeout: 10000 });
|
||
|
||
await fillSlotForm(dialog, { room: 'A101' });
|
||
|
||
// Submit
|
||
const submitButton = dialog.getByRole('button', { name: /créer/i });
|
||
await expect(submitButton).toBeEnabled();
|
||
await submitButton.click();
|
||
|
||
// Modal should close
|
||
await expect(dialog).not.toBeVisible({ timeout: 10000 });
|
||
|
||
// Slot card should appear in the grid
|
||
await expect(page.locator('.slot-card')).toBeVisible({ timeout: 10000 });
|
||
|
||
// Should show room on the slot card
|
||
await expect(page.locator('.slot-card').getByText('A101')).toBeVisible();
|
||
});
|
||
|
||
test('subject field appears before teacher field in creation form', async ({ page }) => {
|
||
await loginAsAdmin(page);
|
||
await page.goto(`${ALPHA_URL}/admin/schedule`);
|
||
await waitForScheduleReady(page);
|
||
|
||
const timeCell = page.locator('.time-cell').first();
|
||
await timeCell.click();
|
||
|
||
const dialog = page.getByRole('dialog');
|
||
await expect(dialog).toBeVisible({ timeout: 10000 });
|
||
|
||
// Subject should appear before teacher in DOM order
|
||
const formGroups = dialog.locator('.form-group');
|
||
const labels = await formGroups.locator('label').allTextContents();
|
||
const subjectIndex = labels.findIndex((l) => l.includes('Matière'));
|
||
const teacherIndex = labels.findIndex((l) => l.includes('Enseignant'));
|
||
expect(subjectIndex).toBeLessThan(teacherIndex);
|
||
});
|
||
|
||
test('selecting a subject filters the teacher dropdown', async ({ page }) => {
|
||
const { academicYearId } = resolveDeterministicIds();
|
||
|
||
// Create a second teacher
|
||
execSync(
|
||
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=e2e-sched-teacher2@example.com --password=Teacher2Pass123 --role=ROLE_PROF 2>&1`,
|
||
{ encoding: 'utf-8' }
|
||
);
|
||
|
||
// Assign teacher1 to subject1, teacher2 to subject2 for class 6A
|
||
runSql(`DELETE FROM teacher_assignments WHERE tenant_id = '${TENANT_ID}'`);
|
||
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, school_classes c, (SELECT id FROM subjects WHERE code = 'E2SMATH' AND tenant_id = '${TENANT_ID}') s ` +
|
||
`WHERE u.email = '${TEACHER_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` +
|
||
`AND c.name = 'E2E-Schedule-6A' AND c.tenant_id = '${TENANT_ID}' ` +
|
||
`ON CONFLICT DO NOTHING`
|
||
);
|
||
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, school_classes c, (SELECT id FROM subjects WHERE code = 'E2SFRA' AND tenant_id = '${TENANT_ID}') s ` +
|
||
`WHERE u.email = 'e2e-sched-teacher2@example.com' AND u.tenant_id = '${TENANT_ID}' ` +
|
||
`AND c.name = 'E2E-Schedule-6A' AND c.tenant_id = '${TENANT_ID}' ` +
|
||
`ON CONFLICT DO NOTHING`
|
||
);
|
||
clearCache();
|
||
|
||
await loginAsAdmin(page);
|
||
await page.goto(`${ALPHA_URL}/admin/schedule`);
|
||
await waitForScheduleReady(page);
|
||
|
||
const timeCell = page.locator('.time-cell').first();
|
||
await timeCell.click();
|
||
|
||
const dialog = page.getByRole('dialog');
|
||
await expect(dialog).toBeVisible({ timeout: 10000 });
|
||
|
||
// Select class 6A — both subjects should be available
|
||
await dialog.locator('#slot-class').selectOption({ label: 'E2E-Schedule-6A' });
|
||
const subjectOptions = dialog.locator('#slot-subject option:not([value=""])');
|
||
await expect(subjectOptions).toHaveCount(2, { timeout: 15000 });
|
||
|
||
// Before selecting a subject, both teachers should be available
|
||
const teacherOptions = dialog.locator('#slot-teacher option:not([value=""])');
|
||
await expect(teacherOptions).toHaveCount(2, { timeout: 10000 });
|
||
|
||
// Select first subject (Français) — should filter to only teacher2
|
||
await dialog.locator('#slot-subject').selectOption({ index: 1 });
|
||
await expect(teacherOptions).toHaveCount(1, { timeout: 10000 });
|
||
});
|
||
|
||
test('filters subjects and teachers by class assignment', async ({ page }) => {
|
||
const { academicYearId } = resolveDeterministicIds();
|
||
|
||
// Clear all assignments, seed exactly one: teacher → class 6A → first subject
|
||
runSql(`DELETE FROM teacher_assignments WHERE tenant_id = '${TENANT_ID}'`);
|
||
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, school_classes c, (SELECT id FROM subjects WHERE tenant_id = '${TENANT_ID}' ORDER BY name LIMIT 1) s ` +
|
||
`WHERE u.email = '${TEACHER_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` +
|
||
`AND c.name = 'E2E-Schedule-6A' AND c.tenant_id = '${TENANT_ID}' ` +
|
||
`ON CONFLICT DO NOTHING`
|
||
);
|
||
clearCache();
|
||
|
||
await loginAsAdmin(page);
|
||
await page.goto(`${ALPHA_URL}/admin/schedule`);
|
||
await waitForScheduleReady(page);
|
||
|
||
// Open creation modal
|
||
const timeCell = page.locator('.time-cell').first();
|
||
await timeCell.click();
|
||
|
||
const dialog = page.getByRole('dialog');
|
||
await expect(dialog).toBeVisible({ timeout: 10000 });
|
||
|
||
// Select class E2E-Schedule-6A (triggers loadAssignments for this class)
|
||
await dialog.locator('#slot-class').selectOption({ label: 'E2E-Schedule-6A' });
|
||
|
||
// Subject dropdown should be filtered to only the assigned subject
|
||
// (auto-retry handles the async assignment loading)
|
||
const subjectOptions = dialog.locator('#slot-subject option:not([value=""])');
|
||
await expect(subjectOptions).toHaveCount(1, { timeout: 15000 });
|
||
|
||
// Teacher dropdown should only show the assigned teacher
|
||
const teacherOptions = dialog.locator('#slot-teacher option:not([value=""])');
|
||
await expect(teacherOptions).toHaveCount(1, { timeout: 10000 });
|
||
});
|
||
});
|
||
});
|
||
|
||
test.describe('Schedule Recurring - Week Navigation & Scope (Story 4.2)', () => {
|
||
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 teacher user
|
||
execSync(
|
||
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${TEACHER_EMAIL} --password=${TEACHER_PASSWORD} --role=ROLE_PROF 2>&1`,
|
||
{ encoding: 'utf-8' }
|
||
);
|
||
|
||
const { schoolId, academicYearId } = resolveDeterministicIds();
|
||
|
||
// Only insert if not already created by first describe block
|
||
try {
|
||
runSql(
|
||
`INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, status, created_at, updated_at) SELECT gen_random_uuid(), '${TENANT_ID}', '${schoolId}', '${academicYearId}', 'E2E-Schedule-6A', '6ème', 'active', NOW(), NOW() WHERE NOT EXISTS (SELECT 1 FROM school_classes WHERE name = 'E2E-Schedule-6A' AND tenant_id = '${TENANT_ID}')`
|
||
);
|
||
} catch {
|
||
// May already exist
|
||
}
|
||
|
||
try {
|
||
runSql(
|
||
`INSERT INTO subjects (id, tenant_id, school_id, name, code, color, status, created_at, updated_at) SELECT gen_random_uuid(), '${TENANT_ID}', '${schoolId}', 'E2E-Schedule-Maths', 'E2SMATH', '#3b82f6', 'active', NOW(), NOW() WHERE NOT EXISTS (SELECT 1 FROM subjects WHERE code = 'E2SMATH' AND tenant_id = '${TENANT_ID}')`
|
||
);
|
||
} catch {
|
||
// May already exist
|
||
}
|
||
|
||
cleanupScheduleData();
|
||
clearCache();
|
||
});
|
||
|
||
test.beforeEach(async () => {
|
||
cleanupScheduleData();
|
||
try {
|
||
runSql(`DELETE FROM schedule_exceptions WHERE tenant_id = '${TENANT_ID}'`);
|
||
} catch {
|
||
// Table may not exist
|
||
}
|
||
try {
|
||
runSql(`DELETE FROM teacher_assignments WHERE tenant_id = '${TENANT_ID}'`);
|
||
} catch {
|
||
// Table may not exist
|
||
}
|
||
seedTeacherAssignments();
|
||
clearCache();
|
||
});
|
||
|
||
// ==========================================================================
|
||
// AC2: Week Navigation
|
||
// ==========================================================================
|
||
test.describe('AC2: Week Navigation', () => {
|
||
test('displays week navigation controls', async ({ page }) => {
|
||
await loginAsAdmin(page);
|
||
await page.goto(`${ALPHA_URL}/admin/schedule`);
|
||
await waitForScheduleReady(page);
|
||
|
||
// Week navigation should be visible
|
||
const weekNav = page.locator('.week-nav');
|
||
await expect(weekNav).toBeVisible();
|
||
|
||
// Previous/next buttons
|
||
await expect(weekNav.getByLabel('Semaine précédente')).toBeVisible();
|
||
await expect(weekNav.getByLabel('Semaine suivante')).toBeVisible();
|
||
|
||
// Week label
|
||
await expect(weekNav.locator('.week-label')).toBeVisible();
|
||
});
|
||
|
||
test('can navigate to next and previous weeks', async ({ page }) => {
|
||
await loginAsAdmin(page);
|
||
await page.goto(`${ALPHA_URL}/admin/schedule`);
|
||
await waitForScheduleReady(page);
|
||
|
||
const weekLabel = page.locator('.week-label');
|
||
const initialLabel = await weekLabel.textContent();
|
||
|
||
// Navigate to next week
|
||
await page.getByLabel('Semaine suivante').click();
|
||
await expect(weekLabel).not.toHaveText(initialLabel!, { timeout: 5000 });
|
||
const nextLabel = await weekLabel.textContent();
|
||
|
||
// Navigate back
|
||
await page.getByLabel('Semaine précédente').click();
|
||
await expect(weekLabel).toHaveText(initialLabel!, { timeout: 5000 });
|
||
|
||
// Navigate to next again
|
||
await page.getByLabel('Semaine suivante').click();
|
||
await expect(weekLabel).toHaveText(nextLabel!, { timeout: 5000 });
|
||
});
|
||
|
||
test('today button appears when not on current week', async ({ page }) => {
|
||
await loginAsAdmin(page);
|
||
await page.goto(`${ALPHA_URL}/admin/schedule`);
|
||
await waitForScheduleReady(page);
|
||
|
||
// "Aujourd'hui" button should not be visible on current week
|
||
await expect(page.locator('.week-nav-today')).not.toBeVisible();
|
||
|
||
// Navigate away
|
||
await page.getByLabel('Semaine suivante').click();
|
||
|
||
// "Aujourd'hui" button should appear
|
||
await expect(page.locator('.week-nav-today')).toBeVisible({ timeout: 5000 });
|
||
|
||
// Click it to go back
|
||
await page.locator('.week-nav-today').click();
|
||
|
||
// Should disappear again
|
||
await expect(page.locator('.week-nav-today')).not.toBeVisible({ timeout: 5000 });
|
||
});
|
||
});
|
||
|
||
// ==========================================================================
|
||
// AC1/AC2: Recurring indicator
|
||
// ==========================================================================
|
||
test.describe('AC1: Recurring Indicator', () => {
|
||
test('recurring slots show recurring badge', async ({ page }) => {
|
||
await loginAsAdmin(page);
|
||
await page.goto(`${ALPHA_URL}/admin/schedule`);
|
||
await waitForScheduleReady(page);
|
||
|
||
// Create a slot first
|
||
const timeCell = page.locator('.time-cell').first();
|
||
await timeCell.click();
|
||
|
||
const dialog = page.getByRole('dialog');
|
||
await expect(dialog).toBeVisible({ timeout: 10000 });
|
||
|
||
await fillSlotForm(dialog, { room: 'B201' });
|
||
await dialog.getByRole('button', { name: /créer/i }).click();
|
||
await expect(dialog).not.toBeVisible({ timeout: 10000 });
|
||
|
||
// Slot card should have the recurring badge
|
||
const slotCard = page.locator('.slot-card');
|
||
await expect(slotCard).toBeVisible({ timeout: 10000 });
|
||
await expect(slotCard.locator('.slot-badge-recurring')).toBeVisible();
|
||
});
|
||
});
|
||
|
||
// ==========================================================================
|
||
// AC3: Scope Choice Modal
|
||
// ==========================================================================
|
||
test.describe('AC3: Scope Choice Modal', () => {
|
||
test('clicking a slot opens scope choice modal', async ({ page }) => {
|
||
await loginAsAdmin(page);
|
||
await page.goto(`${ALPHA_URL}/admin/schedule`);
|
||
await waitForScheduleReady(page);
|
||
|
||
// Create a slot
|
||
const timeCell = page.locator('.time-cell').first();
|
||
await timeCell.click();
|
||
const createDialog = page.getByRole('dialog');
|
||
await expect(createDialog).toBeVisible({ timeout: 10000 });
|
||
|
||
await fillSlotForm(createDialog, { room: 'C301' });
|
||
await createDialog.getByRole('button', { name: /créer/i }).click();
|
||
await expect(createDialog).not.toBeVisible({ timeout: 10000 });
|
||
|
||
// Click on the slot card
|
||
const slotCard = page.locator('.slot-card');
|
||
await expect(slotCard).toBeVisible({ timeout: 10000 });
|
||
await slotCard.click();
|
||
|
||
// Scope modal should appear
|
||
const scopeModal = page.locator('.modal-scope');
|
||
await expect(scopeModal).toBeVisible({ timeout: 10000 });
|
||
await expect(scopeModal.getByText('Cette occurrence uniquement')).toBeVisible();
|
||
await expect(scopeModal.getByText('Toutes les occurrences futures')).toBeVisible();
|
||
});
|
||
|
||
test('scope modal can be closed with Escape', async ({ page }) => {
|
||
await loginAsAdmin(page);
|
||
await page.goto(`${ALPHA_URL}/admin/schedule`);
|
||
await waitForScheduleReady(page);
|
||
|
||
// Create a slot
|
||
const timeCell = page.locator('.time-cell').first();
|
||
await timeCell.click();
|
||
const createDialog = page.getByRole('dialog');
|
||
await expect(createDialog).toBeVisible({ timeout: 10000 });
|
||
|
||
await fillSlotForm(createDialog);
|
||
await createDialog.getByRole('button', { name: /créer/i }).click();
|
||
await expect(createDialog).not.toBeVisible({ timeout: 10000 });
|
||
|
||
// Click on the slot card
|
||
const slotCard = page.locator('.slot-card');
|
||
await expect(slotCard).toBeVisible({ timeout: 10000 });
|
||
await slotCard.click();
|
||
|
||
// Scope modal appears
|
||
const scopeModal = page.locator('.modal-scope');
|
||
await expect(scopeModal).toBeVisible({ timeout: 10000 });
|
||
|
||
// Close with Escape
|
||
await page.keyboard.press('Escape');
|
||
await expect(scopeModal).not.toBeVisible({ timeout: 5000 });
|
||
});
|
||
|
||
test('choosing "this occurrence" opens edit form', async ({ page }) => {
|
||
await loginAsAdmin(page);
|
||
await page.goto(`${ALPHA_URL}/admin/schedule`);
|
||
await waitForScheduleReady(page);
|
||
|
||
// Create a slot
|
||
const timeCell = page.locator('.time-cell').first();
|
||
await timeCell.click();
|
||
const createDialog = page.getByRole('dialog');
|
||
await expect(createDialog).toBeVisible({ timeout: 10000 });
|
||
|
||
await fillSlotForm(createDialog, { room: 'D401' });
|
||
await createDialog.getByRole('button', { name: /créer/i }).click();
|
||
await expect(createDialog).not.toBeVisible({ timeout: 10000 });
|
||
|
||
// Click on the slot card
|
||
const slotCard = page.locator('.slot-card');
|
||
await expect(slotCard).toBeVisible({ timeout: 10000 });
|
||
await slotCard.click();
|
||
|
||
// Choose "this occurrence"
|
||
const scopeModal = page.locator('.modal-scope');
|
||
await expect(scopeModal).toBeVisible({ timeout: 10000 });
|
||
await scopeModal.getByText('Cette occurrence uniquement').click();
|
||
|
||
// Edit form should appear
|
||
const editDialog = page.getByRole('dialog');
|
||
await expect(editDialog).toBeVisible({ timeout: 10000 });
|
||
await expect(
|
||
editDialog.getByRole('heading', { name: /modifier le créneau/i })
|
||
).toBeVisible();
|
||
});
|
||
});
|
||
});
|