A tile now writes the floor the diagnostic proves under its number, and says when the measure reaches it, so an operator stops relaunching toward a value no placement can beat; no floor is written outside the floors' domain. A proved bar segment keeps its 6 px outline without pushing the bar past its value. The matrix and label thresholds adopt their measured values (44 people, 168 px). The spec and the changelog describe the quality page as the code builds it. Checked: node 5130, browser 1146, long 128; captures stable on two passes. --- FR --- [IMP] page qualité : planchers des tuiles, seuils mesurés, spec intégrée Une tuile écrit sous son chiffre le plancher que prouve le diagnostic, et dit quand la mesure l'atteint : l'opérateur cesse de relancer vers une valeur qu'aucun placement ne bat ; hors du domaine des planchers, rien ne s'écrit. Un segment prouvé garde son contour de 6 px sans pousser la barre au-delà de sa valeur. Les seuils de la matrice et des libellés prennent leurs valeurs mesurées (44 personnes, 168 px). La spec et le journal des versions décrivent la page de qualité telle que le code la construit. Vérifié : node 5130, navigateur 1146, longues 128 ; captures stables, 2 passes. Assisted-by: Claude Opus 5.5
1286 lines
55 KiB
JavaScript
1286 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, trois tables de quatre et une de cinq, 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).
|
||
* La table 4 garde un siège vide à chaque tour : ses sièges et ses assis
|
||
* diffèrent, et leurs deux pourcentages aussi aux tours 1 et 2. */
|
||
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 });
|
||
charge.tables[3].sieges = 5;
|
||
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, et son jumeau ouvert : chaque
|
||
// ligne y écrit les trois pourcentages, chacun suivi du compte de sa ligne.
|
||
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);
|
||
await ouvrirJumeaux(m, '[data-vue-qualite="tables"]');
|
||
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;
|
||
},
|
||
},
|
||
]);
|