314 lines
15 KiB
JavaScript
314 lines
15 KiB
JavaScript
|
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
|||
|
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
|||
|
|
|
|||
|
|
// La rubrique « Rencontres » de la fiche dans un vrai moteur de rendu
|
|||
|
|
// (§ 12.5, B8, B14, D33) : la ligne de la personne dans le placement que le
|
|||
|
|
// plan montre, sous la ligne de source, les plus revues d'abord, égale à sa
|
|||
|
|
// ligne de la matrice de la page de qualité ; une phrase pour une source qui
|
|||
|
|
// ne se mesure pas et pour une personne exclue ; absente sans placement,
|
|||
|
|
// sans phrase ; vivante en lecture et sur un plan bloqué. Chaque épreuve
|
|||
|
|
// monte App par monterApp, au cadre de 1 366 × 700, ouvre la section des
|
|||
|
|
// participants et la fiche, et lit le rendu par le texte et les attributs
|
|||
|
|
// data-…, jamais par une classe ; la dernière monte la fiche seule, sur
|
|||
|
|
// une séance en lecture simulée, et change la proposition demandée au
|
|||
|
|
// plan. Les noms d'épreuve sont inventés.
|
|||
|
|
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
|
|||
|
|
import { flushSync, mount, tick, unmount } from 'svelte';
|
|||
|
|
import { writable } from 'svelte/store';
|
|||
|
|
import { appliquerCommande } from '../../application/commandes.js';
|
|||
|
|
import { rencontresDePersonne } from '../../application/evaluation.js';
|
|||
|
|
import { TABLES_APPLICATION } from '../../application/libelles.js';
|
|||
|
|
import { creerTraduction } from '../../application/traduction.js';
|
|||
|
|
import { rechercher } from '../../moteur/recherche.js';
|
|||
|
|
import { serialiser } from '../../stockage/canonique.js';
|
|||
|
|
import { configurationDepuisCharge, creerCharge } from '../../stockage/document.js';
|
|||
|
|
import { SUFFIXES } from '../../stockage/noms.js';
|
|||
|
|
import { VERSION } from '../../version.genere.js';
|
|||
|
|
import { fixerCadre } from '../../../test/navigateur/entrees.js';
|
|||
|
|
import { monterApp } from '../../../test/navigateur/montage.js';
|
|||
|
|
import { creerEtatUi } from '../etat_ui.js';
|
|||
|
|
import { TABLES_INTERFACE } from '../libelles.js';
|
|||
|
|
import Fiche from './Fiche.svelte';
|
|||
|
|
|
|||
|
|
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
|
|||
|
|
const V1 = VERSION.affichee;
|
|||
|
|
const BASE = 'soiree';
|
|||
|
|
|
|||
|
|
// Douze personnes inventées, quatre tables de trois numérotées 11 à 14, six
|
|||
|
|
// tours ; les deux premières réservées ensemble à la table 11 à chaque tour.
|
|||
|
|
const NOMS = [
|
|||
|
|
['Ombrelle', 'Iris'],
|
|||
|
|
['Pervenche', 'Théo'],
|
|||
|
|
['Lacasse', 'Ondine'],
|
|||
|
|
['Tourteau', 'Basile'],
|
|||
|
|
['Quenouille', 'Maëlle'],
|
|||
|
|
['Varech', 'Sidonie'],
|
|||
|
|
['Bruyère', 'Aurèle'],
|
|||
|
|
['Embrun', 'Clovis'],
|
|||
|
|
['Sablière', 'Noé'],
|
|||
|
|
['Goémon', 'Perrine'],
|
|||
|
|
['Ajonc', 'Tancrède'],
|
|||
|
|
['Fougeraie', 'Ysaline'],
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
function chargeDouze() {
|
|||
|
|
const charge = creerCharge({ id: 'evt-fiche', nom: 'Atelier Ressac', siegesParDefaut: 3, tours: 6 });
|
|||
|
|
charge.participants = NOMS.map(([nom, prenom], rang) => ({
|
|||
|
|
id: rang + 1,
|
|||
|
|
nom,
|
|||
|
|
prenom,
|
|||
|
|
appartenance: rang % 2 === 0 ? 'Coopérative Aubier' : 'Atelier Ressac',
|
|||
|
|
courriel: null,
|
|||
|
|
titrePressenti: null,
|
|||
|
|
notes: null,
|
|||
|
|
exclu: false,
|
|||
|
|
}));
|
|||
|
|
charge.tables = [1, 2, 3, 4].map((id) => ({ id, numero: 10 + id, sieges: null, forme: 'ronde', position: { x: 300 * id, y: 0 } }));
|
|||
|
|
charge.reservations = [1, 2].map((participant) => ({ participant, table: 1, siege: null, portee: 'tous', tour: null }));
|
|||
|
|
charge.prochainsIds = { participant: 13, table: 5, proposition: 1 };
|
|||
|
|
return charge;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function generee(charge) {
|
|||
|
|
const configuration = configurationDepuisCharge(charge);
|
|||
|
|
const propositions = rechercher(configuration, { graine: 5, arret: 60, nombre: 2 });
|
|||
|
|
return appliquerCommande('enregistrerGeneration', charge, { propositions, configuration, produitVersion: V1 }).charge;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const executer = (charge, nom, args) => appliquerCommande(nom, charge, args).charge;
|
|||
|
|
|
|||
|
|
let montes = [];
|
|||
|
|
beforeEach(async () => {
|
|||
|
|
await fixerCadre(1366, 700);
|
|||
|
|
});
|
|||
|
|
afterEach(() => {
|
|||
|
|
const erreurs = montes.flatMap((m) => m.erreurs());
|
|||
|
|
for (const m of montes.reverse()) m.demonter();
|
|||
|
|
montes = [];
|
|||
|
|
window.scrollTo(0, 0);
|
|||
|
|
expect(erreurs, 'erreurs reçues par le collecteur').toEqual([]);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
async function attendre(assertion) {
|
|||
|
|
for (let essai = 0; ; essai += 1) {
|
|||
|
|
flushSync();
|
|||
|
|
try {
|
|||
|
|
return assertion();
|
|||
|
|
} catch (erreur) {
|
|||
|
|
if (essai >= 200) throw erreur;
|
|||
|
|
await new Promise((resoudre) => requestAnimationFrame(resoudre));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// La charge posée en fichier d'état, ouverte — en écriture sauf mode
|
|||
|
|
// 'lecture' —, la proposition montrer demandée au plan, la section des
|
|||
|
|
// participants et la fiche de la personne ouvertes.
|
|||
|
|
async function ouvrirFiche(charge, personne, { mode = 'ecriture', montrer = null } = {}) {
|
|||
|
|
const texte = serialiser(charge, { revision: 1, produitVersion: V1 });
|
|||
|
|
const m = await monterApp({ fichiers: [{ racine: 'documents', chemin: `${BASE}${SUFFIXES.etat}`, texte }] });
|
|||
|
|
montes.push(m);
|
|||
|
|
await m.seance.ouvrir(BASE);
|
|||
|
|
if (mode === 'ecriture') await m.seance.passerEnEcriture();
|
|||
|
|
if (montrer !== null) m.ui.montrerProposition(montrer);
|
|||
|
|
m.ui.allerA('participants');
|
|||
|
|
m.ui.ouvrirFiche(personne);
|
|||
|
|
flushSync();
|
|||
|
|
const fiche = await attendre(() => {
|
|||
|
|
const trouvee = m.racine.querySelector(`[data-fiche="${personne}"]`);
|
|||
|
|
expect(trouvee).not.toBeNull();
|
|||
|
|
return trouvee;
|
|||
|
|
});
|
|||
|
|
return { m, fiche };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const texteDe = (element) => element.textContent.replace(/\s+/g, ' ').trim();
|
|||
|
|
const rubrique = (fiche) => fiche.querySelector('[data-rubrique-rencontres]');
|
|||
|
|
|
|||
|
|
// Les lignes de la rubrique : [nom, fois, tours], le nom lu dans le premier
|
|||
|
|
// élément de l'intitulé de ligne, le reste dans le texte de chaque cellule.
|
|||
|
|
const lignesDe = (section) =>
|
|||
|
|
[...section.querySelectorAll('tbody tr')].map((ligne) => {
|
|||
|
|
const [intitule, ...cellules] = ligne.querySelectorAll('th, td');
|
|||
|
|
return [texteDe(intitule.firstElementChild), ...cellules.map(texteDe)];
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Ce que l'intitulé d'une ligne écrit après le nom, un texte par élément :
|
|||
|
|
// l'identifiant d'un homonyme, puis l'appartenance.
|
|||
|
|
const secondairesDe = (ligne) => [...ligne.querySelector('th').children].slice(1).map(texteDe);
|
|||
|
|
|
|||
|
|
describe('la rubrique « Rencontres » de la fiche (§ 12.5, D33)', () => {
|
|||
|
|
const lue = generee(chargeDouze());
|
|||
|
|
const id = lue.propositions[0].id;
|
|||
|
|
|
|||
|
|
test('sous la ligne de source, les personnes rencontrées dans la proposition montrée, les plus revues d’abord, leurs tours et tables écrits', async () => {
|
|||
|
|
const { fiche } = await ouvrirFiche(lue, 1, { montrer: id });
|
|||
|
|
const section = rubrique(fiche);
|
|||
|
|
expect(section).not.toBeNull();
|
|||
|
|
expect(texteDe(section.querySelector('h4'))).toBe(t('participants.fiche.rencontres.titre'));
|
|||
|
|
expect(texteDe(section)).toContain(
|
|||
|
|
`${t('participants.fiche.rencontres.intitule')} ${t('participants.fiche.rencontres.proposition', { proposition: id })}`,
|
|||
|
|
);
|
|||
|
|
const attendues = rencontresDePersonne(lue, id, 1).lignes.map(({ nom, fois, tours }) => [
|
|||
|
|
nom,
|
|||
|
|
String(fois),
|
|||
|
|
tours.map(({ tour, table }) => t('participants.fiche.rencontres.tour', { tour: tour + 1, table: 10 + table })).join(' ; '),
|
|||
|
|
]);
|
|||
|
|
expect(lignesDe(section)).toEqual(attendues);
|
|||
|
|
expect(lignesDe(section)[0].slice(0, 2)).toEqual(['Théo Pervenche', '6']);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('la ligne de la fiche égale celle de la matrice de la page de qualité, pour chaque personne', async () => {
|
|||
|
|
const { m } = await ouvrirFiche(lue, 1, { montrer: id });
|
|||
|
|
m.ui.allerA('qualite');
|
|||
|
|
const grille = await attendre(() => {
|
|||
|
|
const trouvee = m.racine.querySelector('[data-vue-qualite="paires"] [data-graphique="matrice"]');
|
|||
|
|
expect(trouvee?.querySelectorAll('[data-case]')).toHaveLength(144);
|
|||
|
|
return trouvee;
|
|||
|
|
});
|
|||
|
|
// Les intitulés de ligne, « rang. nom », dans l'ordre de la matrice.
|
|||
|
|
const ordre = [...grille.querySelectorAll('text')].map((texte) => texte.textContent).filter((texte) => /^\d+\. /.test(texte));
|
|||
|
|
const nomDe = (intitule) => intitule.replace(/^\d+\. /, '');
|
|||
|
|
const matrice = ordre.map((_, i) =>
|
|||
|
|
ordre
|
|||
|
|
.map((intitule, j) => [nomDe(intitule), texteDe(grille.querySelector(`[data-case="${i}:${j}"]`))])
|
|||
|
|
.filter(([, fois]) => fois !== '' && fois !== '0'),
|
|||
|
|
);
|
|||
|
|
for (const [i, intitule] of ordre.entries()) {
|
|||
|
|
const personne = lue.participants.find(({ nom, prenom }) => `${prenom} ${nom}` === nomDe(intitule)).id;
|
|||
|
|
m.ui.allerA('participants');
|
|||
|
|
m.ui.ouvrirFiche(personne);
|
|||
|
|
const section = await attendre(() => {
|
|||
|
|
const trouvee = rubrique(m.racine.querySelector(`[data-fiche="${personne}"]`));
|
|||
|
|
expect(trouvee).not.toBeNull();
|
|||
|
|
return trouvee;
|
|||
|
|
});
|
|||
|
|
const deLaFiche = lignesDe(section).map(([nom, fois]) => [nom, fois]);
|
|||
|
|
const parNom = (a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0);
|
|||
|
|
expect([...deLaFiche].sort(parNom), intitule).toEqual([...matrice[i]].sort(parNom));
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('sans proposition montrée, le retenu ; vivante en lecture et sur un plan bloqué, sans écriture', async () => {
|
|||
|
|
const retenue = executer(lue, 'retenirProposition', { id: lue.propositions[1].id });
|
|||
|
|
for (const charge of [retenue, executer(retenue, 'changerEtat', { etat: 'bloque' })]) {
|
|||
|
|
const { m, fiche } = await ouvrirFiche(charge, 3, { mode: 'lecture' });
|
|||
|
|
const ecritures = m.trace.length;
|
|||
|
|
const section = rubrique(fiche);
|
|||
|
|
expect(section).not.toBeNull();
|
|||
|
|
expect(texteDe(section)).toContain(`${t('participants.fiche.rencontres.intitule')} ${t('participants.fiche.rencontres.retenu')}`);
|
|||
|
|
expect(lignesDe(section).length).toBe(rencontresDePersonne(charge, 'retenu', 3).lignes.length);
|
|||
|
|
expect(section.querySelector('button, [data-refus]')).toBeNull();
|
|||
|
|
expect(m.trace.length).toBe(ecritures);
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('une proposition demandée au plan l’emporte sur le retenu : la fiche mesure la même source que la page de qualité', async () => {
|
|||
|
|
const retenue = executer(lue, 'retenirProposition', { id: lue.propositions[1].id });
|
|||
|
|
expect(id).not.toBe(lue.propositions[1].id);
|
|||
|
|
const { fiche } = await ouvrirFiche(retenue, 3, { montrer: id });
|
|||
|
|
const section = rubrique(fiche);
|
|||
|
|
expect(texteDe(section)).toContain(
|
|||
|
|
`${t('participants.fiche.rencontres.intitule')} ${t('participants.fiche.rencontres.proposition', { proposition: id })}`,
|
|||
|
|
);
|
|||
|
|
expect(texteDe(section)).not.toContain(t('participants.fiche.rencontres.retenu'));
|
|||
|
|
const attendues = rencontresDePersonne(retenue, id, 3).lignes.map(({ nom, fois }) => [nom, String(fois)]);
|
|||
|
|
expect(lignesDe(section).map(([nom, fois]) => [nom, fois])).toEqual(attendues);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('une personne exclue : la phrase qui le dit, sans ligne ; une source en dérive : la phrase du placement non mesurable', async () => {
|
|||
|
|
const exclue = structuredClone(lue);
|
|||
|
|
exclue.participants[2].exclu = true;
|
|||
|
|
const { fiche } = await ouvrirFiche(exclue, 3, { montrer: id });
|
|||
|
|
expect(texteDe(rubrique(fiche))).toContain(t('participants.fiche.rencontres.exclue'));
|
|||
|
|
expect(rubrique(fiche).querySelector('table')).toBeNull();
|
|||
|
|
const { fiche: autre } = await ouvrirFiche(exclue, 4, { montrer: id });
|
|||
|
|
expect(texteDe(rubrique(autre))).toContain(t('participants.fiche.rencontres.nonMesurable'));
|
|||
|
|
expect(rubrique(autre).querySelector('table')).toBeNull();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('deux homonymes rencontrés : chaque ligne écrit, après le nom, l’identifiant puis l’appartenance, et les deux lignes diffèrent', async () => {
|
|||
|
|
// La personne 2, réservée avec la personne 1, prend son nom : qui
|
|||
|
|
// rencontre l'une rencontre l'autre.
|
|||
|
|
const homonymes = structuredClone(lue);
|
|||
|
|
Object.assign(homonymes.participants[1], { nom: 'Ombrelle', prenom: 'Iris' });
|
|||
|
|
const temoin = [3, 4, 5, 6, 7, 8, 9, 10, 11, 12].find((personne) =>
|
|||
|
|
rencontresDePersonne(homonymes, id, personne).lignes.some((ligne) => ligne.id === 1),
|
|||
|
|
);
|
|||
|
|
const { fiche } = await ouvrirFiche(homonymes, temoin, { montrer: id });
|
|||
|
|
const rangees = [...rubrique(fiche).querySelectorAll('tbody tr')];
|
|||
|
|
const deLaPersonne = (personne) =>
|
|||
|
|
rangees[rencontresDePersonne(homonymes, id, temoin).lignes.findIndex((ligne) => ligne.id === personne)];
|
|||
|
|
const [une, autre] = [1, 2].map(deLaPersonne);
|
|||
|
|
expect(texteDe(une.querySelector('th').firstElementChild)).toBe('Iris Ombrelle');
|
|||
|
|
expect(texteDe(autre.querySelector('th').firstElementChild)).toBe('Iris Ombrelle');
|
|||
|
|
expect(secondairesDe(une)).toEqual([t('qualite.paires.homonyme', { id: 1 }), 'Coopérative Aubier']);
|
|||
|
|
expect(secondairesDe(autre)).toEqual([t('qualite.paires.homonyme', { id: 2 }), 'Atelier Ressac']);
|
|||
|
|
expect(texteDe(une)).not.toBe(texteDe(autre));
|
|||
|
|
// Les autres : le nom, puis l'appartenance seule.
|
|||
|
|
for (const rangee of rangees.filter((r) => r !== une && r !== autre)) {
|
|||
|
|
expect(secondairesDe(rangee)).toHaveLength(1);
|
|||
|
|
expect(secondairesDe(rangee)[0]).not.toMatch(/n°/);
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('sans placement — ni proposition ni retenu —, la rubrique est absente, sans phrase', async () => {
|
|||
|
|
const { fiche } = await ouvrirFiche(chargeDouze(), 1);
|
|||
|
|
expect(rubrique(fiche)).toBeNull();
|
|||
|
|
for (const cle of ['titre', 'nonMesurable', 'exclue', 'aucune']) {
|
|||
|
|
expect(texteDe(fiche)).not.toContain(t(`participants.fiche.rencontres.${cle}`));
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
describe('la rubrique suit le placement que le plan montre (§ 12.5, D25)', () => {
|
|||
|
|
test('la fiche montée seule : une autre proposition demandée au plan change la source et les lignes ; aucune, et sans retenu, la rubrique disparaît', async () => {
|
|||
|
|
const lue = generee(chargeDouze());
|
|||
|
|
const [premiere, seconde] = lue.propositions.map(({ id }) => id);
|
|||
|
|
const etat = { mode: 'lecture', evenement: { etat: 'brouillon', revision: 1 }, charge: lue };
|
|||
|
|
const magasin = writable(etat);
|
|||
|
|
const seance = {
|
|||
|
|
subscribe: magasin.subscribe,
|
|||
|
|
etat: () => etat,
|
|||
|
|
surAnnulation: () => () => {},
|
|||
|
|
debuterGeste() {},
|
|||
|
|
finirGeste() {},
|
|||
|
|
async executer() {
|
|||
|
|
throw new Error('la rubrique n’écrit rien');
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
const ui = creerEtatUi();
|
|||
|
|
ui.montrerProposition(premiere);
|
|||
|
|
const cible = document.createElement('div');
|
|||
|
|
document.body.prepend(cible);
|
|||
|
|
const composant = mount(Fiche, {
|
|||
|
|
target: cible,
|
|||
|
|
props: { participant: lue.participants[4], charge: lue },
|
|||
|
|
context: new Map([
|
|||
|
|
['t', t],
|
|||
|
|
['ui', ui],
|
|||
|
|
['seance', seance],
|
|||
|
|
]),
|
|||
|
|
});
|
|||
|
|
try {
|
|||
|
|
flushSync();
|
|||
|
|
const lignesAttendues = (id) => rencontresDePersonne(lue, id, 5).lignes.map(({ nom, fois }) => [nom, String(fois)]);
|
|||
|
|
const source = (id) => `${t('participants.fiche.rencontres.intitule')} ${t('participants.fiche.rencontres.proposition', { proposition: id })}`;
|
|||
|
|
expect(texteDe(rubrique(cible))).toContain(source(premiere));
|
|||
|
|
expect(lignesDe(rubrique(cible)).map(([nom, fois]) => [nom, fois])).toEqual(lignesAttendues(premiere));
|
|||
|
|
ui.montrerProposition(seconde);
|
|||
|
|
await tick();
|
|||
|
|
flushSync();
|
|||
|
|
expect(texteDe(rubrique(cible))).toContain(source(seconde));
|
|||
|
|
expect(lignesDe(rubrique(cible)).map(([nom, fois]) => [nom, fois])).toEqual(lignesAttendues(seconde));
|
|||
|
|
ui.montrerProposition(null);
|
|||
|
|
await tick();
|
|||
|
|
flushSync();
|
|||
|
|
expect(rubrique(cible)).toBeNull();
|
|||
|
|
} finally {
|
|||
|
|
unmount(composant);
|
|||
|
|
cible.remove();
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
});
|