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.
359 lines
13 KiB
TypeScript
359 lines
13 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);
|
|
|
|
// Extract port from PLAYWRIGHT_BASE_URL or use default (4173 matches playwright.config.ts)
|
|
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}`;
|
|
|
|
// Test credentials — unique to this spec to avoid cross-spec collisions
|
|
const ADMIN_EMAIL = 'e2e-branding-admin@example.com';
|
|
const ADMIN_PASSWORD = 'BrandingAdmin123';
|
|
const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
|
|
|
// Minimal valid 1x1 transparent PNG for logo upload tests
|
|
const TEST_LOGO_PNG = Buffer.from(
|
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==',
|
|
'base64'
|
|
);
|
|
|
|
test.describe('Branding Visual Customization', () => {
|
|
test.describe.configure({ mode: 'serial' });
|
|
|
|
test.beforeAll(async () => {
|
|
const projectRoot = join(__dirname, '../..');
|
|
const composeFile = join(projectRoot, 'compose.yaml');
|
|
|
|
// Clear rate limiter to prevent login throttling across serial tests
|
|
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
|
|
}
|
|
|
|
// 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' }
|
|
);
|
|
|
|
// Clean up branding data from previous test runs
|
|
execSync(
|
|
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "DELETE FROM school_branding WHERE tenant_id = '${TENANT_ID}'" 2>&1`,
|
|
{ encoding: 'utf-8' }
|
|
);
|
|
|
|
// Clean up logo files from previous test runs
|
|
execSync(
|
|
`docker compose -f "${composeFile}" exec -T php sh -c "rm -rf /app/public/uploads/logos/${TENANT_ID}" 2>&1`,
|
|
{ encoding: 'utf-8' }
|
|
);
|
|
|
|
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 in all environments
|
|
}
|
|
});
|
|
|
|
test.beforeEach(async () => {
|
|
const projectRoot = join(__dirname, '../..');
|
|
const composeFile = join(projectRoot, 'compose.yaml');
|
|
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
|
|
}
|
|
});
|
|
|
|
// Helper to login as admin
|
|
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()
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Waits for the branding page to finish loading.
|
|
*
|
|
* After hydration, the page shows the card sections (logo + colors).
|
|
* Waiting for the heading and the first .card ensures the component
|
|
* is interactive and API data has been fetched.
|
|
*/
|
|
async function waitForPageLoaded(page: import('@playwright/test').Page) {
|
|
await expect(
|
|
page.getByRole('heading', { name: /identité visuelle/i })
|
|
).toBeVisible({ timeout: 15000 });
|
|
|
|
// Wait for at least one card section to appear (loading finished)
|
|
await expect(
|
|
page.locator('.card').first()
|
|
).toBeVisible({ timeout: 15000 });
|
|
}
|
|
|
|
// ============================================================================
|
|
// [P2] Page displays logo and color sections (AC1)
|
|
// ============================================================================
|
|
test('[P2] page affiche les sections logo et couleurs', async ({ page }) => {
|
|
await loginAsAdmin(page);
|
|
await page.goto(`${ALPHA_URL}/admin/branding`);
|
|
await waitForPageLoaded(page);
|
|
|
|
// Title
|
|
await expect(
|
|
page.getByRole('heading', { name: /identité visuelle/i })
|
|
).toBeVisible();
|
|
|
|
// Subtitle
|
|
await expect(
|
|
page.getByText(/personnalisez le logo et les couleurs/i)
|
|
).toBeVisible();
|
|
|
|
// Logo section heading
|
|
await expect(
|
|
page.getByRole('heading', { name: /logo de l'établissement/i })
|
|
).toBeVisible();
|
|
|
|
// Format info
|
|
await expect(
|
|
page.getByText(/formats acceptés/i)
|
|
).toBeVisible();
|
|
|
|
// Logo placeholder (no logo initially)
|
|
await expect(
|
|
page.getByText(/aucun logo configuré/i)
|
|
).toBeVisible();
|
|
|
|
// Upload button
|
|
await expect(
|
|
page.getByText('Importer un logo')
|
|
).toBeVisible();
|
|
|
|
// Color section heading
|
|
await expect(
|
|
page.getByRole('heading', { name: /couleur principale/i })
|
|
).toBeVisible();
|
|
|
|
// Color picker and text input
|
|
await expect(page.locator('#primaryColorPicker')).toBeVisible();
|
|
await expect(page.locator('#primaryColor')).toBeVisible();
|
|
|
|
// Reset and save buttons
|
|
await expect(
|
|
page.getByRole('button', { name: /réinitialiser/i })
|
|
).toBeVisible();
|
|
await expect(
|
|
page.getByRole('button', { name: /enregistrer/i })
|
|
).toBeVisible();
|
|
});
|
|
|
|
// ============================================================================
|
|
// [P1] Changing color updates contrast indicator and preview (AC3)
|
|
// ============================================================================
|
|
test('[P1] modifier la couleur met à jour l\'indicateur de contraste et l\'aperçu', async ({ page }) => {
|
|
await loginAsAdmin(page);
|
|
await page.goto(`${ALPHA_URL}/admin/branding`);
|
|
await waitForPageLoaded(page);
|
|
|
|
// Use color picker input for reliable cross-browser reactive updates
|
|
// Wrap in toPass() to handle Firefox timing where fill() may not immediately trigger change event
|
|
const colorPicker = page.locator('#primaryColorPicker');
|
|
|
|
// --- Dark blue: passes AA (ratio ~10.3) → "Lisible" ---
|
|
await expect(async () => {
|
|
await colorPicker.fill('#1e3a5f');
|
|
await expect(page.locator('.contrast-badge')).toContainText('Lisible', { timeout: 2000 });
|
|
}).toPass({ timeout: 10000 });
|
|
await expect(page.locator('.contrast-indicator.pass')).toBeVisible();
|
|
await expect(page.locator('.preview-swatch').first()).toBeVisible();
|
|
await expect(page.locator('.preview-swatch').first()).toHaveCSS(
|
|
'background-color',
|
|
'rgb(30, 58, 95)',
|
|
{ timeout: 5000 }
|
|
);
|
|
|
|
// --- Yellow: fails AA completely (ratio ~1.07) → "Illisible" ---
|
|
await expect(async () => {
|
|
await colorPicker.fill('#ffff00');
|
|
await expect(page.locator('.contrast-badge')).toContainText('Illisible', { timeout: 2000 });
|
|
}).toPass({ timeout: 10000 });
|
|
await expect(page.locator('.contrast-indicator.fail')).toBeVisible();
|
|
|
|
// --- Dark yellow: passes AA Large only (ratio ~3.7) → "Attention" ---
|
|
await expect(async () => {
|
|
await colorPicker.fill('#8b8000');
|
|
await expect(page.locator('.contrast-badge')).toContainText('Attention', { timeout: 2000 });
|
|
}).toPass({ timeout: 10000 });
|
|
await expect(page.locator('.contrast-indicator.warning')).toBeVisible();
|
|
});
|
|
|
|
// ============================================================================
|
|
// [P1] Saving colors applies CSS variables immediately (AC3, AC5)
|
|
// ============================================================================
|
|
test('[P1] enregistrer les couleurs applique les CSS variables immédiatement', async ({ page }) => {
|
|
await loginAsAdmin(page);
|
|
await page.goto(`${ALPHA_URL}/admin/branding`);
|
|
await waitForPageLoaded(page);
|
|
|
|
// Set a dark blue color via color picker (more reliable than text input across browsers)
|
|
// Wrap in toPass() to handle Firefox timing where fill() may not immediately trigger change event
|
|
await expect(async () => {
|
|
await page.locator('#primaryColorPicker').fill('#1e3a5f');
|
|
await expect(page.getByRole('button', { name: /enregistrer/i })).toBeEnabled({ timeout: 2000 });
|
|
}).toPass({ timeout: 10000 });
|
|
|
|
// Click save and wait for API response
|
|
const responsePromise = page.waitForResponse(
|
|
(resp) => resp.url().includes('/school/branding') && resp.request().method() === 'PUT'
|
|
);
|
|
await page.getByRole('button', { name: /enregistrer/i }).click();
|
|
await responsePromise;
|
|
|
|
// Success message
|
|
await expect(page.locator('.alert-success')).toBeVisible({ timeout: 10000 });
|
|
await expect(page.locator('.alert-success')).toContainText(/couleurs mises à jour/i);
|
|
|
|
// CSS variables applied to document root
|
|
const accentPrimary = await page.evaluate(() =>
|
|
document.documentElement.style.getPropertyValue('--accent-primary')
|
|
);
|
|
expect(accentPrimary).toBe('#1E3A5F');
|
|
|
|
const btnPrimaryBg = await page.evaluate(() =>
|
|
document.documentElement.style.getPropertyValue('--btn-primary-bg')
|
|
);
|
|
expect(btnPrimaryBg).toBe('#1E3A5F');
|
|
|
|
// Save button should be disabled (no pending changes)
|
|
await expect(
|
|
page.getByRole('button', { name: /enregistrer/i })
|
|
).toBeDisabled();
|
|
});
|
|
|
|
// ============================================================================
|
|
// [P2] Upload logo displays preview (AC2)
|
|
// ============================================================================
|
|
test('[P2] upload logo affiche l\'aperçu', async ({ page }) => {
|
|
await loginAsAdmin(page);
|
|
await page.goto(`${ALPHA_URL}/admin/branding`);
|
|
await waitForPageLoaded(page);
|
|
|
|
// Initially no logo
|
|
await expect(page.getByText(/aucun logo configuré/i)).toBeVisible();
|
|
|
|
// Trigger file chooser and upload the test PNG
|
|
const fileChooserPromise = page.waitForEvent('filechooser');
|
|
await page.getByText('Importer un logo').click();
|
|
const fileChooser = await fileChooserPromise;
|
|
|
|
const responsePromise = page.waitForResponse(
|
|
(resp) => resp.url().includes('/school/branding/logo') && resp.request().method() === 'POST'
|
|
);
|
|
await fileChooser.setFiles({
|
|
name: 'logo.png',
|
|
mimeType: 'image/png',
|
|
buffer: TEST_LOGO_PNG
|
|
});
|
|
await responsePromise;
|
|
|
|
// Success message
|
|
await expect(page.locator('.alert-success')).toBeVisible({ timeout: 10000 });
|
|
await expect(page.locator('.alert-success')).toContainText(/logo mis à jour/i);
|
|
|
|
// Logo image is now visible
|
|
await expect(page.locator('.logo-image')).toBeVisible();
|
|
|
|
// "Changer le logo" and "Supprimer" buttons visible
|
|
await expect(page.getByText('Changer le logo')).toBeVisible();
|
|
await expect(
|
|
page.getByRole('button', { name: /supprimer/i })
|
|
).toBeVisible();
|
|
|
|
// Placeholder text is gone
|
|
await expect(page.getByText(/aucun logo configuré/i)).not.toBeVisible();
|
|
});
|
|
|
|
// ============================================================================
|
|
// [P2] Delete logo returns to no-logo state (AC2)
|
|
// ============================================================================
|
|
test('[P2] supprimer logo revient à l\'état sans logo', async ({ page }) => {
|
|
await loginAsAdmin(page);
|
|
await page.goto(`${ALPHA_URL}/admin/branding`);
|
|
await waitForPageLoaded(page);
|
|
|
|
// Logo should be visible from previous test
|
|
await expect(page.locator('.logo-image')).toBeVisible();
|
|
|
|
// Accept the confirmation dialog, wait for DELETE response, then click
|
|
page.once('dialog', (dialog) => dialog.accept());
|
|
const responsePromise = page.waitForResponse(
|
|
(resp) => resp.url().includes('/school/branding/logo') && resp.request().method() === 'DELETE'
|
|
);
|
|
await page.getByRole('button', { name: /supprimer/i }).click();
|
|
await responsePromise;
|
|
|
|
// Success message
|
|
await expect(page.locator('.alert-success')).toBeVisible({ timeout: 10000 });
|
|
await expect(page.locator('.alert-success')).toContainText(/logo supprimé/i);
|
|
|
|
// Back to placeholder state
|
|
await expect(page.getByText(/aucun logo configuré/i)).toBeVisible();
|
|
await expect(page.getByText('Importer un logo')).toBeVisible();
|
|
});
|
|
|
|
// ============================================================================
|
|
// [P2] Reset restores default theme (AC4)
|
|
// ============================================================================
|
|
test('[P2] réinitialiser restaure le thème par défaut', async ({ page }) => {
|
|
await loginAsAdmin(page);
|
|
await page.goto(`${ALPHA_URL}/admin/branding`);
|
|
await waitForPageLoaded(page);
|
|
|
|
// Color should be set from test 3
|
|
await expect(page.locator('#primaryColor')).toHaveValue('#1E3A5F');
|
|
|
|
// Accept the confirmation dialog, wait for PUT response, then click
|
|
page.once('dialog', (dialog) => dialog.accept());
|
|
const responsePromise = page.waitForResponse(
|
|
(resp) => resp.url().includes('/school/branding') && resp.request().method() === 'PUT'
|
|
);
|
|
await page.getByRole('button', { name: /réinitialiser/i }).click();
|
|
await responsePromise;
|
|
|
|
// Success message
|
|
await expect(page.locator('.alert-success')).toBeVisible({ timeout: 10000 });
|
|
await expect(page.locator('.alert-success')).toContainText(/couleurs mises à jour/i);
|
|
|
|
// Color input is now empty
|
|
await expect(page.locator('#primaryColor')).toHaveValue('');
|
|
|
|
// CSS variables removed
|
|
const accentPrimary = await page.evaluate(() =>
|
|
document.documentElement.style.getPropertyValue('--accent-primary')
|
|
);
|
|
expect(accentPrimary).toBe('');
|
|
|
|
// Preview swatch should not be visible (no primary color set)
|
|
await expect(page.locator('.preview-swatch')).not.toBeVisible();
|
|
});
|
|
});
|