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.
565 lines
22 KiB
TypeScript
565 lines
22 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 PARENT_EMAIL = 'e2e-parent-schedule@example.com';
|
|
const PARENT_PASSWORD = 'ParentSchedule123';
|
|
const STUDENT_EMAIL = 'e2e-parent-sched-student@example.com';
|
|
const STUDENT_PASSWORD = 'StudentParentSched123';
|
|
const STUDENT2_EMAIL = 'e2e-parent-sched-student2@example.com';
|
|
const STUDENT2_PASSWORD = 'StudentParentSched2_123';
|
|
const TEACHER_EMAIL = 'e2e-parent-sched-teacher@example.com';
|
|
const TEACHER_PASSWORD = 'TeacherParentSched123';
|
|
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! };
|
|
}
|
|
|
|
function currentWeekdayIso(): number {
|
|
const jsDay = new Date().getDay();
|
|
if (jsDay === 0) return 5; // Sunday → seed for Friday
|
|
if (jsDay === 6) return 5; // Saturday → seed for Friday
|
|
return jsDay;
|
|
}
|
|
|
|
function daysBackToSeededWeekday(): number {
|
|
const jsDay = new Date().getDay();
|
|
if (jsDay === 6) return 1; // Saturday → go back 1 day to Friday
|
|
if (jsDay === 0) return 2; // Sunday → go back 2 days to Friday
|
|
return 0;
|
|
}
|
|
|
|
function seededDayName(): string {
|
|
const jsDay = new Date().getDay();
|
|
const target = jsDay === 6 ? 5 : jsDay === 0 ? 5 : jsDay;
|
|
return ['dimanche', 'lundi', 'mardi', 'mercredi', 'jeudi', 'vendredi', 'samedi'][target]!;
|
|
}
|
|
|
|
async function navigateToSeededDay(page: import('@playwright/test').Page) {
|
|
const back = daysBackToSeededWeekday();
|
|
if (back === 0) return;
|
|
|
|
const targetDay = seededDayName();
|
|
const targetPattern = new RegExp(targetDay, 'i');
|
|
const prevBtn = page.getByLabel('Précédent');
|
|
await expect(prevBtn).toBeVisible({ timeout: 10000 });
|
|
|
|
const deadline = Date.now() + 15000;
|
|
let navigated = false;
|
|
while (Date.now() < deadline && !navigated) {
|
|
for (let i = 0; i < back; i++) {
|
|
await prevBtn.click();
|
|
}
|
|
await page.waitForTimeout(500);
|
|
const title = await page.locator('.day-title').textContent();
|
|
if (title && targetPattern.test(title)) {
|
|
navigated = true;
|
|
}
|
|
}
|
|
|
|
await expect(page.locator('.day-title').getByText(targetPattern)).toBeVisible({
|
|
timeout: 5000
|
|
});
|
|
}
|
|
|
|
async function loginAsParent(page: import('@playwright/test').Page) {
|
|
await page.goto(`${ALPHA_URL}/login`);
|
|
await page.locator('#email').fill(PARENT_EMAIL);
|
|
await page.locator('#password').fill(PARENT_PASSWORD);
|
|
await Promise.all([
|
|
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
|
|
page.getByRole('button', { name: /se connecter/i }).click()
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Multi-child parents land on the summary view (no child auto-selected).
|
|
* This helper selects the first actual child (skipping the "Tous" button).
|
|
*/
|
|
async function selectFirstChild(page: import('@playwright/test').Page) {
|
|
const childButtons = page.locator('.child-button');
|
|
await expect(childButtons.first()).toBeVisible({ timeout: 15000 });
|
|
const count = await childButtons.count();
|
|
if (count > 2) {
|
|
// Multi-child: "Tous" is at index 0, first child at index 1
|
|
await childButtons.nth(1).click();
|
|
}
|
|
// Single child is auto-selected, nothing to do
|
|
}
|
|
|
|
test.describe('Parent Schedule Consultation (Story 4.4)', () => {
|
|
test.describe.configure({ mode: 'serial' });
|
|
|
|
test.beforeAll(async () => {
|
|
// Clear caches to prevent stale data from previous runs
|
|
try {
|
|
execSync(
|
|
`docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear cache.rate_limiter users.cache student_guardians.cache --env=dev 2>&1`,
|
|
{ encoding: 'utf-8' }
|
|
);
|
|
} catch {
|
|
// Cache pools may not exist
|
|
}
|
|
|
|
// Create users (idempotent - returns existing if already created)
|
|
execSync(
|
|
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${PARENT_EMAIL} --password=${PARENT_PASSWORD} --role=ROLE_PARENT 2>&1`,
|
|
{ encoding: 'utf-8' }
|
|
);
|
|
execSync(
|
|
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${STUDENT_EMAIL} --password=${STUDENT_PASSWORD} --role=ROLE_ELEVE 2>&1`,
|
|
{ encoding: 'utf-8' }
|
|
);
|
|
execSync(
|
|
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${STUDENT2_EMAIL} --password=${STUDENT2_PASSWORD} --role=ROLE_ELEVE 2>&1`,
|
|
{ encoding: 'utf-8' }
|
|
);
|
|
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();
|
|
|
|
// Create classes
|
|
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-ParentSched-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-ParentSched-5B', '5ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
|
|
);
|
|
} catch {
|
|
// May already exist
|
|
}
|
|
|
|
// Create subjects
|
|
try {
|
|
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-ParentSched-Maths', 'E2EPARMATH', '#3b82f6', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
|
|
);
|
|
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-ParentSched-SVT', 'E2EPARSVT', '#22c55e', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
|
|
);
|
|
} catch {
|
|
// May already exist
|
|
}
|
|
|
|
// Clean up schedule data
|
|
try {
|
|
runSql(`DELETE FROM schedule_slots WHERE tenant_id = '${TENANT_ID}' AND class_id IN (SELECT id FROM school_classes WHERE name LIKE 'E2E-ParentSched-%' AND tenant_id = '${TENANT_ID}')`);
|
|
} catch {
|
|
// Table may not exist
|
|
}
|
|
|
|
// Clean up calendar entries
|
|
try {
|
|
runSql(`DELETE FROM school_calendar_entries WHERE tenant_id = '${TENANT_ID}'`);
|
|
} catch {
|
|
// Table may not exist
|
|
}
|
|
|
|
// Assign students to classes
|
|
runSql(
|
|
`INSERT INTO class_assignments (id, tenant_id, user_id, school_class_id, academic_year_id, assigned_at, created_at, updated_at) ` +
|
|
`SELECT gen_random_uuid(), '${TENANT_ID}', u.id, c.id, '${academicYearId}', NOW(), NOW(), NOW() ` +
|
|
`FROM users u, school_classes c ` +
|
|
`WHERE u.email = '${STUDENT_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` +
|
|
`AND c.name = 'E2E-ParentSched-6A' AND c.tenant_id = '${TENANT_ID}' ` +
|
|
`ON CONFLICT DO NOTHING`
|
|
);
|
|
runSql(
|
|
`INSERT INTO class_assignments (id, tenant_id, user_id, school_class_id, academic_year_id, assigned_at, created_at, updated_at) ` +
|
|
`SELECT gen_random_uuid(), '${TENANT_ID}', u.id, c.id, '${academicYearId}', NOW(), NOW(), NOW() ` +
|
|
`FROM users u, school_classes c ` +
|
|
`WHERE u.email = '${STUDENT2_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` +
|
|
`AND c.name = 'E2E-ParentSched-5B' AND c.tenant_id = '${TENANT_ID}' ` +
|
|
`ON CONFLICT DO NOTHING`
|
|
);
|
|
|
|
// Clean up any existing guardian links for our parent
|
|
try {
|
|
runSql(
|
|
`DELETE FROM student_guardians WHERE guardian_id IN (SELECT id FROM users WHERE email = '${PARENT_EMAIL}' AND tenant_id = '${TENANT_ID}') AND tenant_id = '${TENANT_ID}'`
|
|
);
|
|
} catch {
|
|
// Table may not exist
|
|
}
|
|
|
|
// Create parent-student links
|
|
runSql(
|
|
`INSERT INTO student_guardians (id, student_id, guardian_id, relationship_type, tenant_id, created_at) ` +
|
|
`SELECT gen_random_uuid(), s.id, p.id, 'père', '${TENANT_ID}', NOW() ` +
|
|
`FROM users s, users p ` +
|
|
`WHERE s.email = '${STUDENT_EMAIL}' AND s.tenant_id = '${TENANT_ID}' ` +
|
|
`AND p.email = '${PARENT_EMAIL}' AND p.tenant_id = '${TENANT_ID}' ` +
|
|
`ON CONFLICT (student_id, guardian_id, tenant_id) DO NOTHING`
|
|
);
|
|
runSql(
|
|
`INSERT INTO student_guardians (id, student_id, guardian_id, relationship_type, tenant_id, created_at) ` +
|
|
`SELECT gen_random_uuid(), s.id, p.id, 'père', '${TENANT_ID}', NOW() ` +
|
|
`FROM users s, users p ` +
|
|
`WHERE s.email = '${STUDENT2_EMAIL}' AND s.tenant_id = '${TENANT_ID}' ` +
|
|
`AND p.email = '${PARENT_EMAIL}' AND p.tenant_id = '${TENANT_ID}' ` +
|
|
`ON CONFLICT (student_id, guardian_id, tenant_id) DO NOTHING`
|
|
);
|
|
|
|
// Create schedule slots
|
|
const dayOfWeek = currentWeekdayIso();
|
|
runSql(
|
|
`INSERT INTO schedule_slots (id, tenant_id, class_id, subject_id, teacher_id, day_of_week, start_time, end_time, room, is_recurring, created_at, updated_at) ` +
|
|
`SELECT gen_random_uuid(), '${TENANT_ID}', c.id, s.id, t.id, ${dayOfWeek}, '08:00', '09:00', 'Salle A1', true, NOW(), NOW() ` +
|
|
`FROM school_classes c, ` +
|
|
`(SELECT id FROM subjects WHERE code = 'E2EPARMATH' AND tenant_id = '${TENANT_ID}' LIMIT 1) s, ` +
|
|
`(SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}') t ` +
|
|
`WHERE c.name = 'E2E-ParentSched-6A' AND c.tenant_id = '${TENANT_ID}'`
|
|
);
|
|
runSql(
|
|
`INSERT INTO schedule_slots (id, tenant_id, class_id, subject_id, teacher_id, day_of_week, start_time, end_time, room, is_recurring, created_at, updated_at) ` +
|
|
`SELECT gen_random_uuid(), '${TENANT_ID}', c.id, s.id, t.id, ${dayOfWeek}, '10:00', '11:00', 'Labo SVT', true, NOW(), NOW() ` +
|
|
`FROM school_classes c, ` +
|
|
`(SELECT id FROM subjects WHERE code = 'E2EPARSVT' AND tenant_id = '${TENANT_ID}' LIMIT 1) s, ` +
|
|
`(SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}') t ` +
|
|
`WHERE c.name = 'E2E-ParentSched-5B' AND c.tenant_id = '${TENANT_ID}'`
|
|
);
|
|
|
|
// Late-night slot for child 1 (6A) — always "next" during normal test hours
|
|
runSql(
|
|
`INSERT INTO schedule_slots (id, tenant_id, class_id, subject_id, teacher_id, day_of_week, start_time, end_time, room, is_recurring, created_at, updated_at) ` +
|
|
`SELECT gen_random_uuid(), '${TENANT_ID}', c.id, s.id, t.id, ${dayOfWeek}, '23:00', '23:30', 'Salle A1', true, NOW(), NOW() ` +
|
|
`FROM school_classes c, ` +
|
|
`(SELECT id FROM subjects WHERE code = 'E2EPARMATH' AND tenant_id = '${TENANT_ID}' LIMIT 1) s, ` +
|
|
`(SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}') t ` +
|
|
`WHERE c.name = 'E2E-ParentSched-6A' AND c.tenant_id = '${TENANT_ID}'`
|
|
);
|
|
|
|
clearCache();
|
|
});
|
|
|
|
// ======================================================================
|
|
// AC1: Single child view
|
|
// ======================================================================
|
|
test.describe('AC1: Single child day view', () => {
|
|
test('parent can navigate to parent-schedule page', async ({ page }) => {
|
|
await loginAsParent(page);
|
|
await page.goto(`${ALPHA_URL}/dashboard/parent-schedule`);
|
|
|
|
await expect(
|
|
page.getByRole('heading', { name: /emploi du temps des enfants/i })
|
|
).toBeVisible({ timeout: 15000 });
|
|
});
|
|
|
|
test('child selector shows children', async ({ page }) => {
|
|
await loginAsParent(page);
|
|
|
|
// Intercept the API call to debug
|
|
const responsePromise = page.waitForResponse(
|
|
(resp) => resp.url().includes('/me/children') && !resp.url().includes('schedule'),
|
|
{ timeout: 30000 }
|
|
);
|
|
|
|
await page.goto(`${ALPHA_URL}/dashboard/parent-schedule`);
|
|
|
|
await expect(
|
|
page.getByRole('heading', { name: /emploi du temps des enfants/i })
|
|
).toBeVisible({ timeout: 15000 });
|
|
|
|
const response = await responsePromise;
|
|
expect(response.status()).toBe(200);
|
|
|
|
// Wait for child selector to finish loading
|
|
const childSelector = page.locator('.child-selector');
|
|
await expect(childSelector).toBeVisible({ timeout: 15000 });
|
|
});
|
|
|
|
test('day view shows schedule for selected child', async ({ page }) => {
|
|
await loginAsParent(page);
|
|
await page.goto(`${ALPHA_URL}/dashboard/parent-schedule`);
|
|
await expect(
|
|
page.getByRole('heading', { name: /emploi du temps des enfants/i })
|
|
).toBeVisible({ timeout: 15000 });
|
|
await selectFirstChild(page);
|
|
await navigateToSeededDay(page);
|
|
|
|
// Wait for slots to load
|
|
const slots = page.locator('[data-testid="schedule-slot"]');
|
|
await expect(slots.first()).toBeVisible({ timeout: 20000 });
|
|
});
|
|
});
|
|
|
|
// ======================================================================
|
|
// AC2: Multi-child view
|
|
// ======================================================================
|
|
test.describe('AC2: Multi-child selection', () => {
|
|
test('can switch between children', async ({ page }) => {
|
|
await loginAsParent(page);
|
|
await page.goto(`${ALPHA_URL}/dashboard/parent-schedule`);
|
|
await expect(
|
|
page.getByRole('heading', { name: /emploi du temps des enfants/i })
|
|
).toBeVisible({ timeout: 15000 });
|
|
|
|
// Multi-child: "Tous" + N children buttons
|
|
const childButtons = page.locator('.child-button');
|
|
const count = await childButtons.count();
|
|
|
|
if (count > 2) {
|
|
// Select first child (index 1, after "Tous")
|
|
await childButtons.nth(1).click();
|
|
await navigateToSeededDay(page);
|
|
await expect(page.locator('[data-testid="schedule-slot"]').first()).toBeVisible({
|
|
timeout: 20000
|
|
});
|
|
|
|
// Switch to second child (index 2)
|
|
await childButtons.nth(2).click();
|
|
await page.waitForTimeout(500);
|
|
await navigateToSeededDay(page);
|
|
await expect(page.locator('[data-testid="schedule-slot"]').first()).toBeVisible({
|
|
timeout: 20000
|
|
});
|
|
|
|
// Switch back to "Tous" summary view
|
|
await childButtons.nth(0).click();
|
|
await expect(page.locator('.multi-child-summary')).toBeVisible({ timeout: 10000 });
|
|
}
|
|
});
|
|
});
|
|
|
|
// ======================================================================
|
|
// AC3: Navigation (day/week views)
|
|
// ======================================================================
|
|
test.describe('AC3: Navigation', () => {
|
|
test('day view is the default view', async ({ page }) => {
|
|
await loginAsParent(page);
|
|
await page.goto(`${ALPHA_URL}/dashboard/parent-schedule`);
|
|
await expect(
|
|
page.getByRole('heading', { name: /emploi du temps des enfants/i })
|
|
).toBeVisible({ timeout: 15000 });
|
|
await selectFirstChild(page);
|
|
|
|
const dayButton = page.locator('.view-toggle button', { hasText: 'Jour' });
|
|
await expect(dayButton).toHaveClass(/active/, { timeout: 5000 });
|
|
});
|
|
|
|
test('can switch to week view', async ({ page }) => {
|
|
await loginAsParent(page);
|
|
await page.goto(`${ALPHA_URL}/dashboard/parent-schedule`);
|
|
await expect(
|
|
page.getByRole('heading', { name: /emploi du temps des enfants/i })
|
|
).toBeVisible({ timeout: 15000 });
|
|
await selectFirstChild(page);
|
|
await navigateToSeededDay(page);
|
|
|
|
// Wait for slots to load
|
|
await expect(page.locator('[data-testid="schedule-slot"]').first()).toBeVisible({
|
|
timeout: 20000
|
|
});
|
|
|
|
// Switch to week view
|
|
const weekButton = page.locator('.view-toggle button', { hasText: 'Semaine' });
|
|
await weekButton.click();
|
|
|
|
// Week headers should show
|
|
await expect(page.getByText('Lun', { exact: true })).toBeVisible({ timeout: 15000 });
|
|
await expect(page.getByText('Ven', { exact: true })).toBeVisible();
|
|
});
|
|
|
|
test('can navigate between days', async ({ page }) => {
|
|
await loginAsParent(page);
|
|
await page.goto(`${ALPHA_URL}/dashboard/parent-schedule`);
|
|
await expect(
|
|
page.getByRole('heading', { name: /emploi du temps des enfants/i })
|
|
).toBeVisible({ timeout: 15000 });
|
|
await selectFirstChild(page);
|
|
await navigateToSeededDay(page);
|
|
|
|
await expect(page.locator('[data-testid="schedule-slot"]').first()).toBeVisible({
|
|
timeout: 20000
|
|
});
|
|
|
|
// Navigate forward and wait for the new day to load
|
|
await page.getByLabel('Suivant').click();
|
|
// Wait for the day title to change, confirming navigation completed
|
|
await page.waitForTimeout(1500);
|
|
|
|
// Navigate back to the original day
|
|
await page.getByLabel('Précédent').click();
|
|
|
|
// Wait for data to reload after navigation
|
|
await expect(page.locator('[data-testid="schedule-slot"]').first()).toBeVisible({
|
|
timeout: 30000
|
|
});
|
|
});
|
|
});
|
|
|
|
// ======================================================================
|
|
// AC1: Next class highlighting (P0)
|
|
// ======================================================================
|
|
test.describe('AC1: Next class highlighting', () => {
|
|
test('next class is highlighted with badge on today view', async ({ page }) => {
|
|
// Next class highlighting only works when viewing today's date
|
|
const jsDay = new Date().getDay();
|
|
test.skip(jsDay === 0 || jsDay === 6, 'Next class highlighting only works on weekdays');
|
|
// The seeded slot is at 23:00 — if the test runs after 22:30 the slot
|
|
// may be current/past and won't have the "next" class.
|
|
const hour = new Date().getHours();
|
|
test.skip(hour >= 23, 'Seeded 23:00 slot is past/current — cannot test next-class highlighting');
|
|
|
|
await loginAsParent(page);
|
|
await page.goto(`${ALPHA_URL}/dashboard/parent-schedule`);
|
|
await expect(
|
|
page.getByRole('heading', { name: /emploi du temps des enfants/i })
|
|
).toBeVisible({ timeout: 15000 });
|
|
await selectFirstChild(page);
|
|
|
|
// Wait for schedule slots to load
|
|
await expect(page.locator('[data-testid="schedule-slot"]').first()).toBeVisible({
|
|
timeout: 20000
|
|
});
|
|
|
|
// The 23:00 slot should always be "next" during normal test hours
|
|
const nextSlot = page.locator('.slot-item.next');
|
|
await expect(nextSlot).toBeVisible({ timeout: 10000 });
|
|
|
|
// Verify the "Prochain" badge is displayed
|
|
await expect(nextSlot.locator('.next-badge')).toBeVisible();
|
|
await expect(nextSlot.locator('.next-badge')).toHaveText('Prochain');
|
|
});
|
|
});
|
|
|
|
// ======================================================================
|
|
// AC2: Multi-child content verification (P1)
|
|
// ======================================================================
|
|
test.describe('AC2: Multi-child schedule content', () => {
|
|
test('switching children shows different subjects', async ({ page }) => {
|
|
await loginAsParent(page);
|
|
await page.goto(`${ALPHA_URL}/dashboard/parent-schedule`);
|
|
await expect(
|
|
page.getByRole('heading', { name: /emploi du temps des enfants/i })
|
|
).toBeVisible({ timeout: 15000 });
|
|
|
|
const childButtons = page.locator('.child-button');
|
|
const count = await childButtons.count();
|
|
// "Tous" + at least 2 children = 3 buttons minimum
|
|
test.skip(count < 3, 'Need at least 2 children for this test');
|
|
|
|
// Select first child (index 1, after "Tous")
|
|
await childButtons.nth(1).click();
|
|
await navigateToSeededDay(page);
|
|
|
|
// First child (6A) should show Maths
|
|
await expect(page.locator('[data-testid="schedule-slot"]').first()).toBeVisible({
|
|
timeout: 20000
|
|
});
|
|
await expect(page.getByText('E2E-ParentSched-Maths').first()).toBeVisible({
|
|
timeout: 5000
|
|
});
|
|
|
|
// Switch to second child (index 2) (5B) — should show SVT
|
|
await childButtons.nth(2).click();
|
|
await navigateToSeededDay(page);
|
|
await expect(page.locator('[data-testid="schedule-slot"]').first()).toBeVisible({
|
|
timeout: 20000
|
|
});
|
|
await expect(page.getByText('E2E-ParentSched-SVT').first()).toBeVisible({
|
|
timeout: 5000
|
|
});
|
|
});
|
|
});
|
|
|
|
// ======================================================================
|
|
// AC5: Offline mode
|
|
// ======================================================================
|
|
test.describe('AC5: Offline mode', () => {
|
|
test('shows offline banner when network is lost', async ({ page, context }) => {
|
|
await loginAsParent(page);
|
|
await page.goto(`${ALPHA_URL}/dashboard/parent-schedule`);
|
|
await selectFirstChild(page);
|
|
await navigateToSeededDay(page);
|
|
|
|
// Wait for schedule to load
|
|
await expect(
|
|
page.locator('[data-testid="schedule-slot"]').first()
|
|
).toBeVisible({ timeout: 20000 });
|
|
|
|
// Go offline
|
|
await context.setOffline(true);
|
|
|
|
const offlineBanner = page.locator('.offline-banner[role="status"]');
|
|
await expect(offlineBanner).toBeVisible({ timeout: 5000 });
|
|
await expect(offlineBanner.getByText('Hors ligne')).toBeVisible();
|
|
|
|
// Restore online
|
|
await context.setOffline(false);
|
|
await expect(offlineBanner).not.toBeVisible({ timeout: 5000 });
|
|
});
|
|
});
|
|
|
|
// ======================================================================
|
|
// Navigation link
|
|
// ======================================================================
|
|
test.describe('Navigation link', () => {
|
|
test('EDT enfants link is visible for parent role', async ({ page }) => {
|
|
await loginAsParent(page);
|
|
|
|
const navLink = page.locator('.desktop-nav a', { hasText: 'EDT enfants' });
|
|
await expect(navLink).toBeVisible({ timeout: 10000 });
|
|
});
|
|
|
|
test('clicking EDT enfants link navigates to parent-schedule page', async ({ page }) => {
|
|
await loginAsParent(page);
|
|
|
|
const navLink = page.locator('.desktop-nav a', { hasText: 'EDT enfants' });
|
|
await expect(navLink).toBeVisible({ timeout: 10000 });
|
|
await navLink.click();
|
|
|
|
await expect(page).toHaveURL(/\/dashboard\/parent-schedule/);
|
|
await expect(
|
|
page.getByRole('heading', { name: /emploi du temps des enfants/i })
|
|
).toBeVisible({ timeout: 15000 });
|
|
});
|
|
});
|
|
});
|