mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 12:41:54 +00:00
feat(r1.2): écrans web du référentiel — 7 écrans fidèles à maquette-r1
- Sites : liste + carte Leaflet/OSM réelle (pins maquette, positions PostGIS), création en modale avec position posée au clic sur la carte - Fiche site : arbre site→zones, appareils rattachés, ajout de zone - Ascenseurs : table dense, filtres site/statut, strie rouge à l'arrêt ; fiche appareil (organes ± ajout/retrait, statut, QR réel) ; création identité → rattachement → organes ; étiquette A6 imprimable (objet papier, window.print n'imprime qu'elle) - Personnes & équipes : invitation → lien d'activation affiché à copier (pas d'email en R1, décision explicite), renvoi de lien, équipes ; page /activation (choix du mot de passe, connexion directe) - Catégories : ajout, renommage inline, désactivation - navigation et actions pilotées par la matrice (usePermissions) — l'API re-vérifie chaque requête - e2e Playwright : recette R1 officielle rejouée intégralement (site → zone → appareil+organe → étiquette → invitation → activation) + parcours « la matrice pilote l'UI » ; purge idempotente en globalSetup - alias Vite @siop/shared → source TS (exports nommés CJS ↯ workspace lié) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,14 @@
|
||||
import { execSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
/** Seed idempotent avant le parcours : mêmes comptes en local et en CI. */
|
||||
/** Seed idempotent + purge des restes du run précédent : mêmes données
|
||||
* en local et en CI. */
|
||||
export default function globalSetup() {
|
||||
execSync('pnpm --filter @siop/api seed', {
|
||||
cwd: fileURLToPath(new URL('../../..', import.meta.url)),
|
||||
const cwd = fileURLToPath(new URL('../../..', import.meta.url));
|
||||
execSync('pnpm --filter @siop/api exec tsx prisma/cleanup-e2e.ts', {
|
||||
cwd,
|
||||
stdio: 'inherit',
|
||||
env: process.env,
|
||||
});
|
||||
execSync('pnpm --filter @siop/api seed', { cwd, stdio: 'inherit', env: process.env });
|
||||
}
|
||||
|
||||
96
apps/web/e2e/parcours-r1.spec.ts
Normal file
96
apps/web/e2e/parcours-r1.spec.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Recette R1 (plan de releases) : créer un site → un ascenseur → ses organes
|
||||
* → imprimer l'étiquette QR → inviter un technicien (activation incluse).
|
||||
*/
|
||||
|
||||
const suffix = Date.now().toString(36);
|
||||
|
||||
test('recette R1 complète, de la création du site à l’activation de l’invité', async ({
|
||||
page,
|
||||
}) => {
|
||||
// Connexion démo (Administrateur — création tous azimuts)
|
||||
await page.goto('/connexion');
|
||||
await page.getByRole('button', { name: /Yasmine Alaoui/ }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Tableau de bord' })).toBeVisible();
|
||||
|
||||
// 1 · Sites : le parc seedé est là, on crée un site
|
||||
await page.getByRole('link', { name: 'Sites' }).click();
|
||||
await expect(page.getByRole('cell', { name: /Tour Atlas/ })).toBeVisible();
|
||||
await page.getByRole('button', { name: '+ Nouveau site' }).click();
|
||||
await page.getByLabel('Nom du site *').fill(`Site E2E ${suffix}`);
|
||||
await page.getByLabel('Ville').fill('Casablanca');
|
||||
await page.getByRole('button', { name: 'Créer le site' }).click();
|
||||
|
||||
// → fiche du site créé ; on lui ajoute une zone
|
||||
await expect(page.getByRole('heading', { name: `Site E2E ${suffix}` })).toBeVisible();
|
||||
await page.getByRole('button', { name: '+ Ajouter un emplacement' }).click();
|
||||
await page.getByLabel('Nom de la zone *').fill(`Hall E2E ${suffix}`);
|
||||
await page.getByRole('button', { name: 'Ajouter', exact: true }).click();
|
||||
await expect(page.locator('.arbre')).toContainText(`Hall E2E ${suffix}`);
|
||||
|
||||
// 2 · Nouvel ascenseur avec un organe
|
||||
await page.getByRole('button', { name: '+ Nouvel ascenseur' }).click();
|
||||
await page.getByLabel('Référence *').fill(`E2E-${suffix}`);
|
||||
await page.getByLabel('Marque *').fill('Otis');
|
||||
await page.getByLabel('Catégorie *').selectOption({ label: 'Ascenseur électrique' });
|
||||
await page.getByLabel('Emplacement dans le site *').selectOption({ label: `Hall E2E ${suffix}` });
|
||||
await page.getByLabel("Type de l'organe 1").selectOption({ label: 'Portes cabine / palières' });
|
||||
await page.getByLabel("Désignation de l'organe 1").fill('Fermator 40/10');
|
||||
await page.getByRole('button', { name: "Créer l'ascenseur (QR généré)" }).click();
|
||||
|
||||
// → fiche appareil : identité, organe, QR
|
||||
await expect(
|
||||
page.getByRole('heading', { name: new RegExp(`Ascenseur E2E-${suffix}`) }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText('Portes cabine / palières')).toBeVisible();
|
||||
|
||||
// 3 · Étiquette QR imprimable
|
||||
await page.getByRole('button', { name: 'Étiquette QR' }).click();
|
||||
await expect(page.locator('.etiquette')).toContainText(`Ascenseur E2E-${suffix}`);
|
||||
await expect(page.locator('.etiquette .consigne')).toContainText('Scannez ce code');
|
||||
|
||||
// 4 · Inviter un technicien → récupérer le lien d'activation
|
||||
await page.getByRole('link', { name: 'Personnes' }).click();
|
||||
await page.getByRole('button', { name: '+ Inviter' }).click();
|
||||
await page.getByLabel('Nom complet *').fill('Recrue E2E');
|
||||
await page.getByLabel('Email professionnel *').fill(`recrue-${suffix}@spelev.ma`);
|
||||
await page.getByLabel('Rôle *').selectOption({ label: 'Technicien' });
|
||||
await page.getByRole('button', { name: "Envoyer l'invitation" }).click();
|
||||
const lien = await page.getByTestId('lien-activation').textContent();
|
||||
expect(lien).toContain('/activation?token=');
|
||||
await page.getByRole('button', { name: 'Fermer' }).click();
|
||||
await expect(page.getByRole('cell', { name: /Recrue E2E/ })).toBeVisible();
|
||||
await expect(page.getByText('Invitation envoyée')).toBeVisible();
|
||||
|
||||
// 5 · La recrue active son compte via le lien et entre directement
|
||||
await page.locator('.compte').click();
|
||||
await page.getByRole('menuitem', { name: 'Se déconnecter' }).click();
|
||||
await page.goto(lien!);
|
||||
await page.getByLabel('Mot de passe (8 caractères minimum)').fill('MotDePasse!123');
|
||||
await page.getByLabel('Confirmez le mot de passe').fill('MotDePasse!123');
|
||||
await page.getByRole('button', { name: 'Activer mon compte' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Tableau de bord' })).toBeVisible();
|
||||
await expect(page.locator('.topbar')).toContainText('Recrue E2E');
|
||||
await expect(page.locator('.topbar')).toContainText('Technicien');
|
||||
});
|
||||
|
||||
test('la matrice pilote l’UI : un technicien voit le parc sans boutons de création', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/connexion');
|
||||
await page.getByRole('button', { name: /Ahmed Benali/ }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Tableau de bord' })).toBeVisible();
|
||||
|
||||
// Catégories (SETTINGS) absent de sa sidebar ; Ascenseurs accessible
|
||||
await expect(page.locator('.nav')).not.toContainText('Catégories');
|
||||
await page.getByRole('link', { name: 'Ascenseurs' }).click();
|
||||
await expect(page.getByRole('cell', { name: 'A1', exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: '+ Nouvel ascenseur' })).toHaveCount(0);
|
||||
|
||||
// Fiche : statuts visibles, pas de retrait d'organe
|
||||
await page.getByRole('cell', { name: 'A1', exact: true }).click();
|
||||
await expect(page.getByText('Treuil / machinerie')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /Retirer/ })).toHaveCount(0);
|
||||
});
|
||||
Reference in New Issue
Block a user