// © 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 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 { rechercher } from '../../src/moteur/recherche.js'; import { serialiser } from '../../src/stockage/canonique.js'; import { analyser, capacite, configurationDepuisCharge } from '../../src/stockage/document.js'; import { SUFFIXES } from '../../src/stockage/noms.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. */ export const SECTIONS_EVENEMENT = Object.freeze(['import', 'participants', 'tables', 'plan', 'diagnostic', 'generation', 'propositions']); // 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() { 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' }); const configuration = configurationDepuisCharge(charge); const propositions = rechercher(configuration, { graine: 2, nombre: 1, arret: 300, historique: 1000 }); return appliquerCommande('enregistrerGeneration', charge, { propositions, configuration, produitVersion: VERSION.affichee }).charge; } // 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é. 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 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; } // 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 }); /** * 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 === 4), ); return m.racine; }, }, { nom: 'accueil.mesuresRetenu', mode: null, async atteindre(contexte) { // La principale, sa proposition retenue : la ligne montre les deux // mesures du retenu et la meilleure proposition du classement, et le // pointeur réel sur l'intitulé de la différence 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('les mesures du retenu et la meilleure proposition', () => [ ...['collisionsCumulees', 'diffRedondance'].map((colonne) => `[data-colonne="${colonne}"]`), '[data-meilleure-id]', '[data-meilleure-collisions]', ].every((valeur) => /\d/.test(m.racine.querySelector(`${ligne} ${valeur}`)?.textContent ?? '')), ); const { x, y } = await centre(await element(m, `${ligne} [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.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: '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: '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: '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; }, }, ]);