Demander à une IA : une seule feuille à la fois, la copie confirmée sur le bouton
Recette de 1f (F1, F2). Depuis la feuille du maître ou le bilan, la page du prompt prend la place de leur contenu, à la même hauteur, avec « ‹ Explication » ou « ‹ Bilan » pour revenir ; depuis l'analyse et le glossaire, elle monte dans sa propre feuille. La croix et « Fermer » ferment la feuille. Plus de bandeau « Prompt copié » : le bouton passe à « ✓ Copié » (verdict-good) 2 s. Test navigateur : une poignée, une croix, un titre, même hauteur, retour du téléphone, couleur du bouton. Charte et amendement de l'ADR 0013 mis à jour. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
f2df1b2e7f
commit
d5f08fe393
+174
-73
@@ -11,11 +11,14 @@
|
||||
// l'ordinateur double, le joueur passe. Vérifie le bouton « Demander à une
|
||||
// IA » dans la feuille du maître, sur le bilan, dans l'analyse et sur une
|
||||
// fiche du glossaire ; la feuille du prompt à son adresse (…/ia), lisible
|
||||
// avant la copie, rechargeable ; la copie (dans Chromium, le presse-papiers
|
||||
// est relu et comparé au texte de la feuille, et aux chiffres de la feuille
|
||||
// du maître) ; le bandeau « Prompt copié » ; la fermeture qui ramène à
|
||||
// l'écran d'en dessous ; aucune requête vers un autre site ; l'absence
|
||||
// d'erreur et de violation de la politique de sécurité. Captures (Chromium)
|
||||
// avant la copie, rechargeable ; une seule feuille à la fois (depuis la
|
||||
// feuille du maître ou le bilan, la page du prompt prend leur place, à la
|
||||
// même hauteur, avec « ‹ Explication » ou « ‹ Bilan ») ; la copie (dans
|
||||
// Chromium, le presse-papiers est relu et comparé au texte de la feuille, et
|
||||
// aux chiffres de la feuille du maître), confirmée sur le bouton (« Copié »,
|
||||
// 2 s) ; le retour (« ‹ … », Échap, retour du téléphone) qui ramène à
|
||||
// l'écran d'avant, la croix qui ferme ; aucune requête vers un autre site ;
|
||||
// l'absence d'erreur et de violation de la politique de sécurité. Captures (Chromium)
|
||||
// dans <dossier des captures>/prompts/, en 412×915 et 915×412.
|
||||
|
||||
import { mkdirSync } from "node:fs";
|
||||
@@ -109,20 +112,15 @@ const idle = () => until("écran libre", () => !document.body.classList.contains
|
||||
|
||||
/** Une capture de la recette dans les deux tailles (Chromium seulement),
|
||||
* après la règle commune : rien d'interactif sous une barre d'action, pas
|
||||
* de bouton étiré. before() est rejoué avant chaque taille (le bandeau
|
||||
* « Prompt copié » ne dure que 2 s). */
|
||||
* de bouton étiré. before() est rejoué avant chaque taille (« Copié » ne
|
||||
* dure que 2 s sur le bouton). */
|
||||
async function capture(name, before = null) {
|
||||
if (!full) {
|
||||
return;
|
||||
}
|
||||
// Le bandeau « Prompt copié » d'une copie précédente est parti (sauf
|
||||
// pour sa propre capture, où before le fait réapparaître).
|
||||
await until(
|
||||
"bandeau parti",
|
||||
() => document.getElementById("prompt-banner").hidden,
|
||||
null,
|
||||
10_000,
|
||||
);
|
||||
// « Copié » d'une copie précédente est reparti (sauf pour sa propre
|
||||
// capture, où before le fait réapparaître).
|
||||
await until("bouton revenu", notCopied, null, 10_000);
|
||||
for (const size of SIZES) {
|
||||
await page.setViewportSize(size);
|
||||
await sleep(300);
|
||||
@@ -141,26 +139,71 @@ async function capture(name, before = null) {
|
||||
await sleep(300);
|
||||
}
|
||||
|
||||
/** La feuille du prompt, prête : son texte. */
|
||||
/** Le bouton de copie n'est pas (ou plus) en « Copié ». */
|
||||
const notCopied = () => {
|
||||
const copy = document.getElementById("prompt-copy");
|
||||
return copy === null || !copy.classList.contains("copied");
|
||||
};
|
||||
|
||||
/** La page du prompt, prête et visible : son texte. */
|
||||
async function openPrompt(button, hashEnd) {
|
||||
await page.locator(button).scrollIntoViewIfNeeded();
|
||||
await page.click(button);
|
||||
await until(
|
||||
"feuille du prompt prête",
|
||||
(end) =>
|
||||
location.hash.endsWith(end) &&
|
||||
!document.getElementById("prompt-veil").hidden &&
|
||||
document.getElementById("prompt-sheet")?.dataset.ready === "true",
|
||||
(end) => {
|
||||
const prompt = document.getElementById("prompt-page");
|
||||
return (
|
||||
location.hash.endsWith(end) &&
|
||||
prompt?.dataset.ready === "true" &&
|
||||
prompt.getClientRects().length > 0
|
||||
);
|
||||
},
|
||||
hashEnd,
|
||||
);
|
||||
return page.textContent("#prompt-text");
|
||||
}
|
||||
|
||||
/** Une seule feuille visible : la page du prompt, dans la feuille hostId
|
||||
* (feuille du maître, bilan) ou dans la sienne (null) ; une poignée, une
|
||||
* croix et un titre visibles au plus ; « ‹ back ». */
|
||||
async function checkOneSheet(hostId, back, what) {
|
||||
const seen = await page.evaluate((host) => {
|
||||
const visible = (el) =>
|
||||
el.getClientRects().length > 0 && getComputedStyle(el).visibility === "visible";
|
||||
const count = (selector) => [...document.querySelectorAll(selector)].filter(visible).length;
|
||||
const prompt = document.getElementById("prompt-page");
|
||||
return {
|
||||
parent: prompt.parentElement.id,
|
||||
ownSheet: !document.getElementById("prompt-veil").hidden,
|
||||
grips: count(".grip"),
|
||||
closes: count('[role="dialog"] .icon-button'),
|
||||
titles: count('[role="dialog"] h2'),
|
||||
back: document.getElementById("prompt-back").textContent,
|
||||
hostVisible: host === null || !document.getElementById(host).parentElement.hidden,
|
||||
};
|
||||
}, hostId);
|
||||
check(seen.parent === (hostId ?? "prompt-sheet"), `${what} : page du prompt dans ${seen.parent}`);
|
||||
check(seen.ownSheet === (hostId === null), `${what} : feuille propre ${seen.ownSheet}`);
|
||||
check(seen.hostVisible, `${what} : la feuille d'origine n'est pas affichée`);
|
||||
check(seen.grips <= 1, `${what} : ${seen.grips} poignées visibles`);
|
||||
check(seen.closes === 1, `${what} : ${seen.closes} croix visibles`);
|
||||
check(seen.titles === 1, `${what} : ${seen.titles} titres visibles`);
|
||||
check(seen.back === `‹ ${back}`, `${what} : retour « ${seen.back} »`);
|
||||
}
|
||||
|
||||
/** La hauteur et le haut d'un élément. */
|
||||
const boxOf = (selector) =>
|
||||
page.evaluate((s) => {
|
||||
const r = document.querySelector(s).getBoundingClientRect();
|
||||
return { top: Math.round(r.top), height: Math.round(r.height) };
|
||||
}, selector);
|
||||
|
||||
/** Ce que tout prompt doit contenir (cadrage, « IA générative en V1 »). */
|
||||
function checkPrompt(text, what) {
|
||||
for (const part of [
|
||||
"Tu es un coach de backgammon",
|
||||
"Je suis un joueur de niveau ",
|
||||
"Je débute au backgammon.",
|
||||
"Position ID : ",
|
||||
"Match ID : ",
|
||||
"https://ustagammon.sans.pub/#/position/",
|
||||
@@ -174,42 +217,82 @@ function checkPrompt(text, what) {
|
||||
check(!/\{[a-z_]+\}/.test(text) && !text.includes("undefined"), `${what} : repère vide`);
|
||||
}
|
||||
|
||||
/** Copie le prompt, attend le bandeau, et (Chromium) relit le
|
||||
* presse-papiers. */
|
||||
/** La copie, confirmée sur le bouton : « Copié », coche, verdict-good. */
|
||||
const copiedOnButton = () => {
|
||||
const copy = document.getElementById("prompt-copy");
|
||||
return copy.classList.contains("copied") && copy.textContent === "Copié";
|
||||
};
|
||||
|
||||
/** Copie faite (« Copié ») ou repli (le message sous le texte). */
|
||||
const copyDone = () => {
|
||||
const copy = document.getElementById("prompt-copy");
|
||||
return (
|
||||
copy.classList.contains("copied") ||
|
||||
document.getElementById("prompt-status").textContent !== ""
|
||||
);
|
||||
};
|
||||
|
||||
/** Copie le prompt, attend « Copié » sur le bouton puis son retour à
|
||||
* « Copier le prompt » après 2 s, et (Chromium) relit le presse-papiers. */
|
||||
async function copyPrompt(what) {
|
||||
await page.click("#prompt-copy");
|
||||
await until(
|
||||
"copie faite",
|
||||
() =>
|
||||
!document.getElementById("prompt-banner").hidden ||
|
||||
document.getElementById("prompt-status").textContent !== "",
|
||||
null,
|
||||
10_000,
|
||||
);
|
||||
const shown = await page.evaluate(() => ({
|
||||
banner: !document.getElementById("prompt-banner").hidden,
|
||||
bannerText: document.getElementById("prompt-banner").textContent,
|
||||
status: document.getElementById("prompt-status").textContent,
|
||||
text: document.getElementById("prompt-text").textContent,
|
||||
}));
|
||||
await until("copie faite", copyDone, null, 10_000);
|
||||
const shown = await page.evaluate(() => {
|
||||
const copy = document.getElementById("prompt-copy");
|
||||
return {
|
||||
copied: copy.classList.contains("copied"),
|
||||
label: copy.textContent,
|
||||
check: copy.querySelector("svg.icon") !== null,
|
||||
color: getComputedStyle(copy).color,
|
||||
good: getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--verdict-good")
|
||||
.trim(),
|
||||
status: document.getElementById("prompt-status").textContent,
|
||||
text: document.getElementById("prompt-text").textContent,
|
||||
banners: document.querySelectorAll("#prompt-banner").length,
|
||||
};
|
||||
});
|
||||
check(shown.banners === 0, `${what} : un bandeau de copie`);
|
||||
if (shown.copied) {
|
||||
check(shown.label === "Copié" && shown.check, `${what} : bouton « ${shown.label} »`);
|
||||
// La couleur du bouton est verdict-good (#rrggbb dans app.css).
|
||||
const rgb = shown.good
|
||||
.replace("#", "")
|
||||
.match(/../g)
|
||||
.map((h) => Number.parseInt(h, 16))
|
||||
.join(", ");
|
||||
check(shown.color === `rgb(${rgb})`, `${what} : couleur ${shown.color} pour ${shown.good}`);
|
||||
// Deux secondes après, le bouton revient.
|
||||
await until("« Copier le prompt » revenu", notCopied, null, 5_000);
|
||||
const label = await page.textContent("#prompt-copy");
|
||||
check(label === "Copier le prompt", `${what} : le bouton reste « ${label} »`);
|
||||
}
|
||||
if (full) {
|
||||
check(shown.banner && shown.bannerText.includes("Prompt copié"), `${what} : pas de bandeau`);
|
||||
check(shown.copied, `${what} : pas de « Copié » sur le bouton`);
|
||||
const clipboard = await page.evaluate(() => navigator.clipboard.readText());
|
||||
check(clipboard === shown.text, `${what} : le presse-papiers diffère du texte affiché`);
|
||||
return clipboard;
|
||||
}
|
||||
// Firefox : la copie, ou son repli (texte sélectionné, et un message).
|
||||
check(shown.banner || shown.status.includes("sélectionné"), `${what} : ni copie ni repli`);
|
||||
log(`${what} : ${shown.banner ? "copié" : "repli par sélection"}`);
|
||||
check(shown.copied || shown.status.includes("sélectionné"), `${what} : ni copie ni repli`);
|
||||
log(`${what} : ${shown.copied ? "copié" : "repli par sélection"}`);
|
||||
return shown.text;
|
||||
}
|
||||
|
||||
/** Ferme la feuille du prompt : l'écran d'en dessous revient. */
|
||||
async function closePrompt(hashBelow) {
|
||||
await page.click("#prompt-done");
|
||||
/** « ‹ … » : la page du prompt s'en va, l'écran d'avant revient. */
|
||||
async function backFromPrompt(hashBelow) {
|
||||
await page.click("#prompt-back");
|
||||
await promptGone(hashBelow);
|
||||
}
|
||||
|
||||
/** La page du prompt n'est plus affichée, à l'adresse hashBelow. */
|
||||
async function promptGone(hashBelow) {
|
||||
await until(
|
||||
"feuille du prompt fermée",
|
||||
(below) => document.getElementById("prompt-veil").hidden && location.hash === below,
|
||||
(below) =>
|
||||
document.getElementById("prompt-veil").hidden &&
|
||||
document.querySelector(".prompting") === null &&
|
||||
location.hash === below,
|
||||
hashBelow,
|
||||
);
|
||||
}
|
||||
@@ -254,7 +337,16 @@ const sheetNumbers = await page.evaluate(() =>
|
||||
await capture("1-bouton-feuille-du-maitre", () =>
|
||||
page.locator("#explain-ask-ai").scrollIntoViewIfNeeded(),
|
||||
);
|
||||
// La feuille du maître, avant : sa hauteur, que la page du prompt garde.
|
||||
const explainBox = await boxOf("#explain-sheet");
|
||||
const moveText = await openPrompt("#explain-ask-ai", "/ia");
|
||||
await checkOneSheet("explain-sheet", "Explication", "feuille du maître");
|
||||
const promptBox = await boxOf("#prompt-page");
|
||||
check(
|
||||
Math.abs(promptBox.top - explainBox.top) <= 1 &&
|
||||
Math.abs(promptBox.height - explainBox.height) <= 1,
|
||||
`la feuille change de hauteur : ${JSON.stringify(explainBox)} puis ${JSON.stringify(promptBox)}`,
|
||||
);
|
||||
check((await page.evaluate(() => location.hash)) === `${explainHash}/ia`, "adresse de la feuille");
|
||||
checkPrompt(moveText, "prompt du coup");
|
||||
check(moveText.includes("J'ai joué 6/off : "), "prompt du coup : coup joué");
|
||||
@@ -276,30 +368,39 @@ await capture("2-feuille-du-prompt");
|
||||
const copied = await copyPrompt("prompt du coup");
|
||||
await capture("3-prompt-copie", async () => {
|
||||
await page.click("#prompt-copy");
|
||||
await until("bandeau", () => !document.getElementById("prompt-banner").hidden);
|
||||
await until("« Copié »", copiedOnButton);
|
||||
});
|
||||
log(`prompt du coup copié (${copied.length} caractères)`);
|
||||
// Échap ferme la feuille du prompt, pas l'explication dessous.
|
||||
// Échap ramène à l'explication, qui n'est pas fermée.
|
||||
await page.keyboard.press("Escape");
|
||||
await until(
|
||||
"retour à l'explication",
|
||||
(below) =>
|
||||
document.getElementById("prompt-veil").hidden &&
|
||||
!document.getElementById("explain-veil").hidden &&
|
||||
location.hash === below,
|
||||
explainHash,
|
||||
await promptGone(explainHash);
|
||||
check(
|
||||
await page.evaluate(
|
||||
() =>
|
||||
!document.getElementById("explain-veil").hidden &&
|
||||
getComputedStyle(document.getElementById("explain-close")).visibility === "visible",
|
||||
),
|
||||
"l'explication n'est pas revenue",
|
||||
);
|
||||
// Rechargée, l'adresse de la feuille la rouvre.
|
||||
await page.goto(`${base}${explainHash}/ia`);
|
||||
await page.reload();
|
||||
await until(
|
||||
"feuille rouverte au rechargement",
|
||||
() => document.getElementById("prompt-sheet")?.dataset.ready === "true",
|
||||
() => document.getElementById("prompt-page")?.dataset.ready === "true",
|
||||
);
|
||||
check((await page.textContent("#prompt-text")) === moveText, "le prompt rechargé diffère");
|
||||
await closePrompt(explainHash);
|
||||
await page.click("#explain-done");
|
||||
await until("feuille du maître fermée", () => document.getElementById("explain-veil").hidden);
|
||||
await checkOneSheet("explain-sheet", "Explication", "feuille du maître rechargée");
|
||||
// « ‹ Explication » y ramène.
|
||||
await backFromPrompt(explainHash);
|
||||
// La croix ferme la feuille : l'explication avec.
|
||||
await openPrompt("#explain-ask-ai", "/ia");
|
||||
await page.click("#prompt-close");
|
||||
await until(
|
||||
"feuille du maître fermée",
|
||||
() => document.getElementById("explain-veil").hidden && location.hash === "#/",
|
||||
);
|
||||
await promptGone("#/");
|
||||
|
||||
// ------------------------------------------------------------- Le bilan
|
||||
|
||||
@@ -318,6 +419,7 @@ const summaryHash = await page.evaluate(() => location.hash);
|
||||
const id = summaryHash.split("/")[2];
|
||||
await capture("4-bouton-bilan", () => page.locator("#summary-ask-ai").scrollIntoViewIfNeeded());
|
||||
const summaryText = await openPrompt("#summary-ask-ai", "/bilan/ia");
|
||||
await checkOneSheet("summary", "Bilan", "bilan");
|
||||
checkPrompt(summaryText, "prompt du bilan");
|
||||
check(summaryText.includes("Résultat : l'ordinateur a gagné"), "prompt du bilan : résultat");
|
||||
check(
|
||||
@@ -327,7 +429,9 @@ check(
|
||||
check(summaryText.includes("Le coup à revoir, ma plus grosse erreur (coup 1)"), "coup à revoir");
|
||||
await capture("5-feuille-du-prompt-bilan");
|
||||
await copyPrompt("prompt du bilan");
|
||||
await closePrompt(summaryHash);
|
||||
// Le retour du téléphone ramène au bilan.
|
||||
await page.goBack();
|
||||
await promptGone(summaryHash);
|
||||
check(
|
||||
await page.evaluate(() => document.getElementById("summary")?.dataset.ready === "true"),
|
||||
"le bilan n'est plus là après la feuille du prompt",
|
||||
@@ -349,6 +453,7 @@ await capture("6-bouton-analyse", () =>
|
||||
);
|
||||
const analysisText = await openPrompt("#analysis-ask-ai", `/parties/${id}/analyse/1/ia`);
|
||||
checkPrompt(analysisText, "prompt de l'analyse");
|
||||
await checkOneSheet(null, "Analyse", "analyse");
|
||||
// Le même coup, les mêmes chiffres que dans la feuille du maître (le niveau
|
||||
// du joueur, lui, compte maintenant toutes ses décisions de la partie).
|
||||
const numbersOf = (text) => text.slice(text.indexOf("J'ai joué"), text.indexOf("Ces évaluations"));
|
||||
@@ -356,7 +461,7 @@ check(
|
||||
numbersOf(analysisText) === numbersOf(moveText) && numbersOf(moveText).length > 50,
|
||||
"les chiffres du coup diffèrent entre la feuille du maître et l'analyse",
|
||||
);
|
||||
await closePrompt(`#/parties/${id}/analyse/1`);
|
||||
await backFromPrompt(`#/parties/${id}/analyse/1`);
|
||||
// Coup 2 : le joueur a passé le double, une bonne décision ; son prompt de
|
||||
// videau.
|
||||
await page.goto(`${base}#/parties/${id}/analyse/2`);
|
||||
@@ -368,7 +473,9 @@ const cubeText = await openPrompt("#analysis-ask-ai", "/analyse/2/ia");
|
||||
checkPrompt(cubeText, "prompt du videau");
|
||||
check(cubeText.includes("prendre ou passer le double de l'ordinateur"), "prompt du videau");
|
||||
check(cubeText.includes("J'ai choisi de passer"), "prompt du videau : décision");
|
||||
await closePrompt(`#/parties/${id}/analyse/2`);
|
||||
// « Fermer » : sans feuille dessous, retour à l'analyse.
|
||||
await page.click("#prompt-done");
|
||||
await promptGone(`#/parties/${id}/analyse/2`);
|
||||
|
||||
// --------------------------------------------------------- Le glossaire
|
||||
|
||||
@@ -389,6 +496,7 @@ await capture("7-bouton-glossaire", () =>
|
||||
);
|
||||
const conceptText = await openPrompt("#glossary-ask-ai-videau", "#/glossaire/videau/ia");
|
||||
checkPrompt(conceptText, "prompt du concept");
|
||||
await checkOneSheet(null, "Glossaire", "glossaire");
|
||||
check(conceptText.includes("le concept « Videau » (en anglais : Cube)"), "prompt du concept");
|
||||
check(/chances de gagner \d+ %/.test(conceptText), "prompt du concept : évaluation");
|
||||
await capture("8-feuille-du-prompt-glossaire");
|
||||
@@ -396,24 +504,17 @@ await copyPrompt("prompt du concept");
|
||||
if (full) {
|
||||
// Le repli : navigator.clipboard refuse (comme hors contexte sûr) ; le
|
||||
// texte est sélectionné dans la feuille et copié par le navigateur.
|
||||
await until("bandeau parti", () => document.getElementById("prompt-banner").hidden);
|
||||
await until("bouton revenu", notCopied, null, 5_000);
|
||||
await page.evaluate(async () => {
|
||||
await navigator.clipboard.writeText("");
|
||||
navigator.clipboard.writeText = () => Promise.reject(new Error("refusé"));
|
||||
});
|
||||
await page.click("#prompt-copy");
|
||||
await until(
|
||||
"copie de repli",
|
||||
() =>
|
||||
!document.getElementById("prompt-banner").hidden ||
|
||||
document.getElementById("prompt-status").textContent !== "",
|
||||
null,
|
||||
10_000,
|
||||
);
|
||||
await until("copie de repli", copyDone, null, 10_000);
|
||||
const fallback = await page.evaluate(async () => ({
|
||||
selected: window.getSelection().toString(),
|
||||
text: document.getElementById("prompt-text").textContent,
|
||||
banner: !document.getElementById("prompt-banner").hidden,
|
||||
copied: document.getElementById("prompt-copy").classList.contains("copied"),
|
||||
clipboard: await navigator.clipboard.readText(),
|
||||
}));
|
||||
// Copié depuis une sélection, le texte perd ses espaces insécables
|
||||
@@ -421,12 +522,12 @@ if (full) {
|
||||
const plain = (text) => text.replace(/ /g, " ").trim();
|
||||
check(plain(fallback.selected) === plain(fallback.text), "repli : texte non sélectionné");
|
||||
check(
|
||||
fallback.banner && plain(fallback.clipboard) === plain(fallback.text),
|
||||
`repli : le navigateur n'a pas copié le texte sélectionné (bandeau ${fallback.banner}, presse-papiers ${fallback.clipboard.length} car. pour ${fallback.text.length})`,
|
||||
fallback.copied && plain(fallback.clipboard) === plain(fallback.text),
|
||||
`repli : le navigateur n'a pas copié le texte sélectionné (« Copié » ${fallback.copied}, presse-papiers ${fallback.clipboard.length} car. pour ${fallback.text.length})`,
|
||||
);
|
||||
log("repli par sélection du texte : copié");
|
||||
}
|
||||
await closePrompt("#/glossaire/videau");
|
||||
await backFromPrompt("#/glossaire/videau");
|
||||
|
||||
const csp = await page.evaluate(() => window.__csp);
|
||||
for (const violation of csp) {
|
||||
|
||||
Reference in New Issue
Block a user