|
|
|
@@ -0,0 +1,527 @@
|
|
|
|
|
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: 30000 }),
|
|
|
|
|
page.getByRole('button', { name: /se connecter/i }).click()
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 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 });
|
|
|
|
|
|
|
|
|
|
// If multiple children, buttons should be visible
|
|
|
|
|
const childButtons = page.locator('.child-button');
|
|
|
|
|
const count = await childButtons.count();
|
|
|
|
|
|
|
|
|
|
if (count > 1) {
|
|
|
|
|
// Click second child
|
|
|
|
|
await childButtons.nth(1).click();
|
|
|
|
|
await page.waitForTimeout(500);
|
|
|
|
|
|
|
|
|
|
// Should update schedule data
|
|
|
|
|
await navigateToSeededDay(page);
|
|
|
|
|
const slots = page.locator('[data-testid="schedule-slot"]');
|
|
|
|
|
await expect(slots.first()).toBeVisible({ timeout: 20000 });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ======================================================================
|
|
|
|
|
// 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 });
|
|
|
|
|
|
|
|
|
|
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 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 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');
|
|
|
|
|
|
|
|
|
|
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 });
|
|
|
|
|
|
|
|
|
|
// 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: 5000 });
|
|
|
|
|
|
|
|
|
|
// 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();
|
|
|
|
|
test.skip(count < 2, 'Need at least 2 children for this test');
|
|
|
|
|
|
|
|
|
|
// Navigate to seeded day to see slots
|
|
|
|
|
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 (5B) — should show SVT
|
|
|
|
|
await childButtons.nth(1).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 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 });
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|