mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 20:51:53 +00:00
- 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>
150 lines
7.7 KiB
TypeScript
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');
|
|
});
|