Opening the quality tab measured text and forced layout per chart, and every gesture rehashed each placement because a cloned load lost its fingerprints. A gesture now inherits the fingerprint of an unchanged placement; charts far from view draw on approach, holding their drawn height and their legend, so a link or a scroll lands where the fully drawn page puts it. Checked, 200 propositions: tab opening 84.5 -> 27.1 ms (limit 50), 4x CPU 370.7 -> 113.9 ms, 1,973 -> 485 elements, a gesture back at its prior cost. Every memePlacement mutant fails its own test; 44 captures byte-identical. --- FR --- [IMP] page qualité : graphiques différés, texte en cache, gestes légers L'onglet Qualité mesurait le texte et forçait la mise en page à chaque graphique, et chaque geste rehachait chaque placement, la charge clonée perdant ses empreintes. Un geste hérite de l'empreinte d'un placement inchangé ; un graphique loin de la vue se dessine à l'approche, sa hauteur et sa légende déjà posées : un lien ou un défilement arrive où le met la page entièrement dessinée. Vérifié, 200 propositions : ouverture 84,5 -> 27,1 ms (limite 50), CPU 4x 370,7 -> 113,9 ms, 1 973 -> 485 éléments, un geste revenu à son coût. Chaque mutant de memePlacement fait échouer son test ; 44 captures identiques. Assisted-by: Claude Opus 5.5
1281 lines
55 KiB
JavaScript
1281 lines
55 KiB
JavaScript
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||
|
||
// Les écrans de l'itération, chacun atteint depuis le premier écran de
|
||
// l'application montée sur le système simulé (§ 14.5, § 14.6, § 19.11). Un
|
||
// écran est { nom, mode, atteindre(contexte) } : atteindre monte App par le
|
||
// contexte, pose sur le disque simulé le fichier d'état qu'il lui faut, puis
|
||
// mène l'application jusqu'à l'écran par les gestes de l'opérateur — le clic
|
||
// d'un bouton désigné par son attribut data-…, une entrée réelle de
|
||
// test/navigateur/entrees.js sur le plan, une touche — et rend l'élément qui
|
||
// porte l'écran entier, bandeau compris : le conteneur du montage. mode dit
|
||
// ce que le bandeau et le cadre en annoncent : null sans événement ouvert,
|
||
// 'lecture', 'ecriture' ou 'bloque' sinon.
|
||
//
|
||
// Aucun délai fixe (§ 19.3) : chaque étape attend une condition du document,
|
||
// image après image, et lève en la nommant quand elle ne vient pas. Les
|
||
// données sont tirées des démonstrations livrées, d'événements d'épreuve aux
|
||
// noms inventés et de commandes de l'application, sans aléa : deux parcours
|
||
// rendent le même écran.
|
||
//
|
||
// Un état s'atteint sans geste de l'opérateur : la question du dossier de
|
||
// travail, qui se pose au démarrage, par les options du système simulé.
|
||
import { flushSync } from 'svelte';
|
||
import { appliquerCommande } from '../../src/application/commandes.js';
|
||
import { ErreurAnnulee } from '../../src/application/generation.js';
|
||
import { texteLivre } from '../../src/demo/livrees.js';
|
||
import { SECTIONS } from '../../src/interface/evenement/sections.js';
|
||
import { LISTE_PAR_DEFAUT, listeApres } from '../../src/interface/generation/priorites.js';
|
||
import { rechercher } from '../../src/moteur/recherche.js';
|
||
import { serialiser } from '../../src/stockage/canonique.js';
|
||
import { texteDesCourses } from '../../src/stockage/courses.js';
|
||
import { analyser, capacite, configurationDepuisCharge, creerCharge } from '../../src/stockage/document.js';
|
||
import { SUFFIXES } from '../../src/stockage/noms.js';
|
||
import { FICHIER_REGLAGES, serialiserReglages } from '../../src/stockage/reglages_locaux.js';
|
||
import { VERSION } from '../../src/version.genere.js';
|
||
import urlCasLimites from '../../exemples/participants_cas_limites.csv?url';
|
||
import { dessinerGraphiques, pointer, touche } from './entrees.js';
|
||
import { monterApp } from './montage.js';
|
||
|
||
/** La base du fichier d'état de chaque événement d'épreuve. */
|
||
export const BASE = 'soiree';
|
||
|
||
/** Le nom du CSV des cas limites que sert le dialogue d'import. */
|
||
export const NOM_CAS_LIMITES = 'participants_cas_limites.csv';
|
||
|
||
/** Les sections d'un événement, dans l'ordre de la navigation : celles
|
||
* que l'application offre, si bien qu'une section ajoutée se visite à
|
||
* chaque mode sans retouche ici. */
|
||
export const SECTIONS_EVENEMENT = SECTIONS;
|
||
|
||
// L'exécutable publié du système simulé : son dossier data/ refuse
|
||
// l'écriture, et la question du dossier de travail se pose au démarrage.
|
||
const PUBLIE = 'E:\\soirees-epreuve';
|
||
|
||
// Images attendues au plus pour qu'une condition se réalise : bien au-delà
|
||
// d'un rendu, d'une écriture simulée et d'une génération de la petite
|
||
// démonstration.
|
||
const IMAGES_MAX = 900;
|
||
|
||
// Images attendues au plus pour qu'un résumé de la liste arrive : bien
|
||
// au-delà d'une relecture du système simulé et d'un pas par événement, et
|
||
// sous le délai d'une épreuve, que IMAGES_MAX atteint à 60 images par
|
||
// seconde : un résumé qui ne vient pas se nomme au lieu d'épuiser ce délai.
|
||
const IMAGES_RESUME = 300;
|
||
|
||
// --- L'attente et les gestes -------------------------------------------------
|
||
|
||
const uneImage = () => new Promise((resoudre) => requestAnimationFrame(resoudre));
|
||
|
||
/** Attend, image après image, que condition() rende une valeur vraie sans
|
||
* lever, et la rend ; lève en nommant quoi après images images. */
|
||
export async function attendre(quoi, condition, images = IMAGES_MAX) {
|
||
let derniere = null;
|
||
for (let image = 0; image < images; image += 1) {
|
||
flushSync();
|
||
try {
|
||
const valeur = condition();
|
||
if (valeur) return valeur;
|
||
} catch (erreur) {
|
||
derniere = erreur;
|
||
}
|
||
await uneImage();
|
||
}
|
||
throw new Error(`écran : ${quoi} ne vient pas${derniere === null ? '' : ` (${derniere.message})`}`);
|
||
}
|
||
|
||
// Le premier élément du montage qu'atteint le sélecteur, attendu.
|
||
const element = (m, selecteur) => attendre(selecteur, () => m.racine.querySelector(selecteur));
|
||
|
||
/** Clique le bouton du montage que désigne le sélecteur, une fois présent,
|
||
* par son clic HTML, puis laisse le rendu suivre. */
|
||
export async function cliquer(m, selecteur) {
|
||
const bouton = await element(m, selecteur);
|
||
bouton.click();
|
||
flushSync();
|
||
await uneImage();
|
||
return bouton;
|
||
}
|
||
|
||
/** Mène le chargement des résumés de la liste à son terme (§ 8.1) : l'horloge
|
||
* pilotée du montage tient chaque pas du chargement, et avance d'un pas à
|
||
* chaque image jusqu'à ce qu'aucun signe du chargement ne reste. */
|
||
export async function chargerResumes(m) {
|
||
await attendre('les résumés de la liste', () => {
|
||
if (m.racine.querySelector('[data-chargement]') === null) return true;
|
||
m.horloge.avancer(0);
|
||
return false;
|
||
});
|
||
}
|
||
|
||
/** Suit les résumés que la liste du montage reçoit à partir de cet appel :
|
||
* chaque flux que l'application lance par m.facade.resumer rapporte, en
|
||
* plus, chaque base dont il pose le résumé. Rend resumeRecu(base), qui
|
||
* attend, l'horloge pilotée avancée d'un pas à chaque image, que la liste
|
||
* ait reçu un résumé de base depuis le suivi, le rendu à jour : un résumé
|
||
* posé avant le suivi, d'une liste plus ancienne, n'y répond pas. */
|
||
export function suivreResumes(m) {
|
||
const recues = new Set();
|
||
const resumer = m.facade.resumer;
|
||
m.facade.resumer = (lues, options) =>
|
||
resumer(lues, {
|
||
...options,
|
||
recevoir(base, resume) {
|
||
options.recevoir(base, resume);
|
||
recues.add(base);
|
||
},
|
||
});
|
||
return (base) =>
|
||
attendre(
|
||
`le résumé de ${base} reçu par la liste`,
|
||
() => {
|
||
if (recues.has(base)) return true;
|
||
m.horloge.avancer(0);
|
||
return false;
|
||
},
|
||
IMAGES_RESUME,
|
||
);
|
||
}
|
||
|
||
/** Le centre de la boîte d'un élément, en coordonnées de la fenêtre, une
|
||
* fois l'élément amené dans la fenêtre. */
|
||
export async function centre(cible) {
|
||
const dedans = () => {
|
||
const b = cible.getBoundingClientRect();
|
||
return b.width > 0 && b.left >= 0 && b.top >= 0 && b.right <= innerWidth && b.bottom <= innerHeight;
|
||
};
|
||
if (!dedans()) {
|
||
cible.scrollIntoView({ block: 'center', inline: 'center' });
|
||
await attendre('l’élément dans la fenêtre', dedans);
|
||
}
|
||
const b = cible.getBoundingClientRect();
|
||
return { x: b.left + b.width / 2, y: b.top + b.height / 2 };
|
||
}
|
||
|
||
// Le plateau d'une table, qu'il signale un conflit ou non.
|
||
const plateau = (m, table) =>
|
||
m.racine.querySelector(`[data-table="${table}"] [data-marque="contourTable"], [data-table="${table}"] [data-marque="contourConflit"]`);
|
||
const chaise = (m, table, siege) => m.racine.querySelector(`[data-siege="${table}:${siege}"]`);
|
||
|
||
// Un appui relâché sur place, au centre d'un élément : un clic de pointeur
|
||
// réel, sous le seuil de glissement.
|
||
async function cliquerPointeur(cible) {
|
||
const { x, y } = await centre(cible);
|
||
await pointer('presser', x, y);
|
||
await pointer('relacher', x, y);
|
||
flushSync();
|
||
await uneImage();
|
||
}
|
||
|
||
// --- Les événements d'épreuve -----------------------------------------------
|
||
|
||
const executer = (charge, nom, args) => appliquerCommande(nom, charge, args).charge;
|
||
const demonstration = (cle) => analyser(texteLivre(cle)).charge;
|
||
|
||
/**
|
||
* La petite démonstration, variante conflit, avec une désignation (la
|
||
* personne 1, « hôte », à la table 1, siège 1), une réservation d'un tour (la
|
||
* personne 2 à la table 2, siège 2, au tour 1), un titre non pourvu
|
||
* (« animateur », table 3, siège 3) et une proposition, tirée de la graine 2
|
||
* : au tour 1, les tables 1 et 2 sont en conflit, les tables 3 et 4 saines.
|
||
*/
|
||
export function chargePrincipale() {
|
||
return generationPrincipale().charge;
|
||
}
|
||
|
||
/** La principale et les entrées du compagnon des courses que sa génération
|
||
* rend : { charge, courses }. */
|
||
export function generationPrincipale() {
|
||
let charge = demonstration('petite-conflit');
|
||
charge = executer(charge, 'designer', { participant: 1, table: 1, siege: 1, libelle: 'hôte' });
|
||
charge = executer(charge, 'reserver', { participant: 2, table: 2, siege: 2, portee: 'tour', tour: 1 });
|
||
charge = executer(charge, 'poserTitre', { table: 3, siege: 3, libelle: 'animateur' });
|
||
return generee(charge, { graine: 2, nombre: 1, arret: 300 });
|
||
}
|
||
|
||
// La charge, une génération de plus sous les réglages donnés, historique
|
||
// 1 000, enregistrée par la commande : { charge, courses }, courses les
|
||
// entrées du compagnon que la commande rend, une par proposition.
|
||
function generee(charge, reglages) {
|
||
const configuration = configurationDepuisCharge(charge);
|
||
const propositions = rechercher(configuration, { historique: 1000, ...reglages });
|
||
const rendu = appliquerCommande('enregistrerGeneration', charge, { propositions, configuration, produitVersion: VERSION.affichee });
|
||
return { charge: rendu.charge, courses: rendu.courses };
|
||
}
|
||
|
||
// Des personnes inventées : le prénom et le nom se combinent, chacun
|
||
// désigné par son rang, sans homonyme jusqu'à soixante-quatre.
|
||
const PRENOMS = ['Iris', 'Théo', 'Ondine', 'Basile', 'Maëlle', 'Sidonie', 'Aurèle', 'Clovis'];
|
||
const NOMS = ['Ombrelle', 'Pervenche', 'Lacasse', 'Tourteau', 'Quenouille', 'Cormoran', 'Belette', 'Garance'];
|
||
|
||
// Un événement d'épreuve inventé, sans proposition ni retenu : une
|
||
// personne par entrée d'appartenances — son appartenance, null sans —,
|
||
// tables tables rondes de sieges places numérotées de 1 à tables, tours
|
||
// tours.
|
||
function chargeInventee({ appartenances, tables, sieges, tours }) {
|
||
const charge = creerCharge({ id: 'evt-qualite', nom: 'Soirée Pervenche', siegesParDefaut: sieges, tours });
|
||
charge.participants = appartenances.map((appartenance, rang) => ({
|
||
id: rang + 1,
|
||
nom: NOMS[Math.floor(rang / PRENOMS.length) % NOMS.length],
|
||
prenom: PRENOMS[rang % PRENOMS.length],
|
||
appartenance,
|
||
courriel: null,
|
||
titrePressenti: null,
|
||
notes: null,
|
||
exclu: false,
|
||
}));
|
||
charge.tables = Array.from({ length: tables }, (_, rang) => ({
|
||
id: rang + 1,
|
||
numero: rang + 1,
|
||
sieges: null,
|
||
forme: 'ronde',
|
||
position: { x: 300 * (rang + 1), y: 0 },
|
||
}));
|
||
charge.prochainsIds = { participant: appartenances.length + 1, table: tables + 1, proposition: 1 };
|
||
return charge;
|
||
}
|
||
|
||
const AUBIER = 'Coopérative Aubier';
|
||
const RESSAC = 'Atelier Ressac';
|
||
|
||
/** Douze personnes, quatre tables de trois, six tours (§ 12.5) : chacun
|
||
* revoit quelqu'un ; les deux premières, réservées ensemble à la table 1
|
||
* pour tous les tours, forment une paire imposée. Une génération de deux
|
||
* propositions. */
|
||
export function chargePaires() {
|
||
const charge = chargeInventee({ appartenances: Array.from({ length: 12 }, (_, rang) => [AUBIER, RESSAC, null][rang % 3]), tables: 4, sieges: 3, tours: 6 });
|
||
charge.reservations = [1, 2].map((participant) => ({ participant, table: 1, siege: null, portee: 'tous', tour: null }));
|
||
return generee(charge, { graine: 5, nombre: 2, arret: 60 }).charge;
|
||
}
|
||
|
||
/** Douze personnes, trois tables de quatre, trois tours ; les six premières
|
||
* portent l'étiquette « finance », que la table 1 a pour objectif à tous
|
||
* les tours. Une génération d'une proposition, sous cet objectif. */
|
||
export function chargeObjectifs() {
|
||
const charge = chargeInventee({ appartenances: Array.from({ length: 12 }, (_, rang) => [AUBIER, RESSAC, null][rang % 3]), tables: 3, sieges: 4, tours: 3 });
|
||
for (const personne of charge.participants) if (personne.id <= 6) personne.etiquettes = ['finance'];
|
||
charge.tables[0].objectifs = [{ portee: 'tous', tour: null, regle: null, etiquettes: ['finance'] }];
|
||
return generee(charge, { graine: 3, nombre: 1, arret: 300 }).charge;
|
||
}
|
||
|
||
// Le placement de chargeNiveaux, tours par tours, sièges non attribués.
|
||
// Les doublons parmi les affiliés (α − δ) valent 3 à la table 1 à chaque
|
||
// tour, 2 et 1 aux tables 2 et 3 du tour 1, 0 ailleurs ; les sièges sans
|
||
// appartenance nouvelle (c − δ) valent 0 à la table 2 du tour 2, 1 aux
|
||
// tables 2 et 3 du tour 3, 2 et 3 ailleurs ; la table 4 du tour 3 n'a aucun
|
||
// affilié. Les quatre de la table 1 se voient trois fois, 5 et 6 deux
|
||
// fois, 5 et 7 une fois, 1 et 5 jamais.
|
||
const NIVEAUX = Object.freeze([
|
||
[[1, 2, 3, 4], [5, 6, 7, 12], [8, 9, 13, 14], [10, 11, 15, 16]],
|
||
[[1, 2, 3, 4], [5, 8, 10, 11], [6, 9, 12, 13], [7, 14, 15, 16]],
|
||
[[1, 2, 3, 4], [5, 6, 9, 10], [7, 8, 11, 12], [13, 14, 15, 16]],
|
||
]);
|
||
|
||
/** Seize personnes inventées, quatre tables de quatre, trois tours : les
|
||
* quatre de la première appartenance réservées ensemble à la table 1 pour
|
||
* tous les tours, et la proposition NIVEAUX, écrite à la main, où la
|
||
* grille des tables, sous chaque position, et la matrice des rencontres
|
||
* portent les quatre niveaux de l'échelle (§ 12.7, D33, D35). */
|
||
export function chargeNiveaux() {
|
||
const appartenances = [
|
||
...Array(4).fill(AUBIER),
|
||
...Array(3).fill(RESSAC),
|
||
...Array(2).fill('Collectif Brisant'),
|
||
'Cercle Ormeau',
|
||
'Brigade Sauge',
|
||
...Array(5).fill(null),
|
||
];
|
||
let charge = chargeInventee({ appartenances, tables: 4, sieges: 4, tours: 3 });
|
||
for (const participant of [1, 2, 3, 4]) {
|
||
charge = executer(charge, 'reserver', { participant, table: 1, siege: participant, portee: 'tous', tour: null });
|
||
}
|
||
return avecTours(charge, NIVEAUX);
|
||
}
|
||
|
||
// Le nombre de tours de chargeCollisionsEtroites.
|
||
const TOURS_ETROITS = 10;
|
||
|
||
/** Soixante-quatre personnes inventées, huit tables de huit, TOURS_ETROITS
|
||
* tours, et une proposition écrite à la main, la même à chaque tour : les
|
||
* huit de la première appartenance réunies à la table 1, vingt-huit
|
||
* collisions par tour, aucune prouvée ; les huit de la seconde, une par
|
||
* table sauf deux à la table 2, une collision par tour, dont une seule
|
||
* prouvée — les deux réservées ensemble à la table 2 au tour 1. Le segment
|
||
* « prouvé inévitable » de la seconde vaut un sur un axe de 280 : il est
|
||
* étroit (§ 12.4, B13). Au-delà de SEUIL_MATRICE_PARTICIPANTS, la matrice
|
||
* ne se dessine pas. */
|
||
export function chargeCollisionsEtroites() {
|
||
const appartenances = [...Array(8).fill(AUBIER), ...Array(8).fill(RESSAC), ...Array(48).fill(null)];
|
||
const charge = chargeInventee({ appartenances, tables: 8, sieges: 8, tours: TOURS_ETROITS });
|
||
charge.reservations = [9, 10].map((participant) => ({ participant, table: 2, siege: null, portee: 'tour', tour: 1 }));
|
||
// Les tables 2 à 8 : la seconde appartenance, 9 et 10 à la table 2, puis
|
||
// une de 11 à 16 par table, complétées des personnes sans appartenance.
|
||
const sans = Array.from({ length: 48 }, (_, rang) => 17 + rang);
|
||
const autres = [
|
||
[9, 10, ...sans.slice(0, 6)],
|
||
...[11, 12, 13, 14, 15, 16].map((personne, rang) => [personne, ...sans.slice(6 + 7 * rang, 13 + 7 * rang)]),
|
||
];
|
||
const tour = [[1, 2, 3, 4, 5, 6, 7, 8], ...autres];
|
||
return avecTours(charge, Array.from({ length: TOURS_ETROITS }, () => tour));
|
||
}
|
||
|
||
/** La petite démonstration dont la table 4 est réservée en entier pour
|
||
* tous les tours (chargeGelee), sous la proposition TROIS_CONFLITS, écrite
|
||
* à la main, qui assoit ailleurs les trois personnes réservées : les
|
||
* rencontres que le plan mesure dépassent les plafonds que les
|
||
* réservations bornent, et la garde du détail (refusDessin, § 12.3) retire
|
||
* le dessin de la page de qualité. */
|
||
export function chargeRefusDessin() {
|
||
return avecTours(chargeGelee(), TROIS_CONFLITS);
|
||
}
|
||
|
||
/** Le compagnon des courses d'une charge, ses entrées données, posé dans
|
||
* les Documents sous BASE. */
|
||
export const fichierCourses = (charge, entrees, base = BASE) => ({
|
||
racine: 'documents',
|
||
chemin: `${base}${SUFFIXES.courses}`,
|
||
texte: texteDesCourses(charge.evenement.id, entrees),
|
||
});
|
||
|
||
// Un placement de la petite démonstration en conflit, quatre tours de
|
||
// quatre tables de trois, sièges non attribués : au tour 3, la table 1 réunit
|
||
// une même appartenance, des voisins revus et un retour à sa table, la
|
||
// table 2 aucun conflit. Écrit tel quel, il ne dépend d'aucun chemin de la
|
||
// recherche.
|
||
const TROIS_CONFLITS = Object.freeze([
|
||
[[1, 2, 11], [4, 9, 10], [3, 5, 12], [6, 7, 8]],
|
||
[[9, 10, 11], [2, 4, 8], [3, 5, 6], [1, 7, 12]],
|
||
[[4, 5, 10], [6, 11, 12], [1, 7, 9], [2, 3, 8]],
|
||
[[1, 8, 10], [2, 9, 12], [3, 4, 6], [5, 7, 11]],
|
||
]);
|
||
|
||
// La charge reçue et une proposition écrite à la main, la seule : tours,
|
||
// par tour, la liste des personnes de chaque table, dans l'ordre des tables
|
||
// de la charge, sièges non attribués ; le plan devient proposé.
|
||
function avecTours(charge, tours) {
|
||
const suivante = structuredClone(charge);
|
||
suivante.propositions = [
|
||
{
|
||
id: 1,
|
||
graine: 1,
|
||
arret: 300,
|
||
historique: 1000,
|
||
produitVersion: VERSION.affichee,
|
||
siegesAttribues: false,
|
||
tables: suivante.tables.map(({ id }) => id),
|
||
capacites: suivante.tables.map((table) => capacite(suivante, table)),
|
||
tours: tours.length,
|
||
participants: suivante.participants.filter(({ exclu }) => !exclu).map(({ id }) => id),
|
||
placement: tours.map((listes) => ({ sieges: listes.map((liste) => [...liste]), reserve: [] })),
|
||
},
|
||
];
|
||
suivante.prochainsIds.proposition = 2;
|
||
suivante.evenement.etat = 'propose';
|
||
return suivante;
|
||
}
|
||
|
||
/** La principale, sa proposition retenue, puis le plan bloqué. */
|
||
export function chargeBloquee() {
|
||
const principale = chargePrincipale();
|
||
const charge = executer(principale, 'retenirProposition', { id: principale.propositions[0].id });
|
||
return executer(charge, 'changerEtat', { etat: 'bloque' });
|
||
}
|
||
|
||
// La principale, sa proposition retenue, puis une personne ajoutée : le
|
||
// retenu ne la place pas, et l'ouverture avertit de la dérive.
|
||
function chargeEnDerive() {
|
||
const principale = chargePrincipale();
|
||
const charge = executer(principale, 'retenirProposition', { id: principale.propositions[0].id });
|
||
return executer(charge, 'ajouterParticipant', { champs: { nom: 'Ombrelle', prenom: 'Iris' } });
|
||
}
|
||
|
||
// La petite démonstration, la table 4 réservée en entier pour tous les
|
||
// tours : elle est gelée.
|
||
function chargeGelee() {
|
||
let charge = demonstration('petite');
|
||
for (const participant of [1, 2, 3]) {
|
||
charge = executer(charge, 'reserver', { participant, table: 4, siege: participant, portee: 'tous', tour: null });
|
||
}
|
||
return charge;
|
||
}
|
||
|
||
/** Le fichier d'état d'une charge, posé dans les Documents sous BASE. */
|
||
export const fichierEtat = (charge, base = BASE) => ({
|
||
racine: 'documents',
|
||
chemin: `${base}${SUFFIXES.etat}`,
|
||
texte: serialiser(charge, { revision: 1, produitVersion: VERSION.affichee }),
|
||
});
|
||
|
||
/** Les réglages locaux de la machine, posés dans les Documents, le
|
||
* dossier de travail des épreuves : le papier choisi, aucun cadrage. */
|
||
export const fichierReglages = (papier) => ({
|
||
racine: 'documents',
|
||
chemin: FICHIER_REGLAGES,
|
||
texte: serialiserReglages({ format: 1, papier, cadrages: [] }),
|
||
});
|
||
|
||
/** Les octets du CSV des cas limites, tels que le fichier les porte. */
|
||
export async function octetsCasLimites() {
|
||
return new Uint8Array(await (await fetch(urlCasLimites)).arrayBuffer());
|
||
}
|
||
|
||
/**
|
||
* Un exécuteur de génération que l'épreuve ne résout pas : la génération
|
||
* reste en cours tant que le montage vit ; annuler la rejette par
|
||
* ErreurAnnulee.
|
||
*/
|
||
export function executeurSuspendu() {
|
||
return {
|
||
lancer() {
|
||
let rejeter;
|
||
const resultat = new Promise((_, ko) => {
|
||
rejeter = ko;
|
||
});
|
||
return {
|
||
surAvancement: () => () => {},
|
||
resultat,
|
||
annuler() {
|
||
rejeter(new ErreurAnnulee());
|
||
},
|
||
};
|
||
},
|
||
};
|
||
}
|
||
|
||
// --- Les parcours -------------------------------------------------------------
|
||
|
||
/**
|
||
* Monte App sur la charge, ouvre l'événement depuis la liste, passe en
|
||
* écriture si demandé, montre la première proposition au plan quand la
|
||
* charge n'a pas de retenu, puis ouvre la section. Rend le montage.
|
||
*
|
||
* @param {Object} contexte celui de creerContexte
|
||
* @param {Object} charge
|
||
* @param {{ecriture?: boolean, section?: string, options?: Object}} [reglages]
|
||
* options : celles de monterApp, ses fichiers ajoutés à celui de la charge
|
||
*/
|
||
export async function ouvrirEvenement(contexte, charge, { ecriture = false, section = 'plan', options = {} } = {}) {
|
||
const m = await contexte.monter({ ...options, fichiers: [fichierEtat(charge), ...(options.fichiers ?? [])] });
|
||
await cliquer(m, `[data-evenement="${BASE}"] [data-commande="ouvrir"]`);
|
||
await element(m, '[data-ecran="evenement"]');
|
||
if (ecriture) {
|
||
await cliquer(m, '[data-place-fixe] [data-commande="modifier"]');
|
||
await element(m, '[data-place-fixe] [data-commande="lecture"]');
|
||
}
|
||
if (charge.retenu === null && charge.propositions.length > 0) {
|
||
await cliquer(m, '[data-commande="section"][data-cible="propositions"]');
|
||
await cliquer(m, `[data-proposition="${charge.propositions[0].id}"] [data-commande="voir"]`);
|
||
await element(m, '[data-section="plan"]');
|
||
}
|
||
await cliquer(m, `[data-commande="section"][data-cible="${section}"]`);
|
||
await element(m, `[data-section="${section}"]`);
|
||
if (section === 'plan') {
|
||
await attendre('le plan posé', () => {
|
||
const vue = m.racine.querySelector('[data-vue]');
|
||
return vue?.getAttribute('transform') && m.racine.querySelectorAll('[data-table]').length === charge.tables.length;
|
||
});
|
||
}
|
||
return m;
|
||
}
|
||
|
||
/**
|
||
* Ouvre l'événement de la charge, sur la section donnée, puis l'aperçu
|
||
* avant impression par la commande « Imprimer… » de sa tête (§ 11.7), et
|
||
* rend le montage. Le papier de la machine est posé avant le montage dans
|
||
* les réglages locaux ; null n'en pose aucun, et l'aperçu s'ouvre sur la
|
||
* question du papier. Avec un papier, l'aperçu rend son document.
|
||
*
|
||
* @param {Object} contexte
|
||
* @param {Object} charge
|
||
* @param {{papier?: 'A4'|'Lettre'|null, ecriture?: boolean, section?: string, options?: Object}} [reglages]
|
||
*/
|
||
export async function ouvrirApercu(contexte, charge, { papier = 'A4', ecriture = false, section = 'plan', options = {} } = {}) {
|
||
const fichiers = [...(papier === null ? [] : [fichierReglages(papier)]), ...(options.fichiers ?? [])];
|
||
const m = await ouvrirEvenement(contexte, charge, { ecriture, section, options: { ...options, fichiers } });
|
||
await cliquer(m, '[data-ecran="evenement"] [data-commande="apercu"]');
|
||
await element(m, '[data-ecran="apercu"]');
|
||
if (papier !== null) await element(m, '[data-impression] [data-feuille]');
|
||
return m;
|
||
}
|
||
|
||
// Un écran d'événement sur la principale, sur la section donnée, au mode
|
||
// donné ; la bloquée pour 'bloque'.
|
||
function ecranDeSection(section, mode) {
|
||
return {
|
||
nom: `evenement.${section}.${mode}`,
|
||
mode,
|
||
async atteindre(contexte) {
|
||
const charge = mode === 'bloque' ? chargeBloquee() : chargePrincipale();
|
||
const m = await ouvrirEvenement(contexte, charge, { ecriture: mode === 'ecriture', section });
|
||
// La page de qualité se lit dessinée, chaque graphique amené à son
|
||
// tour : les gardes lisent ses marques sous chaque mode.
|
||
if (section === 'qualite') await qualiteDessinee(m);
|
||
return m.racine;
|
||
},
|
||
};
|
||
}
|
||
|
||
// La principale en écriture, sur le plan.
|
||
const planEnEcriture = (contexte, options) => ouvrirEvenement(contexte, chargePrincipale(), { ecriture: true, section: 'plan', options });
|
||
|
||
// La page de qualité du montage dessinée : une vue posée au moins, chaque
|
||
// graphique de la section amené à son tour dans la zone visible
|
||
// (dessinerGraphiques), mesuré et porteur d'une marque au moins.
|
||
async function qualiteDessinee(m) {
|
||
await attendre('une vue de la page de qualité', () => m.racine.querySelector('[data-section="qualite"] [data-vue-qualite]'));
|
||
await dessinerGraphiques(m.racine.querySelector('[data-section="qualite"]'));
|
||
await attendre('la page de qualité dessinée', () => {
|
||
const section = m.racine.querySelector('[data-section="qualite"]');
|
||
if (section === null || section.querySelector('[data-vue-qualite]') === null) return false;
|
||
return [...section.querySelectorAll('svg[data-graphique]')].every(
|
||
(svg) => Number(svg.getAttribute('width')) > 0 && svg.querySelector('[data-marque]') !== null,
|
||
);
|
||
});
|
||
}
|
||
|
||
// Ouvre l'événement de la charge sur la page de qualité (ouvrirEvenement),
|
||
// puis attend son dessin. Rend le montage.
|
||
async function ouvrirQualite(contexte, charge, { ecriture = false, options = {} } = {}) {
|
||
const m = await ouvrirEvenement(contexte, charge, { ecriture, section: 'qualite', options });
|
||
await qualiteDessinee(m);
|
||
return m;
|
||
}
|
||
|
||
|
||
// Les commandes qui ouvrent un jumeau, sous le sélecteur donné.
|
||
const commandesJumeau = (m, sous) => m.racine.querySelectorAll(`${sous} [data-commande="ouvrirJumeau"]`);
|
||
|
||
// Ouvre, par sa commande, chaque jumeau sous le sélecteur donné, l'un après
|
||
// l'autre — la commande d'un jumeau ouvert devient celle qui le ferme —,
|
||
// puis attend que chacun soit rendu. Rend le nombre de jumeaux ouverts.
|
||
async function ouvrirJumeaux(m, sous) {
|
||
const total = commandesJumeau(m, sous).length;
|
||
for (let ouverts = 0; ouverts < total; ouverts += 1) {
|
||
const commande = commandesJumeau(m, sous)[0];
|
||
if (commande === undefined) throw new Error(`écran : ${total - ouverts} jumeau(x) de ${sous} sans commande d'ouverture`);
|
||
commande.click();
|
||
flushSync();
|
||
await uneImage();
|
||
}
|
||
await attendre(`les ${total} jumeaux de ${sous} ouverts`, () => commandesJumeau(m, sous).length === 0 && m.racine.querySelectorAll(`${sous} [data-jumeau]`).length >= total);
|
||
return total;
|
||
}
|
||
|
||
// Un jumeau du profil ouvert sur la page de qualité de la charge, au mode
|
||
// donné : regarder ne refuse rien et n'écrit rien (§ 8.4) — un refus rendu
|
||
// ou une primitive d'écriture appelée depuis l'ouverture de la page fait
|
||
// lever l'écran.
|
||
async function jumeauSansEcriture(contexte, charge) {
|
||
const m = await ouvrirQualite(contexte, charge);
|
||
const ecritures = m.trace.length;
|
||
await ouvrirJumeaux(m, '[data-vue-qualite="profil"]');
|
||
if (m.racine.querySelector('[data-refus]') !== null) throw new Error('écran : un refus rendu à l’ouverture d’un jumeau');
|
||
if (m.trace.length !== ecritures) throw new Error(`écran : l’ouverture d’un jumeau a écrit ${JSON.stringify(m.trace.slice(ecritures))}`);
|
||
return m.racine;
|
||
}
|
||
|
||
// La page de qualité de chargeNiveaux, son sélecteur du mélange des tables
|
||
// posé sur la position donnée par un clic.
|
||
async function tablesSurPosition(contexte, position) {
|
||
const m = await ouvrirQualite(contexte, chargeNiveaux());
|
||
await cliquer(m, `[data-vue-qualite="tables"] [data-position-tables="${position}"]`);
|
||
await attendre(`la position « ${position} » choisie`, () => m.racine.querySelector(`[data-position-tables="${position}"]`)?.checked === true);
|
||
await qualiteDessinee(m);
|
||
return m.racine;
|
||
}
|
||
|
||
/**
|
||
* Le contexte d'un parcours : il monte App dans la langue et le thème
|
||
* donnés, garde chaque montage et chaque nettoyage, et défait tout par
|
||
* defaire(), les nettoyages d'abord, dans l'ordre inverse.
|
||
*
|
||
* @param {{langue?: 'fr'|'temoin', theme?: 'clair'|'sombre'}} [reglages]
|
||
*/
|
||
export function creerContexte({ langue = 'fr', theme = 'clair' } = {}) {
|
||
const montages = [];
|
||
const nettoyages = [];
|
||
return {
|
||
langue,
|
||
montages,
|
||
/** Le dernier montage : celui que l'écran montre. */
|
||
get montage() {
|
||
return montages.at(-1) ?? null;
|
||
},
|
||
async monter(options = {}) {
|
||
const m = await monterApp({ langue, theme, ...options });
|
||
montages.push(m);
|
||
return m;
|
||
},
|
||
nettoyer(fn) {
|
||
nettoyages.push(fn);
|
||
},
|
||
/** Ce que le collecteur a reçu depuis chaque montage. */
|
||
erreurs() {
|
||
return montages.flatMap((m) => m.erreurs());
|
||
},
|
||
async defaire() {
|
||
for (const fn of nettoyages.splice(0).reverse()) await fn();
|
||
for (const m of montages.splice(0).reverse()) m.demonter();
|
||
window.scrollTo(0, 0);
|
||
},
|
||
};
|
||
}
|
||
|
||
/** Les écrans de l'itération. Leur union rend chaque sorte de MARQUES. */
|
||
export const ECRANS = Object.freeze([
|
||
{
|
||
nom: 'accueil.liste',
|
||
mode: null,
|
||
async atteindre(contexte) {
|
||
const m = await contexte.monter({ fichiers: [fichierEtat(chargePrincipale()), fichierEtat(chargeGelee(), 'gelee')] });
|
||
await element(m, `[data-evenement="${BASE}"]`);
|
||
await chargerResumes(m);
|
||
return m.racine;
|
||
},
|
||
},
|
||
{
|
||
nom: 'accueil.chargement',
|
||
mode: null,
|
||
async atteindre(contexte) {
|
||
// La liste paraît avant ses résumés : tant que l'horloge pilotée
|
||
// n'avance pas, chaque valeur de résumé porte le signe du chargement.
|
||
// Rien ici ne l'avance : les signes restent pendant les relevés des
|
||
// deux thèmes, où le trait de chacun se mesure comme la marque
|
||
// « chargement ».
|
||
const m = await contexte.monter({ fichiers: [fichierEtat(chargePrincipale()), fichierEtat(chargeGelee(), 'gelee')] });
|
||
await attendre('le signe du chargement dans chaque valeur de résumé', () =>
|
||
[BASE, 'gelee'].every((base) => m.racine.querySelectorAll(`[data-evenement="${base}"] [aria-busy="true"] [data-chargement]`).length === 2),
|
||
);
|
||
return m.racine;
|
||
},
|
||
},
|
||
{
|
||
nom: 'accueil.mesuresRetenu',
|
||
mode: null,
|
||
async atteindre(contexte) {
|
||
// La principale, sa proposition retenue : la ligne montre la
|
||
// différence du retenu et la meilleure proposition du classement, et le
|
||
// pointeur réel sur l'intitulé de la différence — un en-tête de
|
||
// colonne du tableau, une fois pour la liste — ouvre son infobulle.
|
||
const principale = chargePrincipale();
|
||
const charge = executer(principale, 'retenirProposition', { id: principale.propositions[0].id });
|
||
const m = await contexte.monter({ fichiers: [fichierEtat(charge)] });
|
||
const ligne = `[data-evenement="${BASE}"]`;
|
||
await chargerResumes(m);
|
||
await attendre('la différence du retenu et la meilleure proposition', () =>
|
||
['[data-colonne="diffRedondance"]', '[data-meilleure-id]'].every((valeur) => /\d/.test(m.racine.querySelector(`${ligne} ${valeur}`)?.textContent ?? '')),
|
||
);
|
||
const { x, y } = await centre(await element(m, '[data-ecran="accueil"] thead [data-terme="diffRedondance"]'));
|
||
await pointer('bouger', x, y);
|
||
await attendre("l'infobulle de la différence", () => {
|
||
const bulle = m.racine.querySelector('[data-ecran="accueil"] [role="tooltip"]');
|
||
return bulle !== null && !bulle.hidden && getComputedStyle(bulle).visibility === 'visible';
|
||
});
|
||
return m.racine;
|
||
},
|
||
},
|
||
{
|
||
nom: 'accueil.vide',
|
||
mode: null,
|
||
async atteindre(contexte) {
|
||
const m = await contexte.monter();
|
||
await element(m, '[data-ecran="accueil"] [data-dossier-travail]');
|
||
return m.racine;
|
||
},
|
||
},
|
||
{
|
||
nom: 'apropos',
|
||
mode: null,
|
||
async atteindre(contexte) {
|
||
const m = await contexte.monter();
|
||
await cliquer(m, '[data-bandeau] [data-commande="apropos"]');
|
||
await element(m, '[data-ecran="apropos"]');
|
||
return m.racine;
|
||
},
|
||
},
|
||
{
|
||
nom: 'recuperation',
|
||
mode: null,
|
||
async atteindre(contexte) {
|
||
const sain = fichierEtat(chargePrincipale());
|
||
const tronque = { ...sain, texte: sain.texte.slice(0, Math.floor(sain.texte.length / 2)) };
|
||
const precedent = { ...sain, chemin: `${BASE}${SUFFIXES.precedent}` };
|
||
const m = await contexte.monter({ fichiers: [tronque, precedent] });
|
||
await cliquer(m, `[data-evenement="${BASE}"] [data-commande="ouvrir"]`);
|
||
await element(m, '[data-ecran="recuperation"]');
|
||
return m.racine;
|
||
},
|
||
},
|
||
{
|
||
nom: 'dossier.question',
|
||
mode: null,
|
||
async atteindre(contexte) {
|
||
const m = await contexte.monter({
|
||
systeme: { executable: PUBLIE, sonde: { portable: 'EACCES' } },
|
||
fichiers: [{ ...fichierEtat(chargePrincipale()), racine: 'portable' }],
|
||
});
|
||
await element(m, '[data-question]');
|
||
return m.racine;
|
||
},
|
||
},
|
||
...SECTIONS_EVENEMENT.flatMap((section) => ['lecture', 'ecriture', 'bloque'].map((mode) => ecranDeSection(section, mode))),
|
||
{
|
||
nom: 'plan.tableGelee',
|
||
mode: 'ecriture',
|
||
async atteindre(contexte) {
|
||
const m = await ouvrirEvenement(contexte, chargeGelee(), { ecriture: true, section: 'plan' });
|
||
await element(m, '[data-table="4"] [data-marque="gel"]');
|
||
return m.racine;
|
||
},
|
||
},
|
||
{
|
||
nom: 'plan.miseEnEvidence',
|
||
mode: 'ecriture',
|
||
async atteindre(contexte) {
|
||
const m = await planEnEcriture(contexte);
|
||
await cliquerPointeur(chaise(m, 4, 1));
|
||
await cliquer(m, '[data-panneau="place"] [data-menu] [data-commande="mettreEnEvidence"]');
|
||
await element(m, '[data-siege="4:1"] [data-marque="miseEnEvidence"]');
|
||
return m.racine;
|
||
},
|
||
},
|
||
{
|
||
nom: 'plan.focus',
|
||
mode: 'ecriture',
|
||
async atteindre(contexte) {
|
||
const m = await planEnEcriture(contexte);
|
||
(await element(m, '[data-commande="pleinEcran"]')).focus();
|
||
await touche('Tab');
|
||
await element(m, '[data-plan] [data-marque="focus"]');
|
||
return m.racine;
|
||
},
|
||
},
|
||
{
|
||
nom: 'plan.panneauPlace',
|
||
mode: 'ecriture',
|
||
async atteindre(contexte) {
|
||
const m = await planEnEcriture(contexte);
|
||
await cliquerPointeur(chaise(m, 3, 3));
|
||
await element(m, '[data-panneau="place"]');
|
||
await element(m, '[data-siege="3:3"] [data-marque="selection"]');
|
||
return m.racine;
|
||
},
|
||
},
|
||
{
|
||
nom: 'plan.menuPersonne',
|
||
mode: 'ecriture',
|
||
async atteindre(contexte) {
|
||
const m = await planEnEcriture(contexte);
|
||
await cliquerPointeur(chaise(m, 4, 1));
|
||
await element(m, '[data-panneau="place"] [data-menu]');
|
||
return m.racine;
|
||
},
|
||
},
|
||
{
|
||
nom: 'plan.panneauTable',
|
||
mode: 'ecriture',
|
||
async atteindre(contexte) {
|
||
const m = await planEnEcriture(contexte);
|
||
await cliquerPointeur(plateau(m, 3));
|
||
await element(m, '[data-panneau="table"]');
|
||
return m.racine;
|
||
},
|
||
},
|
||
{
|
||
nom: 'plan.infobulle',
|
||
mode: 'ecriture',
|
||
async atteindre(contexte) {
|
||
const m = await planEnEcriture(contexte);
|
||
const { x, y } = await centre(plateau(m, 1));
|
||
await pointer('bouger', x, y);
|
||
await attendre('l’infobulle', () => {
|
||
const infobulle = m.racine.querySelector('[role="tooltip"]');
|
||
return infobulle !== null && !infobulle.hidden && infobulle.getBoundingClientRect().width > 0;
|
||
});
|
||
return m.racine;
|
||
},
|
||
},
|
||
{
|
||
nom: 'plan.glissement',
|
||
mode: 'ecriture',
|
||
async atteindre(contexte) {
|
||
const m = await planEnEcriture(contexte);
|
||
const depuis = await centre(chaise(m, 3, 2));
|
||
const vers = await centre(chaise(m, 4, 2));
|
||
await pointer('presser', depuis.x, depuis.y);
|
||
contexte.nettoyer(async () => {
|
||
await touche('Escape');
|
||
await pointer('relacher', vers.x, vers.y);
|
||
});
|
||
for (let pas = 1; pas <= 10; pas += 1) {
|
||
await pointer('bouger', depuis.x + ((vers.x - depuis.x) * pas) / 10, depuis.y + ((vers.y - depuis.y) * pas) / 10);
|
||
}
|
||
await element(m, '[data-fantome] [data-marque="fantome"]');
|
||
return m.racine;
|
||
},
|
||
},
|
||
{
|
||
nom: 'plan.refusPlace',
|
||
mode: 'ecriture',
|
||
async atteindre(contexte) {
|
||
const m = await planEnEcriture(contexte);
|
||
const depuis = await centre(chaise(m, 3, 1));
|
||
const vers = await centre(chaise(m, 1, 1));
|
||
await pointer('presser', depuis.x, depuis.y);
|
||
for (let pas = 1; pas <= 10; pas += 1) {
|
||
await pointer('bouger', depuis.x + ((vers.x - depuis.x) * pas) / 10, depuis.y + ((vers.y - depuis.y) * pas) / 10);
|
||
}
|
||
await pointer('relacher', vers.x, vers.y);
|
||
await element(m, '[data-surface] [data-refus="PLACE_RESERVEE"]');
|
||
return m.racine;
|
||
},
|
||
},
|
||
{
|
||
nom: 'plan.troisConflits',
|
||
mode: 'ecriture',
|
||
async atteindre(contexte) {
|
||
// La petite démonstration en conflit et la proposition TROIS_CONFLITS,
|
||
// écrite à la main, montrée au tour 3, choisi au clavier : la table 1
|
||
// viole les trois règles et superpose leurs trames, la table 2 est
|
||
// saine ; puis la légende des conflits, ouverte par la barre de vue.
|
||
const charge = avecTours(demonstration('petite-conflit'), TROIS_CONFLITS);
|
||
const m = await ouvrirEvenement(contexte, charge, { ecriture: true, section: 'plan' });
|
||
const choix = await element(m, '[data-champ="tour"]');
|
||
choix.focus();
|
||
for (let essai = 0; essai < choix.options.length && choix.value !== '3'; essai += 1) {
|
||
await touche('ArrowDown');
|
||
flushSync();
|
||
}
|
||
for (const marque of ['trameAppartenance', 'trameVoisins', 'trameTable']) await element(m, `[data-table="1"] [data-marque="${marque}"]`);
|
||
await cliquer(m, '[data-commande="legende"]');
|
||
await element(m, '[data-legende] [data-legende-regle="nouvelleTable"]');
|
||
return m.racine;
|
||
},
|
||
},
|
||
{
|
||
nom: 'participants.refusChamp',
|
||
mode: 'ecriture',
|
||
async atteindre(contexte) {
|
||
const m = await ouvrirEvenement(contexte, chargePrincipale(), { ecriture: true, section: 'participants' });
|
||
await cliquer(m, '[data-section="participants"] [data-commande="ajouter"]');
|
||
await element(m, '[data-section="participants"] [data-refus="NOM_REQUIS"]');
|
||
return m.racine;
|
||
},
|
||
},
|
||
{
|
||
nom: 'participants.fiche',
|
||
mode: 'ecriture',
|
||
async atteindre(contexte) {
|
||
const m = await ouvrirEvenement(contexte, chargePrincipale(), { ecriture: true, section: 'participants' });
|
||
await cliquer(m, '[data-participant="1"] [data-commande="fiche"]');
|
||
await element(m, '[data-fiche="1"]');
|
||
return m.racine;
|
||
},
|
||
},
|
||
{
|
||
nom: 'participants.etiquettes',
|
||
mode: 'ecriture',
|
||
async atteindre(contexte) {
|
||
// Deux personnes étiquetées, puis la fiche d'une troisième, où
|
||
// « FINANCE » tapé au clavier s'enregistre sous l'orthographe que
|
||
// l'événement porte : les pastilles neutres et leur croix de retrait, le
|
||
// signal d'orthographe contre le champ, et la légende des étiquettes
|
||
// sous celle des appartenances.
|
||
let charge = chargePrincipale();
|
||
charge = executer(charge, 'changerEtiquettes', { id: 1, ajouter: 'finance, Montréal' });
|
||
charge = executer(charge, 'changerEtiquettes', { id: 2, ajouter: 'bénévole' });
|
||
const m = await ouvrirEvenement(contexte, charge, { ecriture: true, section: 'participants' });
|
||
await cliquer(m, '[data-participant="3"] [data-commande="fiche"]');
|
||
const champ = await element(m, '[data-fiche="3"] [data-champ="etiquettes"]');
|
||
champ.focus();
|
||
for (const caractere of 'FINANCE') await touche(caractere);
|
||
await touche('Enter');
|
||
await element(m, '[data-fiche="3"] [data-etiquettes] [data-avertissement="ORTHOGRAPHE_RETENUE"]');
|
||
await element(m, '[data-legende-etiquettes]');
|
||
return m.racine;
|
||
},
|
||
},
|
||
{
|
||
nom: 'participants.groupes',
|
||
mode: 'ecriture',
|
||
async atteindre(contexte) {
|
||
// La liste groupée par appartenance, choisie au clavier dans le
|
||
// sélecteur, puis le premier groupe replié.
|
||
const m = await ouvrirEvenement(contexte, chargePrincipale(), { ecriture: true, section: 'participants' });
|
||
const choix = await element(m, '[data-section="participants"] [data-champ="grouper"]');
|
||
choix.focus();
|
||
for (let essai = 0; essai < choix.options.length && choix.value !== 'appartenance'; essai += 1) {
|
||
await touche('ArrowDown');
|
||
flushSync();
|
||
}
|
||
await element(m, '[data-groupe] [data-commande="basculerGroupe"]');
|
||
const bouton = await cliquer(m, '[data-groupe] [data-commande="basculerGroupe"]');
|
||
await attendre('un groupe replié', () => bouton.getAttribute('aria-expanded') === 'false' && m.racine.querySelectorAll('[data-groupe]').length > 1);
|
||
return m.racine;
|
||
},
|
||
},
|
||
{
|
||
nom: 'participants.conflits',
|
||
mode: 'ecriture',
|
||
async atteindre(contexte) {
|
||
// La liste groupée par appartenance, choisie au clavier, sous la
|
||
// proposition montrée au plan : un groupe en conflit porte sa marque,
|
||
// ses lignes de conflit et celles de ses personnes.
|
||
const m = await ouvrirEvenement(contexte, chargePrincipale(), { ecriture: true, section: 'participants' });
|
||
const choix = await element(m, '[data-section="participants"] [data-champ="grouper"]');
|
||
choix.focus();
|
||
for (let essai = 0; essai < choix.options.length && choix.value !== 'appartenance'; essai += 1) {
|
||
await touche('ArrowDown');
|
||
flushSync();
|
||
}
|
||
await element(m, '[data-groupe] [data-groupe-conflits]');
|
||
await element(m, '[data-conflits] [data-conflit]');
|
||
await element(m, '[data-participant] [data-conflit-personne]');
|
||
return m.racine;
|
||
},
|
||
},
|
||
{
|
||
nom: 'import.apercu',
|
||
mode: 'ecriture',
|
||
async atteindre(contexte) {
|
||
const m = await ouvrirEvenement(contexte, chargePrincipale(), { ecriture: true, section: 'import' });
|
||
const octets = await octetsCasLimites();
|
||
m.fs.choisirFichierAImporter = async () => ({ nom: NOM_CAS_LIMITES, octets });
|
||
await cliquer(m, '[data-commande="choisirFichier"]');
|
||
await element(m, '[data-apercu] [data-refusees]');
|
||
return m.racine;
|
||
},
|
||
},
|
||
{
|
||
nom: 'import.rapport',
|
||
mode: 'ecriture',
|
||
async atteindre(contexte) {
|
||
const m = await ouvrirEvenement(contexte, chargePrincipale(), { ecriture: true, section: 'import' });
|
||
const octets = await octetsCasLimites();
|
||
m.fs.choisirFichierAImporter = async () => ({ nom: NOM_CAS_LIMITES, octets });
|
||
await cliquer(m, '[data-commande="choisirFichier"]');
|
||
await cliquer(m, '[data-apercu] [data-commande="importer"]');
|
||
await attendre('le rapport, ou la confirmation du remplacement', () =>
|
||
m.racine.querySelector('[data-rapport]') ?? m.racine.querySelector('dialog[open]'),
|
||
);
|
||
if (m.racine.querySelector('[data-rapport]') === null) await cliquer(m, 'dialog[open] [data-commande="confirmer"]');
|
||
await element(m, '[data-rapport]');
|
||
return m.racine;
|
||
},
|
||
},
|
||
{
|
||
nom: 'participants.confirmer',
|
||
mode: 'ecriture',
|
||
async atteindre(contexte) {
|
||
// Supprimer une personne qui tient une réservation se confirme dans
|
||
// une fenêtre, qui nomme la réservation.
|
||
const m = await ouvrirEvenement(contexte, chargePrincipale(), { ecriture: true, section: 'participants' });
|
||
await cliquer(m, '[data-participant="1"] [data-commande="supprimerParticipant"]');
|
||
await element(m, 'dialog[open]');
|
||
return m.racine;
|
||
},
|
||
},
|
||
{
|
||
nom: 'tables.objectifs',
|
||
mode: 'ecriture',
|
||
async atteindre(contexte) {
|
||
// La règle par défaut dans la carte des réglages ; la table 1 vise
|
||
// « finance » pour la soirée sous sa règle propre, et au tour 2 en
|
||
// suivant le défaut, ses objectifs par tour dépliés ; la table 2 vise
|
||
// « cuisine », que personne ne porte : ses avis contre l'objectif.
|
||
let charge = chargePrincipale();
|
||
charge = executer(charge, 'changerEtiquettes', { id: 1, ajouter: 'finance' });
|
||
charge = executer(charge, 'poserObjectif', { table: 1, tour: null, etiquettes: 'finance', regle: 'ou' });
|
||
charge = executer(charge, 'poserObjectif', { table: 1, tour: 2, etiquettes: 'finance', regle: null });
|
||
charge = executer(charge, 'poserObjectif', { table: 2, tour: null, etiquettes: 'cuisine', regle: null });
|
||
const m = await ouvrirEvenement(contexte, charge, { ecriture: true, section: 'tables' });
|
||
await element(m, '[data-regle-objectifs]');
|
||
await cliquer(m, '[data-section="tables"] [data-table="1"] [data-commande="basculerObjectifsTours"]');
|
||
await element(m, '[data-objectif="1-2"][data-portee="tour"]');
|
||
await element(m, '[data-objectif="2"] [data-avertissement="ETIQUETTE_ORPHELINE"]');
|
||
return m.racine;
|
||
},
|
||
},
|
||
{
|
||
nom: 'evenement.avertissements',
|
||
mode: 'lecture',
|
||
async atteindre(contexte) {
|
||
const m = await ouvrirEvenement(contexte, chargeEnDerive(), { section: 'plan' });
|
||
await element(m, '[data-ecran="evenement"] [data-avertissement]');
|
||
return m.racine;
|
||
},
|
||
},
|
||
{
|
||
nom: 'generation.enCours',
|
||
mode: 'ecriture',
|
||
async atteindre(contexte) {
|
||
const m = await ouvrirEvenement(contexte, chargePrincipale(), {
|
||
ecriture: true,
|
||
section: 'generation',
|
||
options: { executeur: executeurSuspendu() },
|
||
});
|
||
await cliquer(m, '[data-section="generation"] [data-commande="generer"]');
|
||
await attendre('la génération lancée, ou sa question', () =>
|
||
m.racine.querySelector('[data-avancement]') ?? m.racine.querySelector('[data-question="longue"]'),
|
||
);
|
||
if (m.racine.querySelector('[data-avancement]') === null) await cliquer(m, '[data-commande="confirmerGeneration"]');
|
||
await element(m, '[data-avancement]');
|
||
return m.racine;
|
||
},
|
||
},
|
||
{
|
||
nom: 'generation.priorites',
|
||
mode: 'ecriture',
|
||
async atteindre(contexte) {
|
||
// L'éditeur des priorités sur un ordre réglé — l'excédent et les
|
||
// collisions cumulées au même palier, chacun son poids —, puis la
|
||
// redondance jointe aux animateurs : l'annonce du geste paraît.
|
||
const paliers = listeApres(LISTE_PAR_DEFAUT, 'joindrePalier', 'collisionsCumulees');
|
||
const charge = executer(chargePrincipale(), 'changerPriorites', { paliers });
|
||
const m = await ouvrirEvenement(contexte, charge, { ecriture: true, section: 'generation' });
|
||
await cliquer(m, '[data-section="generation"] [data-priorite="redondance"] [data-commande="joindrePalier"]');
|
||
await attendre("l'annonce du geste", () => {
|
||
const annonce = m.racine.querySelector('[data-section="generation"] [data-priorites] ~ [role="status"]');
|
||
return (annonce?.textContent.trim() ?? '') !== '';
|
||
});
|
||
return m.racine;
|
||
},
|
||
},
|
||
{
|
||
nom: 'generation.modeObjectifs',
|
||
mode: 'ecriture',
|
||
async atteindre(contexte) {
|
||
// Les tours hors objectif partagent un palier : choisir « l'équité
|
||
// d'abord » se refuse contre le groupe du mode, le choix revenu.
|
||
const paliers = listeApres(LISTE_PAR_DEFAUT, 'joindrePalier', 'toursHorsObjectif');
|
||
const charge = executer(chargePrincipale(), 'changerPriorites', { paliers });
|
||
const m = await ouvrirEvenement(contexte, charge, { ecriture: true, section: 'generation' });
|
||
await cliquer(m, '[data-section="generation"] [data-mode-objectifs] input[value="minimum"]');
|
||
await element(m, '[data-mode-objectifs] [data-refus="OBJECTIFS_PALIER_PARTAGE"]');
|
||
return m.racine;
|
||
},
|
||
},
|
||
{
|
||
nom: 'propositions.critereSurvole',
|
||
mode: 'lecture',
|
||
async atteindre(contexte) {
|
||
// Le pointeur réel sur un nom de critère de la phrase de l'ordre : la
|
||
// colonne qu'il désigne se surligne, en-tête souligné.
|
||
const m = await ouvrirEvenement(contexte, chargePrincipale(), { section: 'propositions' });
|
||
const { x, y } = await centre(await element(m, '[data-ordre] [data-critere="excedentCollisions"]'));
|
||
await pointer('bouger', x, y);
|
||
await attendre('la colonne surlignée', () =>
|
||
getComputedStyle(m.racine.querySelector('thead th[data-colonne="excedentCollisions"]')).textDecorationLine.includes('underline'),
|
||
);
|
||
return m.racine;
|
||
},
|
||
},
|
||
{
|
||
nom: 'propositions.infobulleColonne',
|
||
mode: 'lecture',
|
||
async atteindre(contexte) {
|
||
// Le pointeur réel sur un en-tête de colonne : l'infobulle qui
|
||
// l'explique paraît, visible, de largeur non nulle.
|
||
const m = await ouvrirEvenement(contexte, chargePrincipale(), { section: 'propositions' });
|
||
const { x, y } = await centre(await element(m, 'thead th[data-colonne="excedentCollisions"]'));
|
||
await pointer('bouger', x, y);
|
||
await attendre("l'infobulle de la colonne", () => {
|
||
const bulle = m.racine.querySelector('[data-section="propositions"] [role="tooltip"]');
|
||
return (
|
||
bulle !== null &&
|
||
!bulle.hidden &&
|
||
bulle.getBoundingClientRect().width > 0 &&
|
||
getComputedStyle(bulle).visibility === 'visible'
|
||
);
|
||
});
|
||
return m.racine;
|
||
},
|
||
},
|
||
{
|
||
nom: 'qualite.infobulle',
|
||
mode: 'lecture',
|
||
async atteindre(contexte) {
|
||
// Le pointeur réel au centre de la surface du profil en comptes,
|
||
// amenée au milieu de la fenêtre comme centre() amène toute cible : le
|
||
// rang pointé se désigne, et l'infobulle porte sa ligne du jumeau, son
|
||
// pourcentage suivi du compte de la ligne. Rien ne la place à l'écart
|
||
// du bandeau collant : l'infobulle elle-même reste dans la zone où
|
||
// défile le graphique.
|
||
const m = await ouvrirQualite(contexte, chargePrincipale());
|
||
const { x, y } = await centre(await element(m, '[data-graphique="profilComptes"] > rect[pointer-events="all"]'));
|
||
await pointer('bouger', x, y);
|
||
await attendre("l'infobulle du profil", () => {
|
||
const bulle = m.racine.querySelector('[data-vue-qualite="profil"] [role="tooltip"]');
|
||
return (
|
||
bulle !== null &&
|
||
!bulle.hidden &&
|
||
getComputedStyle(bulle).visibility === 'visible' &&
|
||
bulle.querySelector('[data-pourcentage]') !== null &&
|
||
m.racine.querySelector('[data-graphique="profilComptes"] [data-rang]') !== null
|
||
);
|
||
});
|
||
return m.racine;
|
||
},
|
||
},
|
||
{
|
||
nom: 'qualite.jumeaux',
|
||
mode: 'lecture',
|
||
async atteindre(contexte) {
|
||
// La principale : chaque jumeau de la page ouvert par sa commande. Les
|
||
// écrans des objectifs et de la performance ouvrent les leurs.
|
||
const m = await ouvrirQualite(contexte, chargePrincipale());
|
||
await ouvrirJumeaux(m, '[data-section="qualite"]');
|
||
return m.racine;
|
||
},
|
||
},
|
||
{
|
||
nom: 'qualite.jumeaux.lecture',
|
||
mode: 'lecture',
|
||
atteindre: (contexte) => jumeauSansEcriture(contexte, chargePrincipale()),
|
||
},
|
||
{
|
||
nom: 'qualite.jumeaux.bloque',
|
||
mode: 'bloque',
|
||
atteindre: (contexte) => jumeauSansEcriture(contexte, chargeBloquee()),
|
||
},
|
||
{
|
||
nom: 'qualite.paires',
|
||
mode: 'lecture',
|
||
async atteindre(contexte) {
|
||
// 12 / 4 × 3 / 6 : les paires choisies et imposées, et la matrice
|
||
// dessinée sous le seuil.
|
||
const m = await ouvrirQualite(contexte, chargePaires());
|
||
await element(m, '[data-vue-qualite="paires"] [data-paire]');
|
||
await element(m, '[data-graphique="matrice"] [data-case]');
|
||
return m.racine;
|
||
},
|
||
},
|
||
{
|
||
nom: 'qualite.objectifs',
|
||
mode: 'lecture',
|
||
async atteindre(contexte) {
|
||
// La charge à objectifs, chaque jumeau de la page ouvert : celui des
|
||
// objectifs porte la ligne de la soirée et celle de chaque table.
|
||
const m = await ouvrirQualite(contexte, chargeObjectifs());
|
||
await element(m, '[data-graphique="objectifs"]');
|
||
await ouvrirJumeaux(m, '[data-section="qualite"]');
|
||
return m.racine;
|
||
},
|
||
},
|
||
{
|
||
nom: 'qualite.performance',
|
||
mode: 'lecture',
|
||
async atteindre(contexte) {
|
||
// La principale et le compagnon des courses que sa génération rend :
|
||
// la ligne de tête de la course et ses points de relevé, chaque
|
||
// jumeau de la page ouvert, celui de la performance compris.
|
||
const { charge, courses } = generationPrincipale();
|
||
const m = await ouvrirQualite(contexte, charge, { options: { fichiers: [fichierCourses(charge, courses)] } });
|
||
await element(m, '[data-vue-qualite="performance"] [data-course]');
|
||
await element(m, '[data-vue-qualite="performance"] [data-point-releve]');
|
||
await ouvrirJumeaux(m, '[data-section="qualite"]');
|
||
return m.racine;
|
||
},
|
||
},
|
||
{
|
||
nom: 'qualite.collisions.etroit',
|
||
mode: 'lecture',
|
||
async atteindre(contexte) {
|
||
// chargeCollisionsEtroites : la barre de la seconde appartenance
|
||
// porte un segment « prouvé inévitable » étroit, à côté de son reste.
|
||
const m = await ouvrirQualite(contexte, chargeCollisionsEtroites());
|
||
await element(m, '[data-graphique="collisions"] [data-segment="prouve"]');
|
||
await element(m, '[data-graphique="collisions"] [data-segment="reste"]');
|
||
return m.racine;
|
||
},
|
||
},
|
||
{
|
||
nom: 'qualite.niveaux',
|
||
mode: 'lecture',
|
||
async atteindre(contexte) {
|
||
// La grille des tables, position « sièges », et la matrice portent
|
||
// chacune les quatre niveaux de l'échelle.
|
||
const m = await ouvrirQualite(contexte, chargeNiveaux());
|
||
await element(m, '[data-graphique="tables"] [data-case]');
|
||
await element(m, '[data-graphique="matrice"] [data-case]');
|
||
return m.racine;
|
||
},
|
||
},
|
||
{
|
||
nom: 'qualite.tables.assis',
|
||
mode: 'lecture',
|
||
atteindre: (contexte) => tablesSurPosition(contexte, 'assis'),
|
||
},
|
||
{
|
||
nom: 'qualite.tables.doublons',
|
||
mode: 'lecture',
|
||
atteindre: (contexte) => tablesSurPosition(contexte, 'doublons'),
|
||
},
|
||
{
|
||
nom: 'qualite.sansCourse',
|
||
mode: 'lecture',
|
||
async atteindre(contexte) {
|
||
// La principale sans compagnon des courses : la phrase de la
|
||
// performance.
|
||
const m = await ouvrirQualite(contexte, chargePrincipale());
|
||
await element(m, '[data-vue-qualite="performance"] [data-phrase-qualite="sansCourse"]');
|
||
return m.racine;
|
||
},
|
||
},
|
||
{
|
||
nom: 'qualite.refus',
|
||
mode: 'lecture',
|
||
async atteindre(contexte) {
|
||
// chargeRefusDessin : la garde du détail retire le dessin, la phrase
|
||
// du refus et chaque contradiction nommée.
|
||
const m = await ouvrirEvenement(contexte, chargeRefusDessin(), { section: 'qualite' });
|
||
await element(m, '[data-section="qualite"] [data-phrase-qualite="refusDessin"]');
|
||
return m.racine;
|
||
},
|
||
},
|
||
{
|
||
nom: 'apercu',
|
||
mode: 'lecture',
|
||
async atteindre(contexte) {
|
||
// L'aperçu avant impression du plan de la principale, sa proposition
|
||
// montrée : un brouillon, chaque feuille sous son filigrane.
|
||
const m = await ouvrirApercu(contexte, chargePrincipale(), { section: 'participants' });
|
||
await element(m, '[data-impression] [data-filigrane]');
|
||
return m.racine;
|
||
},
|
||
},
|
||
{
|
||
nom: 'refus.modeLecture',
|
||
mode: 'lecture',
|
||
async atteindre(contexte) {
|
||
const m = await ouvrirEvenement(contexte, chargePrincipale(), { section: 'participants' });
|
||
await cliquer(m, '[data-etat="brouillon"] [data-commande="etat"]');
|
||
await element(m, '[data-refus="MODE_LECTURE"]');
|
||
return m.racine;
|
||
},
|
||
},
|
||
{
|
||
nom: 'refus.bloque',
|
||
mode: 'bloque',
|
||
async atteindre(contexte) {
|
||
const m = await ouvrirEvenement(contexte, chargeBloquee(), { section: 'participants' });
|
||
await cliquer(m, '[data-etat="propose"] [data-commande="etat"]');
|
||
await element(m, '[data-refus="LECTURE_SEULE"]');
|
||
return m.racine;
|
||
},
|
||
},
|
||
{
|
||
nom: 'refus.verrou',
|
||
mode: 'lecture',
|
||
async atteindre(contexte) {
|
||
const premier = await ouvrirEvenement(contexte, chargePrincipale(), { ecriture: true, section: 'participants' });
|
||
premier.racine.hidden = true;
|
||
const second = await contexte.monter({ systeme: { disque: premier.disque } });
|
||
await cliquer(second, `[data-evenement="${BASE}"] [data-commande="ouvrir"]`);
|
||
await element(second, '[data-ecran="evenement"]');
|
||
await cliquer(second, '[data-place-fixe] [data-commande="modifier"]');
|
||
await element(second, '[data-refus="VERROU_PRIS"]');
|
||
return second.racine;
|
||
},
|
||
},
|
||
]);
|