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.
769 lines
31 KiB
TypeScript
769 lines
31 KiB
TypeScript
import { test, expect, type Page } 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-parenthw-admin@example.com';
|
|
const ADMIN_PASSWORD = 'AdminParentHW123';
|
|
const PARENT_EMAIL = 'e2e-parenthw-parent@example.com';
|
|
const PARENT_PASSWORD = 'ParentHomework123';
|
|
const TEACHER_EMAIL = 'e2e-parenthw-teacher@example.com';
|
|
const TEACHER_PASSWORD = 'TeacherParentHW123';
|
|
const STUDENT1_EMAIL = 'e2e-parenthw-student1@example.com';
|
|
const STUDENT1_PASSWORD = 'Student1ParentHW123';
|
|
const STUDENT2_EMAIL = 'e2e-parenthw-student2@example.com';
|
|
const STUDENT2_PASSWORD = 'Student2ParentHW123';
|
|
const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
|
|
|
const projectRoot = join(__dirname, '../..');
|
|
const composeFile = join(projectRoot, 'compose.yaml');
|
|
|
|
let student1UserId: string;
|
|
let student2UserId: string;
|
|
|
|
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 extractUserId(output: string): string {
|
|
const match = output.match(/User ID\s+([a-f0-9-]{36})/i);
|
|
if (!match) {
|
|
throw new Error(`Could not extract User ID from command output:\n${output}`);
|
|
}
|
|
return match[1];
|
|
}
|
|
|
|
function getNextWeekday(daysFromNow: number): string {
|
|
const date = new Date();
|
|
date.setDate(date.getDate() + daysFromNow);
|
|
const day = date.getDay();
|
|
if (day === 0) date.setDate(date.getDate() + 1);
|
|
if (day === 6) date.setDate(date.getDate() + 2);
|
|
const y = date.getFullYear();
|
|
const m = String(date.getMonth() + 1).padStart(2, '0');
|
|
const d = String(date.getDate()).padStart(2, '0');
|
|
return `${y}-${m}-${d}`;
|
|
}
|
|
|
|
function getTomorrowWeekday(): string {
|
|
return getNextWeekday(1);
|
|
}
|
|
|
|
function getFormattedToday(): string {
|
|
const date = new Date();
|
|
const y = date.getFullYear();
|
|
const m = String(date.getMonth() + 1).padStart(2, '0');
|
|
const d = String(date.getDate()).padStart(2, '0');
|
|
return `${y}-${m}-${d}`;
|
|
}
|
|
|
|
function getPastDate(daysAgo: number): string {
|
|
const date = new Date();
|
|
date.setDate(date.getDate() - daysAgo);
|
|
const y = date.getFullYear();
|
|
const m = String(date.getMonth() + 1).padStart(2, '0');
|
|
const d = String(date.getDate()).padStart(2, '0');
|
|
return `${y}-${m}-${d}`;
|
|
}
|
|
|
|
async function loginAsAdmin(page: 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 loginAsParent(page: 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()
|
|
]);
|
|
}
|
|
|
|
async function addGuardianIfNotLinked(page: Page, studentId: string, parentSearchTerm: string, relationship: string) {
|
|
await page.goto(`${ALPHA_URL}/admin/students/${studentId}`);
|
|
await expect(page.locator('.guardian-section')).toBeVisible({ timeout: 10000 });
|
|
await expect(
|
|
page.getByText(/aucun parent\/tuteur/i).or(page.locator('.guardian-list'))
|
|
).toBeVisible({ timeout: 10000 });
|
|
|
|
const addButton = page.getByRole('button', { name: /ajouter un parent/i });
|
|
if (!(await addButton.isVisible())) return;
|
|
|
|
const sectionText = await page.locator('.guardian-section').textContent();
|
|
if (sectionText && sectionText.includes(parentSearchTerm)) return;
|
|
|
|
await addButton.click();
|
|
const dialog = page.getByRole('dialog');
|
|
await expect(dialog).toBeVisible({ timeout: 5000 });
|
|
|
|
const searchInput = dialog.getByRole('combobox', { name: /rechercher/i });
|
|
await searchInput.fill(parentSearchTerm);
|
|
|
|
const listbox = dialog.locator('#parent-search-listbox');
|
|
await expect(listbox).toBeVisible({ timeout: 10000 });
|
|
const option = listbox.locator('[role="option"]').first();
|
|
await option.click();
|
|
|
|
await expect(dialog.getByText(/sélectionné/i)).toBeVisible();
|
|
|
|
await dialog.getByLabel(/type de relation/i).selectOption(relationship);
|
|
await dialog.getByRole('button', { name: 'Ajouter' }).click();
|
|
|
|
await expect(
|
|
page.locator('.alert-success').or(page.locator('.alert-error'))
|
|
).toBeVisible({ timeout: 10000 });
|
|
}
|
|
|
|
test.describe('Parent Homework Consultation (Story 5.8)', () => {
|
|
test.describe.configure({ mode: 'serial', timeout: 60000 });
|
|
|
|
const urgentDueDate = getTomorrowWeekday();
|
|
const futureDueDate = getNextWeekday(10);
|
|
const todayDueDate = getFormattedToday();
|
|
const overdueDueDate = getPastDate(3);
|
|
|
|
test.beforeAll(async ({ browser }, testInfo) => {
|
|
testInfo.setTimeout(120000);
|
|
try {
|
|
execSync(
|
|
`docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear cache.rate_limiter users.cache --env=dev 2>&1`,
|
|
{ encoding: 'utf-8' }
|
|
);
|
|
} catch {
|
|
// Cache pools may not exist
|
|
}
|
|
|
|
// Create users
|
|
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' }
|
|
);
|
|
|
|
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 --firstName=ParentHW --lastName=TestUser 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 student1Output = execSync(
|
|
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${STUDENT1_EMAIL} --password=${STUDENT1_PASSWORD} --role=ROLE_ELEVE --firstName=Emma --lastName=ParentHWTest 2>&1`,
|
|
{ encoding: 'utf-8' }
|
|
);
|
|
student1UserId = extractUserId(student1Output);
|
|
|
|
const student2Output = 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 --firstName=Lucas --lastName=ParentHWTest 2>&1`,
|
|
{ encoding: 'utf-8' }
|
|
);
|
|
student2UserId = extractUserId(student2Output);
|
|
|
|
const { schoolId, academicYearId } = resolveDeterministicIds();
|
|
|
|
// Ensure classes exist
|
|
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-PHW-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-PHW-6B', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
|
|
);
|
|
} catch {
|
|
// May already exist
|
|
}
|
|
|
|
// Ensure subjects exist
|
|
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-PHW-Maths', 'E2EPHWMAT', '#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-PHW-Français', 'E2EPHWFRA', '#ef4444', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
|
|
);
|
|
} catch {
|
|
// May already 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 = '${STUDENT1_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` +
|
|
`AND c.name = 'E2E-PHW-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-PHW-6B' AND c.tenant_id = '${TENANT_ID}' ` +
|
|
`ON CONFLICT DO NOTHING`
|
|
);
|
|
|
|
// Clean up stale homework from previous runs
|
|
try {
|
|
runSql(
|
|
`DELETE FROM homework WHERE tenant_id = '${TENANT_ID}' AND class_id IN ` +
|
|
`(SELECT id FROM school_classes WHERE name IN ('E2E-PHW-6A', 'E2E-PHW-6B') AND tenant_id = '${TENANT_ID}')`
|
|
);
|
|
} catch {
|
|
// Table may not exist
|
|
}
|
|
|
|
// Seed homework for both classes
|
|
// Urgent homework (due tomorrow) for class 6A
|
|
runSql(
|
|
`INSERT INTO homework (id, tenant_id, class_id, subject_id, teacher_id, title, description, due_date, status, created_at, updated_at) ` +
|
|
`SELECT gen_random_uuid(), '${TENANT_ID}', c.id, s.id, t.id, 'Devoir urgent maths', 'Exercices urgents', '${urgentDueDate}', 'published', NOW(), NOW() ` +
|
|
`FROM school_classes c, ` +
|
|
`(SELECT id FROM subjects WHERE code = 'E2EPHWMAT' 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-PHW-6A' AND c.tenant_id = '${TENANT_ID}'`
|
|
);
|
|
|
|
// Future homework for class 6A
|
|
runSql(
|
|
`INSERT INTO homework (id, tenant_id, class_id, subject_id, teacher_id, title, description, due_date, status, created_at, updated_at) ` +
|
|
`SELECT gen_random_uuid(), '${TENANT_ID}', c.id, s.id, t.id, 'Rédaction français Emma', 'Écrire une rédaction', '${futureDueDate}', 'published', NOW(), NOW() ` +
|
|
`FROM school_classes c, ` +
|
|
`(SELECT id FROM subjects WHERE code = 'E2EPHWFRA' 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-PHW-6A' AND c.tenant_id = '${TENANT_ID}'`
|
|
);
|
|
|
|
// Homework for class 6B (Lucas)
|
|
runSql(
|
|
`INSERT INTO homework (id, tenant_id, class_id, subject_id, teacher_id, title, description, due_date, status, created_at, updated_at) ` +
|
|
`SELECT gen_random_uuid(), '${TENANT_ID}', c.id, s.id, t.id, 'Exercices maths Lucas', 'Exercices chapitre 7', '${futureDueDate}', 'published', NOW(), NOW() ` +
|
|
`FROM school_classes c, ` +
|
|
`(SELECT id FROM subjects WHERE code = 'E2EPHWMAT' 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-PHW-6B' AND c.tenant_id = '${TENANT_ID}'`
|
|
);
|
|
|
|
// Homework due TODAY for class 6A ("Aujourd'hui" badge)
|
|
runSql(
|
|
`INSERT INTO homework (id, tenant_id, class_id, subject_id, teacher_id, title, description, due_date, status, created_at, updated_at) ` +
|
|
`SELECT gen_random_uuid(), '${TENANT_ID}', c.id, s.id, t.id, 'Devoir maths aujourd''hui', 'Exercices pour aujourd''hui', '${todayDueDate}', 'published', NOW(), NOW() ` +
|
|
`FROM school_classes c, ` +
|
|
`(SELECT id FROM subjects WHERE code = 'E2EPHWMAT' 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-PHW-6A' AND c.tenant_id = '${TENANT_ID}'`
|
|
);
|
|
|
|
// Overdue homework for class 6A ("En retard" badge)
|
|
runSql(
|
|
`INSERT INTO homework (id, tenant_id, class_id, subject_id, teacher_id, title, description, due_date, status, created_at, updated_at) ` +
|
|
`SELECT gen_random_uuid(), '${TENANT_ID}', c.id, s.id, t.id, 'Devoir français en retard', 'Exercices en retard', '${overdueDueDate}', 'published', NOW(), NOW() ` +
|
|
`FROM school_classes c, ` +
|
|
`(SELECT id FROM subjects WHERE code = 'E2EPHWFRA' 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-PHW-6A' AND c.tenant_id = '${TENANT_ID}'`
|
|
);
|
|
|
|
// Link parent to both students via admin UI
|
|
const page = await browser.newPage();
|
|
await loginAsAdmin(page);
|
|
await addGuardianIfNotLinked(page, student1UserId, PARENT_EMAIL, 'tuteur');
|
|
await addGuardianIfNotLinked(page, student2UserId, PARENT_EMAIL, 'tutrice');
|
|
await page.close();
|
|
|
|
clearCache();
|
|
});
|
|
|
|
test.beforeEach(async () => {
|
|
try {
|
|
execSync(
|
|
`docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear cache.rate_limiter --env=dev 2>&1`,
|
|
{ encoding: 'utf-8' }
|
|
);
|
|
} catch {
|
|
// Cache pool may not exist
|
|
}
|
|
});
|
|
|
|
// ======================================================================
|
|
// AC1: Liste devoirs enfant
|
|
// ======================================================================
|
|
test.describe('AC1: Homework List', () => {
|
|
test('parent can navigate to homework page via navigation', async ({ page }) => {
|
|
await loginAsParent(page);
|
|
|
|
const nav = page.locator('.desktop-nav');
|
|
await expect(nav.getByRole('link', { name: /devoirs/i })).toBeVisible({ timeout: 15000 });
|
|
});
|
|
|
|
test('parent homework page shows homework list', async ({ page }) => {
|
|
await loginAsParent(page);
|
|
await page.goto(`${ALPHA_URL}/dashboard/parent-homework`);
|
|
|
|
await expect(
|
|
page.getByRole('heading', { name: /devoirs des enfants/i })
|
|
).toBeVisible({ timeout: 15000 });
|
|
|
|
// Homework cards should be visible
|
|
const cards = page.locator('.homework-card');
|
|
await expect(cards.first()).toBeVisible({ timeout: 10000 });
|
|
});
|
|
});
|
|
|
|
// ======================================================================
|
|
// AC2: Vue identique élève (sans marquage "Fait")
|
|
// ======================================================================
|
|
test.describe('AC2: Student-like View Without Done Toggle', () => {
|
|
test('homework cards do NOT show done toggle checkbox', async ({ page }) => {
|
|
await loginAsParent(page);
|
|
await page.goto(`${ALPHA_URL}/dashboard/parent-homework`);
|
|
|
|
const card = page.locator('.homework-card').first();
|
|
await expect(card).toBeVisible({ timeout: 10000 });
|
|
|
|
// No toggle-done button should exist (privacy)
|
|
await expect(card.locator('.toggle-done')).toHaveCount(0);
|
|
});
|
|
|
|
test('homework cards show title and due date', async ({ page }) => {
|
|
await loginAsParent(page);
|
|
await page.goto(`${ALPHA_URL}/dashboard/parent-homework`);
|
|
|
|
const card = page.locator('.homework-card').first();
|
|
await expect(card).toBeVisible({ timeout: 10000 });
|
|
|
|
// Title visible
|
|
await expect(card.locator('.card-title')).toBeVisible();
|
|
// Due date visible
|
|
await expect(card.locator('.due-date')).toBeVisible();
|
|
});
|
|
});
|
|
|
|
// ======================================================================
|
|
// AC3: Vue multi-enfants
|
|
// ======================================================================
|
|
test.describe('AC3: Multi-Child View', () => {
|
|
test('parent with multiple children sees child selector', async ({ page }) => {
|
|
await loginAsParent(page);
|
|
await page.goto(`${ALPHA_URL}/dashboard/parent-homework`);
|
|
|
|
const childSelector = page.locator('.child-selector');
|
|
await expect(childSelector).toBeVisible({ timeout: 10000 });
|
|
|
|
// Should have "Tous" + 2 children buttons
|
|
const buttons = childSelector.locator('.child-button');
|
|
await expect(buttons).toHaveCount(3);
|
|
});
|
|
|
|
test('consolidated view shows homework grouped by child', async ({ page }) => {
|
|
await loginAsParent(page);
|
|
await page.goto(`${ALPHA_URL}/dashboard/parent-homework`);
|
|
|
|
// Wait for data to load
|
|
const card = page.locator('[data-testid="homework-card"]').first();
|
|
await expect(card).toBeVisible({ timeout: 10000 });
|
|
|
|
// Both children's names should appear as section headers
|
|
const childNames = page.locator('[data-testid="child-name"]');
|
|
await expect(childNames).toHaveCount(2, { timeout: 10000 });
|
|
});
|
|
|
|
test('clicking a specific child filters to their homework', async ({ page }) => {
|
|
await loginAsParent(page);
|
|
await page.goto(`${ALPHA_URL}/dashboard/parent-homework`);
|
|
|
|
const childSelector = page.locator('.child-selector');
|
|
await expect(childSelector).toBeVisible({ timeout: 10000 });
|
|
|
|
// Click on first child (Emma)
|
|
const buttons = childSelector.locator('.child-button');
|
|
await buttons.nth(1).click();
|
|
|
|
// Wait for data to reload
|
|
const card = page.locator('.homework-card').first();
|
|
await expect(card).toBeVisible({ timeout: 10000 });
|
|
|
|
// Should no longer show multiple child sections
|
|
await expect(page.locator('.child-name')).toHaveCount(0, { timeout: 5000 });
|
|
});
|
|
});
|
|
|
|
// ======================================================================
|
|
// AC4: Mise en évidence urgence
|
|
// ======================================================================
|
|
test.describe('AC4: Urgency Highlight', () => {
|
|
test('homework due tomorrow shows urgent badge', async ({ page }) => {
|
|
await loginAsParent(page);
|
|
await page.goto(`${ALPHA_URL}/dashboard/parent-homework`);
|
|
|
|
await expect(page.locator('[data-testid="homework-card"]').first()).toBeVisible({ timeout: 10000 });
|
|
|
|
// Find urgent badge — text depends on when test runs relative to seeded date
|
|
const urgentBadge = page.locator('[data-testid="urgent-badge"]');
|
|
await expect(urgentBadge.first()).toBeVisible({ timeout: 5000 });
|
|
await expect(urgentBadge.first()).toContainText(/pour demain|aujourd'hui|en retard/i);
|
|
});
|
|
|
|
test('urgent homework card has red styling', async ({ page }) => {
|
|
await loginAsParent(page);
|
|
await page.goto(`${ALPHA_URL}/dashboard/parent-homework`);
|
|
|
|
await expect(page.locator('[data-testid="homework-card"]').first()).toBeVisible({ timeout: 10000 });
|
|
|
|
// Urgent card should have the urgent class
|
|
const urgentCard = page.locator('[data-testid="homework-card"].urgent');
|
|
await expect(urgentCard.first()).toBeVisible({ timeout: 5000 });
|
|
});
|
|
|
|
test('urgent homework shows contact teacher link', async ({ page }) => {
|
|
await loginAsParent(page);
|
|
await page.goto(`${ALPHA_URL}/dashboard/parent-homework`);
|
|
|
|
await expect(page.locator('[data-testid="homework-card"]').first()).toBeVisible({ timeout: 10000 });
|
|
|
|
// Contact teacher link should be visible on urgent homework
|
|
const contactLink = page.locator('[data-testid="contact-teacher"]');
|
|
await expect(contactLink.first()).toBeVisible({ timeout: 5000 });
|
|
await expect(contactLink.first()).toContainText(/contacter l'enseignant/i);
|
|
});
|
|
|
|
test('contact teacher link points to messaging page', async ({ page }) => {
|
|
await loginAsParent(page);
|
|
await page.goto(`${ALPHA_URL}/dashboard/parent-homework`);
|
|
|
|
await expect(page.locator('[data-testid="homework-card"]').first()).toBeVisible({ timeout: 10000 });
|
|
|
|
const contactLink = page.locator('[data-testid="contact-teacher"]').first();
|
|
await expect(contactLink).toBeVisible({ timeout: 5000 });
|
|
|
|
// Verify href contains message creation path with proper encoding
|
|
const href = await contactLink.getAttribute('href');
|
|
expect(href).toContain('/messages/new');
|
|
expect(href).toContain('to=');
|
|
expect(href).toContain('subject=Devoir');
|
|
});
|
|
});
|
|
|
|
// ======================================================================
|
|
// Homework detail
|
|
// ======================================================================
|
|
test.describe('Homework Detail', () => {
|
|
test('clicking a homework card shows detail view', async ({ page }) => {
|
|
await loginAsParent(page);
|
|
await page.goto(`${ALPHA_URL}/dashboard/parent-homework`);
|
|
|
|
const card = page.locator('.homework-card').first();
|
|
await expect(card).toBeVisible({ timeout: 10000 });
|
|
await card.click();
|
|
|
|
await expect(page.locator('.homework-detail')).toBeVisible({ timeout: 10000 });
|
|
await expect(page.locator('.detail-title')).toBeVisible();
|
|
});
|
|
|
|
test('back button returns to homework list', async ({ page }) => {
|
|
await loginAsParent(page);
|
|
await page.goto(`${ALPHA_URL}/dashboard/parent-homework`);
|
|
|
|
const card = page.locator('.homework-card').first();
|
|
await expect(card).toBeVisible({ timeout: 10000 });
|
|
await card.click();
|
|
|
|
await expect(page.locator('.homework-detail')).toBeVisible({ timeout: 10000 });
|
|
|
|
// Click back
|
|
await page.locator('.back-button').click();
|
|
await expect(page.locator('.homework-card').first()).toBeVisible({ timeout: 5000 });
|
|
});
|
|
});
|
|
|
|
// ======================================================================
|
|
// AC4 Extended: Urgency Badge Variants
|
|
// ======================================================================
|
|
test.describe('AC4 Extended: Urgency Badge Variants', () => {
|
|
test('homework due today shows "Aujourd\'hui" badge', async ({ page }) => {
|
|
await loginAsParent(page);
|
|
await page.goto(`${ALPHA_URL}/dashboard/parent-homework`);
|
|
|
|
await expect(page.locator('[data-testid="homework-card"]').first()).toBeVisible({ timeout: 10000 });
|
|
|
|
// Find the card for "Devoir maths aujourd'hui"
|
|
const todayCard = page.locator('[data-testid="homework-card"]', {
|
|
has: page.locator('.card-title', { hasText: "Devoir maths aujourd'hui" })
|
|
});
|
|
await expect(todayCard).toBeVisible({ timeout: 10000 });
|
|
|
|
// Verify urgent badge shows "Aujourd'hui"
|
|
const badge = todayCard.locator('[data-testid="urgent-badge"]');
|
|
await expect(badge).toBeVisible({ timeout: 5000 });
|
|
await expect(badge).toContainText("Aujourd'hui");
|
|
|
|
// Badge should NOT have the overdue class
|
|
await expect(badge).not.toHaveClass(/overdue/);
|
|
});
|
|
|
|
test('overdue homework shows "En retard" badge with overdue styling', async ({ page }) => {
|
|
await loginAsParent(page);
|
|
await page.goto(`${ALPHA_URL}/dashboard/parent-homework`);
|
|
|
|
await expect(page.locator('[data-testid="homework-card"]').first()).toBeVisible({ timeout: 10000 });
|
|
|
|
// Find the card for the overdue homework
|
|
const overdueCard = page.locator('[data-testid="homework-card"]', {
|
|
has: page.locator('.card-title', { hasText: 'Devoir français en retard' })
|
|
});
|
|
await expect(overdueCard).toBeVisible({ timeout: 10000 });
|
|
|
|
// Verify urgent badge shows "En retard"
|
|
const badge = overdueCard.locator('[data-testid="urgent-badge"]');
|
|
await expect(badge).toBeVisible({ timeout: 5000 });
|
|
await expect(badge).toContainText('En retard');
|
|
|
|
// Badge should have the overdue class for stronger styling
|
|
await expect(badge).toHaveClass(/overdue/);
|
|
});
|
|
|
|
test('overdue homework card has urgent styling', async ({ page }) => {
|
|
await loginAsParent(page);
|
|
await page.goto(`${ALPHA_URL}/dashboard/parent-homework`);
|
|
|
|
await expect(page.locator('[data-testid="homework-card"]').first()).toBeVisible({ timeout: 10000 });
|
|
|
|
// The overdue card should also have the .urgent class
|
|
const overdueCard = page.locator('[data-testid="homework-card"]', {
|
|
has: page.locator('.card-title', { hasText: 'Devoir français en retard' })
|
|
});
|
|
await expect(overdueCard).toHaveClass(/urgent/);
|
|
});
|
|
});
|
|
|
|
// ======================================================================
|
|
// AC1 Extended: Subject Filter
|
|
// ======================================================================
|
|
test.describe('AC1 Extended: Subject Filter', () => {
|
|
test('subject filter chips are visible when multiple subjects exist', async ({ page }) => {
|
|
await loginAsParent(page);
|
|
await page.goto(`${ALPHA_URL}/dashboard/parent-homework`);
|
|
|
|
await expect(page.locator('[data-testid="homework-card"]').first()).toBeVisible({ timeout: 10000 });
|
|
|
|
// Filter bar should be visible
|
|
const filterBar = page.locator('.filter-bar');
|
|
await expect(filterBar).toBeVisible({ timeout: 5000 });
|
|
|
|
// Should have "Tous" chip + subject chips (at least Maths and Français)
|
|
const chips = filterBar.locator('.filter-chip');
|
|
const chipCount = await chips.count();
|
|
expect(chipCount).toBeGreaterThanOrEqual(3); // Tous + Maths + Français
|
|
|
|
// "Tous" chip should be active by default
|
|
const tousChip = filterBar.locator('.filter-chip', { hasText: 'Tous' });
|
|
await expect(tousChip).toBeVisible();
|
|
await expect(tousChip).toHaveClass(/active/);
|
|
});
|
|
|
|
test('clicking a subject filter shows only homework of that subject', async ({ page }) => {
|
|
await loginAsParent(page);
|
|
await page.goto(`${ALPHA_URL}/dashboard/parent-homework`);
|
|
|
|
await expect(page.locator('[data-testid="homework-card"]').first()).toBeVisible({ timeout: 10000 });
|
|
|
|
// Count all homework cards before filtering
|
|
const allCardsCount = await page.locator('[data-testid="homework-card"]').count();
|
|
expect(allCardsCount).toBeGreaterThanOrEqual(2);
|
|
|
|
// Click on the "E2E-PHW-Français" filter chip
|
|
const filterBar = page.locator('.filter-bar');
|
|
const francaisChip = filterBar.locator('.filter-chip', { hasText: /Français/i });
|
|
await expect(francaisChip).toBeVisible({ timeout: 5000 });
|
|
await francaisChip.click();
|
|
|
|
// Wait for the filter to be applied (chip becomes active)
|
|
await expect(francaisChip).toHaveClass(/active/, { timeout: 5000 });
|
|
|
|
// Wait for the card count to actually decrease (async data reload)
|
|
await expect.poll(
|
|
() => page.locator('[data-testid="homework-card"]').count(),
|
|
{ timeout: 15000, message: 'Filter should reduce homework card count' }
|
|
).toBeLessThan(allCardsCount);
|
|
|
|
// All visible cards should be Français homework
|
|
const filteredCards = page.locator('[data-testid="homework-card"]');
|
|
const filteredCount = await filteredCards.count();
|
|
|
|
// Each visible card should show the Français subject name
|
|
for (let i = 0; i < filteredCount; i++) {
|
|
await expect(filteredCards.nth(i).locator('.subject-name')).toContainText(/Français/i);
|
|
}
|
|
});
|
|
|
|
test('clicking "Tous" resets the subject filter', async ({ page }) => {
|
|
await loginAsParent(page);
|
|
await page.goto(`${ALPHA_URL}/dashboard/parent-homework`);
|
|
|
|
await expect(page.locator('[data-testid="homework-card"]').first()).toBeVisible({ timeout: 10000 });
|
|
|
|
// Apply a Français filter and wait for it to take effect
|
|
const filterBar = page.locator('.filter-bar');
|
|
const francaisChip = filterBar.locator('.filter-chip', { hasText: /Français/i });
|
|
await francaisChip.click();
|
|
await expect(francaisChip).toHaveClass(/active/, { timeout: 5000 });
|
|
|
|
// Wait for the filter to stabilize
|
|
await page.waitForTimeout(2000);
|
|
const filteredCount = await page.locator('[data-testid="homework-card"]').count();
|
|
|
|
// Now click "Tous" to reset
|
|
const tousChip = filterBar.locator('.filter-chip', { hasText: 'Tous' });
|
|
await tousChip.click();
|
|
await expect(tousChip).toHaveClass(/active/, { timeout: 5000 });
|
|
|
|
// "Tous" should show at least as many cards as the filtered view
|
|
await page.waitForTimeout(2000);
|
|
await expect(page.locator('[data-testid="homework-card"]').first()).toBeVisible({ timeout: 10000 });
|
|
const resetCount = await page.locator('[data-testid="homework-card"]').count();
|
|
expect(resetCount).toBeGreaterThanOrEqual(filteredCount);
|
|
|
|
// Verify "Tous" chip is active (filter was reset)
|
|
await expect(tousChip).toHaveClass(/active/);
|
|
});
|
|
});
|
|
|
|
// ======================================================================
|
|
// Dashboard Widget
|
|
// ======================================================================
|
|
test.describe('Dashboard Widget', () => {
|
|
test('dashboard shows homework widget with child names', async ({ page }) => {
|
|
await loginAsParent(page);
|
|
|
|
// Dashboard should load
|
|
await expect(page.locator('.dashboard-grid')).toBeVisible({ timeout: 15000 });
|
|
|
|
// Homework section should show items
|
|
const homeworkSection = page.locator('.dashboard-grid').locator('section', { hasText: /devoirs à venir/i });
|
|
await expect(homeworkSection).toBeVisible({ timeout: 10000 });
|
|
|
|
// Items should be clickable buttons
|
|
const homeworkBtn = homeworkSection.locator('button.homework-item').first();
|
|
await expect(homeworkBtn).toBeVisible({ timeout: 10000 });
|
|
|
|
// Child name should be visible on items
|
|
await expect(homeworkSection.locator('.homework-child').first()).toBeVisible();
|
|
});
|
|
|
|
test('dashboard shows homework from multiple children sorted by date', async ({ page }) => {
|
|
await loginAsParent(page);
|
|
|
|
await expect(page.locator('.dashboard-grid')).toBeVisible({ timeout: 15000 });
|
|
|
|
// Wait for homework buttons to load
|
|
const homeworkBtn = page.locator('button.homework-item').first();
|
|
await expect(homeworkBtn).toBeVisible({ timeout: 10000 });
|
|
|
|
// Should see homework from both Emma and Lucas
|
|
const childLabels = page.locator('.homework-child');
|
|
const count = await childLabels.count();
|
|
const names = new Set<string>();
|
|
for (let i = 0; i < count; i++) {
|
|
const text = await childLabels.nth(i).textContent();
|
|
if (text) names.add(text.trim());
|
|
}
|
|
expect(names.size).toBeGreaterThanOrEqual(2);
|
|
});
|
|
|
|
test('clicking a homework item opens detail modal', async ({ page }) => {
|
|
await loginAsParent(page);
|
|
|
|
await expect(page.locator('.dashboard-grid')).toBeVisible({ timeout: 15000 });
|
|
|
|
const homeworkBtn = page.locator('button.homework-item').first();
|
|
await expect(homeworkBtn).toBeVisible({ timeout: 10000 });
|
|
await homeworkBtn.click();
|
|
|
|
// Modal with detail should appear
|
|
const modal = page.locator('[role="dialog"]');
|
|
await expect(modal).toBeVisible({ timeout: 10000 });
|
|
await expect(modal.locator('.detail-title')).toBeVisible();
|
|
await expect(modal.locator('.teacher-name')).toBeVisible();
|
|
});
|
|
|
|
test('homework detail modal closes with X button', async ({ page }) => {
|
|
await loginAsParent(page);
|
|
|
|
await expect(page.locator('.dashboard-grid')).toBeVisible({ timeout: 15000 });
|
|
|
|
const homeworkBtn = page.locator('button.homework-item').first();
|
|
await expect(homeworkBtn).toBeVisible({ timeout: 10000 });
|
|
await homeworkBtn.click();
|
|
|
|
const modal = page.locator('[role="dialog"]');
|
|
await expect(modal).toBeVisible({ timeout: 10000 });
|
|
|
|
// Close modal
|
|
await page.locator('.homework-modal-close').click();
|
|
await expect(modal).not.toBeVisible({ timeout: 5000 });
|
|
});
|
|
|
|
test('"Voir tous les devoirs" link navigates to homework page', async ({ page }) => {
|
|
await loginAsParent(page);
|
|
|
|
await expect(page.locator('.dashboard-grid')).toBeVisible({ timeout: 15000 });
|
|
|
|
await page.getByText(/voir tous les devoirs/i).click();
|
|
await expect(
|
|
page.getByRole('heading', { name: /devoirs des enfants/i })
|
|
).toBeVisible({ timeout: 15000 });
|
|
});
|
|
});
|
|
});
|