Files
siop2/apps/web/e2e/parcours-r5.spec.ts
pr-daaif cccfaaabc9 feat(r5): bibliothèque-corpus en tableau (arbitrage de recette du référent)
- La liste des documents passe en tableau, conforme à l'écran 5 de
  maquette-r5 (la bibliothèque sera surtout des PDF) : Document,
  Rattaché à, Indexation, Corpus (ASSETS.edit), actions.
- Les vignettes restent aux cartes « Documents » des fiches OT/ascenseur
  (photos) ; StatutCorpus/InterrupteurCorpus extraits en composants.
- Une image non indexable affiche son interrupteur éteint quel que soit
  l'état stocké — l'interrupteur montre la réalité du corpus.
- e2e adapté aux lignes de tableau, 16/16 Playwright rejoués ; artefact
  de revue pixel mis à jour (arbitrages rendus).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 21:40:28 +01:00

150 lines
7.7 KiB
TypeScript

import { expect, test } from '@playwright/test';
/**
* Recette R5 (plan de releases) : la bibliothèque EST le corpus (bandeau
* 09-08, statut d'indexation, interrupteur, réindexation explicite) ; puis
* l'assistant « sourcé ou silencieux » (réponse citée depuis le PDF téléversé,
* refus honnête et chiffré sinon) ; enfin la suggestion de bilan sur un OT
* (codes existants, appliqués par le geste humain, liseré « suggéré »).
* Le service IA tourne avec l'embeddeur déterministe et des seuils abaissés
* (ci.yml) — c'est le CIRCUIT qui se recette, le vrai modèle se recette en
* local (journal R5.1).
*/
const suffix = Date.now().toString(36);
/** PDF 1 page minimal mais VALIDE (xref calculée) — pypdf doit pouvoir en
* extraire le texte : c'est ce qui alimente l'index côté service IA. */
function pdfMinimal(texte: string): Buffer {
const contenu = `BT /F1 12 Tf 72 720 Td (${texte.replace(/[()\\]/g, '\\$&')}) Tj ET`;
const objets = [
'<< /Type /Catalog /Pages 2 0 R >>',
'<< /Type /Pages /Kids [3 0 R] /Count 1 >>',
'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R ' +
'/Resources << /Font << /F1 5 0 R >> >> >>',
`<< /Length ${contenu.length} >>\nstream\n${contenu}\nendstream`,
'<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>',
];
let corps = '%PDF-1.4\n';
const offsets: number[] = [];
objets.forEach((objet, i) => {
offsets.push(corps.length);
corps += `${i + 1} 0 obj\n${objet}\nendobj\n`;
});
const debutXref = corps.length;
corps += `xref\n0 ${objets.length + 1}\n0000000000 65535 f \n`;
for (const offset of offsets) corps += `${String(offset).padStart(10, '0')} 00000 n \n`;
corps += `trailer\n<< /Size ${objets.length + 1} /Root 1 0 R >>\nstartxref\n${debutXref}\n%%EOF`;
return Buffer.from(corps, 'latin1');
}
async function connexionDemo(page: import('@playwright/test').Page, nom: RegExp) {
await page.goto('/connexion');
await page.getByRole('button', { name: nom }).click();
await expect(page.locator('.topbar')).toBeVisible();
}
test('recette R5 : corpus → réindexation → assistant sourcé → refus honnête', async ({ page }) => {
test.setTimeout(180_000); // deux réindexations complètes du corpus réel
const fichier = `e2e-notice-${suffix}.pdf`;
// 1 · Nadia (gestionnaire, ASSETS.edit) — la bibliothèque est le corpus
await connexionDemo(page, /Nadia Berrada/);
await page.getByRole('link', { name: 'Fichiers' }).click();
await expect(page.getByRole('heading', { name: /corpus de l'assistant/ })).toBeVisible();
await expect(page.locator('.avert')).toContainText('Loi 09-08'); // l'anonymisation est DITE
// 2 · Téléverser une notice PDF de test rattachée à A1
await page.getByRole('button', { name: 'Téléverser' }).click();
await page.getByLabel("Rattacher à l'appareil *").selectOption({ index: 1 });
await page.getByLabel('Choisir un fichier').setInputFiles({
name: fichier,
mimeType: 'application/pdf',
buffer: pdfMinimal(
'Couple de serrage des coulisseaux de guides : 25 Nm. Verifier le jeu lateral.',
),
});
await page.getByRole('button', { name: 'Téléverser', exact: true }).last().click();
const vignette = page.locator('tr', { hasText: fichier }); // tableau du corpus (arbitrage référent)
await expect(vignette).toBeVisible();
await expect(vignette.locator('.st')).toHaveText('à indexer'); // jamais indexé à la naissance
// 3 · Réindexer tout — geste explicite, bilan chiffré, statut à jour
await page.getByRole('button', { name: 'Réindexer tout' }).click();
// l'ingestion réelle (MinIO + pypdf + embeddings) peut dépasser les 5 s
await expect(page.getByText('Réindexation terminée')).toBeVisible({ timeout: 60_000 });
await expect(vignette.locator('.st')).toContainText('indexé ·');
await expect(vignette.locator('.st')).toContainText('extrait');
// 4 · L'assistant répond SOURCÉ depuis ce PDF (embeddeur déterministe :
// la question reprend les mots de la notice)
await page.getByRole('link', { name: 'Assistant' }).click();
await expect(page.getByText('répond UNIQUEMENT depuis votre bibliothèque')).toBeVisible();
await page
.getByLabel('Poser une question')
.fill('couple de serrage des coulisseaux de guides ?');
await page.getByRole('button', { name: 'Envoyer' }).click();
const source = page.locator('.source', { hasText: fichier });
await expect(source).toBeVisible();
await expect(source.locator('.extrait')).toContainText('25 Nm'); // l'extrait EXACT
await expect(source.locator('.ou')).toContainText('p. 1'); // la citation pointe la page
await expect(page.locator('.msg-r .avert')).toContainText('vous validez'); // pas un disclaimer caché
// 5 · Sans source au-dessus du seuil : refus honnête, chiffré, avec l'action utile
await page.getByLabel('Poser une question').fill('xylophone quantique zorglub ?');
await page.getByRole('button', { name: 'Envoyer' }).click();
const refus = page.locator('.refus');
await expect(refus).toBeVisible();
await expect(refus).toContainText('je préfère ne pas inventer');
await expect(refus).toContainText(/J'ai cherché dans \d+ documents? indexés? et \d+ bilans?/);
await expect(refus.getByRole('link', { name: /Téléverser la notice/ })).toBeVisible();
// 6 · L'interrupteur exclut la notice — réversible, effectif à la réindexation
await page.getByRole('link', { name: 'Fichiers' }).click();
await vignette.getByRole('switch').click();
await expect(vignette.locator('.st')).toHaveText('exclu du corpus');
await page.getByRole('button', { name: 'Réindexer tout' }).click();
await expect(page.getByText('Réindexation terminée')).toBeVisible({ timeout: 60_000 });
// Ménage : la notice de test sort de la bibliothèque
page.on('dialog', (d) => void d.accept());
await vignette.getByRole('button', { name: 'Supprimer' }).click();
await expect(vignette).toHaveCount(0);
});
test('recette R5 : suggestion de bilan — codes existants, appliqués par l\'humain', async ({
page,
}) => {
const titre = `E2E Suggestion ${suffix}`;
// 1 · Nadia crée un OT de dépannage sur A1
await connexionDemo(page, /Nadia Berrada/);
await page.getByRole('link', { name: 'Ordres de travail' }).click();
await page.getByRole('button', { name: '+ Nouvel OT' }).click();
await page.getByLabel('Objet *').fill(titre);
await page.getByLabel('Équipement *').selectOption({ index: 1 });
await page.getByRole('button', { name: "Créer l'OT (statut Ouvert)" }).click();
await expect(page.getByRole('heading', { name: titre })).toBeVisible();
// 2 · Décrire pour suggérer — l'IA propose des codes EXISTANTS, justifiés
await page
.getByLabel('Décrire pour suggérer (optionnel)')
.fill('Frottement mécanique sur les portes, nettoyage et graissage effectués, essais OK.');
await page.getByRole('button', { name: /Suggérer les codes/ }).click();
const suggestions = page.locator('.suggestion');
await expect(suggestions.first()).toBeVisible();
await expect(page.locator('.confiance').first()).toContainText('confiance');
// 3 · « Appliquer les N » : les sélecteurs se pré-remplissent, liseré « suggéré »
await page.getByRole('button', { name: /Appliquer les \d/ }).click();
await expect(page.locator('.champ-b[data-suggere]').first()).toBeVisible();
const anomalie = page.locator('#bilan-ANOMALY');
await expect(anomalie).not.toHaveValue('');
// 4 · Un choix MANUEL retire le liseré du champ concerné (l'humain a repris la main)
const champAnomalie = page.locator('.champ-b', { has: anomalie });
await expect(champAnomalie).toHaveAttribute('data-suggere', 'true');
await anomalie.selectOption({ index: 1 });
await expect(champAnomalie).not.toHaveAttribute('data-suggere', 'true');
});