gestion_table_tournante_libre/test/navigateur/ecrans.js

1276 lines
54 KiB
JavaScript
Raw Normal View History

// © 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 { 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 });
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 mesuré et porteur d'une marque au moins.
async function qualiteDessinee(m) {
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;
},
},
]);