[ADD] quality page base: Qualité tab, detail, charts, course companion
The quality page gets its section, its data and its building blocks. The engine details a placement (percentages floored, worst case as a count, three table-mix counts with their orders, per-round floors); a Qualité tab, loaded as its own chunk, follows the proposition the plan shows; charts and their twin tables share one component set. The search course goes to a companion file written only when propositions change, keyed by proposition identity, timestamp and placement fingerprint, so a gesture costs what it did and a stale course never reaches the page. Checked: node 4929, navigateur 1016, node-long 128; main chunk 689 ko. --- FR --- [ADD] base de la qualité : onglet, détail, graphiques, compagnon La page de qualité reçoit sa section, ses données et ses briques. Le moteur détaille un placement (pourcentages arrondis vers le bas, pire cas en compte, trois comptes du mélange par table et leurs ordres, planchers par tour) ; un onglet Qualité, chargé à part, suit la proposition que montre le plan ; graphiques et tableaux jumeaux partagent leurs composants. La course de la recherche va dans un fichier compagnon écrit quand les propositions changent, clé : identité, horodatage, empreinte du placement ; un geste coûte ce qu'il coûtait, aucune course périmée. Vérifié : node 4929, navigateur 1016, longues 128 ; entrée à 689 ko. Assisted-by: Claude Opus 5.5
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 89 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 131 KiB After Width: | Height: | Size: 132 KiB |
|
Before Width: | Height: | Size: 159 KiB After Width: | Height: | Size: 159 KiB |
|
Before Width: | Height: | Size: 153 KiB After Width: | Height: | Size: 153 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 141 KiB After Width: | Height: | Size: 142 KiB |
|
Before Width: | Height: | Size: 135 KiB After Width: | Height: | Size: 135 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 167 KiB After Width: | Height: | Size: 167 KiB |
|
Before Width: | Height: | Size: 158 KiB After Width: | Height: | Size: 159 KiB |
|
|
@ -29,11 +29,12 @@ import { ErreurCsv } from '../csv/erreurs.js';
|
|||
import { appliquerImport, bilanRemplacement } from '../csv/import.js';
|
||||
import { appliquerImportObjectifs } from '../csv/objectifs.js';
|
||||
import { serialiserCharge } from '../stockage/canonique.js';
|
||||
import { entreeDeCourse } from '../stockage/courses.js';
|
||||
import { SCHEMA, clesRangees, creerCharge, etatDeduit, premiereFaute } from '../stockage/document.js';
|
||||
import { ErreurStockage } from '../stockage/erreurs.js';
|
||||
import { ETIQUETTES_MAX } from '../stockage/etiquettes.js';
|
||||
import { estHorodatage } from '../stockage/journal.js';
|
||||
import { fautes, formeNommee, versFichier } from '../stockage/placements.js';
|
||||
import { courseVersFichier, fautes, formeNommee, versFichier } from '../stockage/placements.js';
|
||||
import { COMMANDES_MOBILIER } from './commandes_mobilier.js';
|
||||
import { COMMANDES_PLACES } from './commandes_places.js';
|
||||
import { ErreurCommande, refus } from './erreurs.js';
|
||||
|
|
@ -486,7 +487,16 @@ function rangerParSiege(charge, plan, configuration) {
|
|||
* la proposition d'identifiant k du moteur prend prochainsIds.proposition +
|
||||
* k − 1 —, s'ajoutent aux précédentes, et le compteur passe au-delà de la
|
||||
* dernière. Sièges attribués, chaque liste se range d'abord par siège
|
||||
* (rangerParSiege). Un brouillon passe à proposé.
|
||||
* (rangerParSiege), celles du plan de la proposition et celles du plan de
|
||||
* chacun de ses points de relevé : un placement de relevé a ainsi la forme
|
||||
* d'un placement de proposition, chaises vides et sièges réservés compris.
|
||||
* Un brouillon passe à proposé.
|
||||
*
|
||||
* La course que le moteur rapporte de chaque proposition (§ 5.10) n'entre
|
||||
* pas dans la charge : le rendu la porte à côté, courses, l'entrée du
|
||||
* compagnon des courses de chaque proposition qui en rapporte une, dans
|
||||
* l'ordre des propositions (courseVersFichier, entreeDeCourse). Le dépôt
|
||||
* l'écrit dans le compagnon, après l'état (§ 8.9).
|
||||
*/
|
||||
function enregistrerGeneration(charge, { propositions, configuration, produitVersion }) {
|
||||
exigerModifiable(charge);
|
||||
|
|
@ -498,12 +508,23 @@ function enregistrerGeneration(charge, { propositions, configuration, produitVer
|
|||
}
|
||||
const decalage = charge.prochainsIds.proposition - 1;
|
||||
const options = { produitVersion, attribuerSieges: charge.reglages.attribuerSieges, decalage };
|
||||
let fichiers;
|
||||
const fichiers = [];
|
||||
const courses = [];
|
||||
try {
|
||||
fichiers = propositions.map((proposition) => {
|
||||
const rangee = options.attribuerSieges ? { ...proposition, plan: rangerParSiege(charge, proposition.plan, configuration) } : proposition;
|
||||
return versFichier(rangee, configuration, options);
|
||||
});
|
||||
for (const proposition of propositions) {
|
||||
let rangee = proposition;
|
||||
if (options.attribuerSieges) {
|
||||
const ranger = (plan) => rangerParSiege(charge, plan, configuration);
|
||||
rangee = { ...proposition, plan: ranger(proposition.plan) };
|
||||
if (Array.isArray(proposition.releves)) {
|
||||
rangee.releves = proposition.releves.map((releve) => ({ ...releve, plan: ranger(releve.plan) }));
|
||||
}
|
||||
}
|
||||
const fichier = versFichier(rangee, configuration, options);
|
||||
const course = courseVersFichier(rangee, fichier, options);
|
||||
fichiers.push(fichier);
|
||||
if (course !== null) courses.push(entreeDeCourse(fichier, course));
|
||||
}
|
||||
} catch (erreur) {
|
||||
if (erreur instanceof ErreurStockage && erreur.code === 'COMPTEUR_SATURE') {
|
||||
throw refus('COMPTEUR_SATURE', erreur.details);
|
||||
|
|
@ -521,6 +542,7 @@ function enregistrerGeneration(charge, { propositions, configuration, produitVer
|
|||
charge: suivante,
|
||||
libelle: libelle('ENTREE_ENREGISTRER_GENERATION', { n: fichiers.length, premier, dernier }),
|
||||
avertissements: [],
|
||||
courses,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -582,8 +604,9 @@ export function reunirCommandes(...registres) {
|
|||
/**
|
||||
* Le registre des commandes, par nom : chacune (charge, arguments) →
|
||||
* { charge, libelle, avertissements }, et importerParticipants et
|
||||
* importerObjectifs y ajoutent le résumé de l'import. Il réunit les commandes de ce module, celles du
|
||||
* mobilier et celles des places.
|
||||
* importerObjectifs y ajoutent le résumé de l'import, enregistrerGeneration
|
||||
* les courses de ses propositions. Il réunit les commandes de ce module,
|
||||
* celles du mobilier et celles des places.
|
||||
*
|
||||
* @typedef {{code: string, details: Object}} Avertissement
|
||||
* @typedef {{charge: Object, libelle: string, avertissements: Avertissement[]}} Rendu
|
||||
|
|
@ -623,6 +646,15 @@ function sansHorodatage(retenu) {
|
|||
const VIDE = { participants: [], tables: [], reservations: [], titres: [], propositions: [] };
|
||||
const texteDuPlacement = (charge, retenu) => serialiserCharge({ ...charge, ...VIDE, retenu: sansHorodatage(retenu) });
|
||||
|
||||
// Les entrées de courses d'une génération, refaites sur les propositions de
|
||||
// la charge datée : chacune prend l'horodatage que dater a posé sur sa
|
||||
// proposition, qui entre dans son identité (cleDeCourse). Chaque entrée
|
||||
// est celle d'une proposition de la charge.
|
||||
function daterCourses(charge, courses) {
|
||||
const parId = new Map(charge.propositions.map((p) => [p.id, p]));
|
||||
return courses.map(({ id, course }) => entreeDeCourse(parId.get(id), course));
|
||||
}
|
||||
|
||||
// La charge rendue datée de horodatage (§ 8.9) : le retenu quand retenuNeuf,
|
||||
// et chaque proposition dont l'identifiant manque à la charge reçue. Les
|
||||
// objets datés sont neufs : ni la charge rendue ni la reçue ne changent.
|
||||
|
|
@ -650,10 +682,12 @@ function dater(recue, rendue, horodatage, retenuNeuf) {
|
|||
* datation : un geste sans effet ne le devient pas par son instant. Puis le
|
||||
* retenu qui change, ou qui apparaît, prend horodatage, et chaque proposition
|
||||
* dont l'identifiant manque à la charge reçue aussi ; les autres gardent le
|
||||
* leur. Corriger un nom ne date rien : l'horodatage date le placement, non
|
||||
* la page. horodatage null, le défaut, ne pose ni ne retire rien : un
|
||||
* retenu reconstruit à l'identique garde l'horodatage reçu, et aucun autre
|
||||
* n'est posé.
|
||||
* leur. Les entrées de courses d'une génération (courses) prennent
|
||||
* l'horodatage de leur proposition, qui entre dans leur identité
|
||||
* (cleDeCourse). Corriger un nom ne date rien : l'horodatage date le
|
||||
* placement, non la page. horodatage null, le défaut, ne pose ni ne retire
|
||||
* rien : un retenu reconstruit à l'identique garde l'horodatage reçu, aucun
|
||||
* autre n'est posé, et les entrées de courses restent sans horodatage.
|
||||
*
|
||||
* @param {string} nom
|
||||
* @param {Object} charge
|
||||
|
|
@ -684,8 +718,10 @@ export function appliquerCommande(nom, charge, args = {}, { horodatage = null }
|
|||
suivante = { ...suivante, retenu: recu === undefined ? sansHorodatage(retenu) : { ...sansHorodatage(retenu), horodatage: recu } };
|
||||
}
|
||||
if (serialiserCharge(suivante) === serialiserCharge(charge)) throw refus('SANS_EFFET', { commande: nom });
|
||||
if (horodatage !== null) suivante = dater(charge, suivante, horodatage, retenu !== null && !memePlacement);
|
||||
return { ...rendu, charge: suivante, avertissements: rendu.avertissements ?? [] };
|
||||
if (horodatage === null) return { ...rendu, charge: suivante, avertissements: rendu.avertissements ?? [] };
|
||||
suivante = dater(charge, suivante, horodatage, retenu !== null && !memePlacement);
|
||||
const courses = rendu.courses === undefined ? {} : { courses: daterCourses(suivante, rendu.courses) };
|
||||
return { ...rendu, charge: suivante, ...courses, avertissements: rendu.avertissements ?? [] };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -14,7 +14,8 @@ import { apercevoirObjectifs } from '../csv/objectifs.js';
|
|||
import { rechercher } from '../moteur/recherche.js';
|
||||
import { serialiserCharge } from '../stockage/canonique.js';
|
||||
import { configurationDepuisCharge, creerCharge, etatDeduit } from '../stockage/document.js';
|
||||
import { derive, fautes, formeNommee, planDepuisFichier } from '../stockage/placements.js';
|
||||
import { cleDeCourse } from '../stockage/courses.js';
|
||||
import { courseDepuisFichier, derive, fauteDeCourse, fautes, formeNommee, planDepuisFichier } from '../stockage/placements.js';
|
||||
import { VERSION } from '../version.genere.js';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { chargements } from '../../test/chargements.js';
|
||||
|
|
@ -770,6 +771,120 @@ describe('commandes : générer, retenir, effacer (§ 5.7, § 8.9)', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('commandes : la course de la recherche, rendue à côté de la charge (§ 8.9, B31, D53)', () => {
|
||||
// La charge d'essai sur trois tours, sièges attribués, deux tables de
|
||||
// trois places pour quatre personnes : Iris Ombrelle réservée au siège 3
|
||||
// de la table 1, Ondine Lacasse au siège 3 de la table 2, à chaque tour.
|
||||
// Chaque table assied sa réservée et au plus deux autres, et l'une au
|
||||
// moins reste incomplète : une chaise vide avant le siège 3 à chaque tour.
|
||||
function chargeAuxSieges() {
|
||||
const charge = chargeEssai();
|
||||
charge.evenement.siegesParDefaut = 3;
|
||||
charge.evenement.tours = 3;
|
||||
charge.reglages.attribuerSieges = true;
|
||||
charge.reservations = [
|
||||
{ participant: 1, table: 1, siege: 3, portee: 'tous', tour: null },
|
||||
{ participant: 4, table: 2, siege: 3, portee: 'tous', tour: null },
|
||||
];
|
||||
charge.titres = [];
|
||||
return charge;
|
||||
}
|
||||
|
||||
// Le plan reçu, chaque liste et chaque réserve par identifiant croissant,
|
||||
// sans chaise vide.
|
||||
const range = ({ tables, tours, reserves }) => ({
|
||||
tables: [...tables],
|
||||
tours: tours.map((listes) => listes.map((liste) => liste.filter((id) => id !== null).sort((a, b) => a - b))),
|
||||
reserves: reserves.map((reserve) => [...reserve].sort((a, b) => a - b)),
|
||||
});
|
||||
|
||||
test('enregistrerGeneration rend la course de chaque proposition à côté de la charge, sous son identité ; la charge et ses propositions ne portent aucune course', () => {
|
||||
const avant = chargeEssai();
|
||||
const args = generer(avant, 2, 7);
|
||||
args.propositions = rechercher(args.configuration, { graine: 7, arret: 200, nombre: 2 });
|
||||
const { charge, courses, libelle } = executer('enregistrerGeneration', avant, args);
|
||||
assert.deepEqual(charge.propositions.map(({ id }) => id), [1, 2]);
|
||||
assert.equal(charge.prochainsIds.proposition, 3);
|
||||
assert.equal(libelle, 'Généré 2 propositions (n° 1 à 2)');
|
||||
assert.equal(serialiserCharge(charge).includes('evalues'), false);
|
||||
assert.deepEqual(courses.map(cleDeCourse), charge.propositions.map(cleDeCourse));
|
||||
charge.propositions.forEach((p, rang) => {
|
||||
assert.equal(Object.hasOwn(p, 'course'), false);
|
||||
const { course } = courses[rang];
|
||||
assert.equal(fauteDeCourse(p, course), null);
|
||||
for (const { placement } of course.releves) {
|
||||
for (const { sieges } of placement) {
|
||||
for (const liste of sieges) assert.deepEqual(liste, [...liste].sort((a, b) => a - b));
|
||||
}
|
||||
}
|
||||
assert.deepEqual(courseDepuisFichier(p, course), {
|
||||
evalues: args.propositions[rang].evalues,
|
||||
releves: args.propositions[rang].releves.map(({ mouvements, plan }) => ({ mouvements, plan: range(plan) })),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("appliquée à un instant, la génération date chaque entrée de course comme sa proposition, et la clé de course en tient compte ; sans instant, aucune n'est datée", () => {
|
||||
const avant = chargeEssai();
|
||||
const args = generer(avant, 2, 7);
|
||||
args.propositions = rechercher(args.configuration, { graine: 7, arret: 200, nombre: 2 });
|
||||
const horodatage = '2031-05-17T08:02:00-04:00';
|
||||
const datee = appliquerCommande('enregistrerGeneration', avant, args, { horodatage });
|
||||
assert.deepEqual(datee.charge.propositions.map((p) => p.horodatage), [horodatage, horodatage]);
|
||||
assert.deepEqual(datee.courses.map((entree) => entree.horodatage), [horodatage, horodatage]);
|
||||
assert.deepEqual(datee.courses.map(cleDeCourse), datee.charge.propositions.map(cleDeCourse));
|
||||
const sansInstant = appliquerCommande('enregistrerGeneration', avant, args);
|
||||
assert.equal(sansInstant.courses.some((entree) => Object.hasOwn(entree, 'horodatage')), false);
|
||||
assert.deepEqual(sansInstant.courses.map(cleDeCourse), sansInstant.charge.propositions.map(cleDeCourse));
|
||||
assert.notDeepEqual(sansInstant.courses.map(cleDeCourse), datee.courses.map(cleDeCourse));
|
||||
});
|
||||
|
||||
test('sièges attribués, chaque placement de relevé se range par siège comme celui de la proposition : la chaise vide et les sièges réservés à leur place, et la course se lit sous la proposition', () => {
|
||||
const avant = chargeAuxSieges();
|
||||
const configuration = configurationDepuisCharge(avant);
|
||||
const propositions = rechercher(configuration, { graine: 7, arret: 200, nombre: 2 });
|
||||
const { charge, courses } = executer('enregistrerGeneration', avant, { propositions, configuration, produitVersion: V1 });
|
||||
let chaisesVides = 0;
|
||||
charge.propositions.forEach((p, rang) => {
|
||||
const source = propositions[rang];
|
||||
const { course } = courses[rang];
|
||||
assert.equal(p.siegesAttribues, true);
|
||||
assert.deepEqual(fautes(p), []);
|
||||
assert.equal(fauteDeCourse(p, course), null);
|
||||
assert.equal(course.evalues, source.evalues);
|
||||
assert.deepEqual(course.releves.map(({ mouvements }) => mouvements), [1, 10, 100]);
|
||||
const placements = [p.placement, ...course.releves.map(({ placement }) => placement)];
|
||||
placements.forEach((placement, point) => {
|
||||
placement.forEach(({ sieges }, tour) => {
|
||||
const ici = `proposition ${p.id}, point ${point}, tour ${tour + 1}`;
|
||||
assert.equal(sieges[p.tables.indexOf(1)][2], 1, ici);
|
||||
assert.equal(sieges[p.tables.indexOf(2)][2], 4, ici);
|
||||
for (const liste of sieges) {
|
||||
assert.equal(liste.length, 3, ici);
|
||||
chaisesVides += liste.filter((id) => id === null).length;
|
||||
}
|
||||
});
|
||||
});
|
||||
assert.deepEqual(
|
||||
courseDepuisFichier(p, course).releves.map(({ plan }) => plan),
|
||||
source.releves.map(({ plan }) => range(plan)),
|
||||
);
|
||||
});
|
||||
assert.ok(chaisesVides > 0);
|
||||
});
|
||||
|
||||
test("sans course rapportée par le moteur, aucune course n'est rendue ; retenir et effacer n'en rendent aucune", () => {
|
||||
const args = generer(chargeEssai(), 2);
|
||||
args.propositions = args.propositions.map(({ evalues, releves, ...proposition }) => proposition);
|
||||
const sans = executer('enregistrerGeneration', chargeEssai(), args);
|
||||
assert.deepEqual(sans.courses, []);
|
||||
assert.equal(executer('enregistrerGeneration', chargeEssai(), generer(chargeEssai(), 2)).courses.length, 2);
|
||||
const retenue = executer('retenirProposition', sans.charge, { id: 1 });
|
||||
assert.equal(Object.hasOwn(retenue, 'courses'), false);
|
||||
assert.equal(Object.hasOwn(executer('effacerPropositions', retenue.charge, {}), 'courses'), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("commandes : la charge d'un événement neuf (§ 8.1)", () => {
|
||||
test("chargeNeuve contrôle chaque champ avant de tirer l'identifiant, et convertit la saisie en NFC", () => {
|
||||
let tirages = 0;
|
||||
|
|
|
|||
|
|
@ -81,6 +81,15 @@
|
|||
// Chaque geste lit l'horloge une fois : l'instant de son entrée au journal
|
||||
// date aussi le placement qu'il change (§ 8.9, § 11.8).
|
||||
//
|
||||
// La course de la recherche de chaque proposition (§ 5.10) va au compagnon
|
||||
// des courses, que le dépôt écrit après l'état (courses.js) : la séance
|
||||
// passe au dépôt les courses que la génération rend à côté de la charge, et
|
||||
// courseDe lit celle d'une proposition de l'Ouvert, contrôlée contre elle.
|
||||
// L'Ouvert garde en mémoire les courses de la séance, celles d'une
|
||||
// proposition retirée comprises ; l'événement relu au passage en écriture
|
||||
// les garde aussi, quand il est le même événement : un retour qui rend la
|
||||
// proposition retrouve sa course.
|
||||
//
|
||||
// La planche de badges se prépare, se produit et s'enregistre ailleurs dans
|
||||
// tout mode (planche_seance.js) : elle lit l'événement ouvert sans le verrou
|
||||
// d'écriture, à son tour dans la file des appels, et n'écrit que son PDF
|
||||
|
|
@ -91,6 +100,7 @@
|
|||
// laissé. L'horloge et les minuteries entrent en paramètre : la séance ne lit
|
||||
// ni l'heure ni un aléa, et n'arme aucune minuterie elle-même.
|
||||
import { serialiserCharge } from '../stockage/canonique.js';
|
||||
import { avecCourses, courseDe } from '../stockage/courses.js';
|
||||
import {
|
||||
creer as creerSurDisque,
|
||||
deverrouiller,
|
||||
|
|
@ -727,10 +737,20 @@ export function creerSeance({
|
|||
return { frais: parSource, source: etat.source };
|
||||
}
|
||||
|
||||
// L'Ouvert frais, relu sur le disque, portant aussi les courses en mémoire
|
||||
// de l'Ouvert d'avant quand il s'agit du même événement : celles que le
|
||||
// compagnon relu confirme passent devant. Un autre événement garde les
|
||||
// siennes seules. Chaque course se contrôle encore à sa lecture (courseDe).
|
||||
function avecCoursesDe(frais, avant) {
|
||||
if (frais.charge.evenement.id !== avant.charge.evenement.id) return frais;
|
||||
return { ...frais, courses: avecCourses(avant.courses, [...frais.courses.values()]) };
|
||||
}
|
||||
|
||||
// Relit l'événement de l'Ouvert, le verrou de la séance pris (relire). Ce
|
||||
// que la lecture rend devient l'Ouvert de la séance, journal et jalons
|
||||
// compris ; rend [EVENEMENT_CHANGE {revision}] quand sa révision ou sa
|
||||
// charge diffèrent de celles de l'Ouvert, [] sinon. Une entrée d'une autre
|
||||
// compris, ses courses complétées de celles de l'Ouvert (avecCoursesDe) ;
|
||||
// rend [EVENEMENT_CHANGE {revision}] quand sa révision ou sa charge
|
||||
// diffèrent de celles de l'Ouvert, [] sinon. Une entrée d'une autre
|
||||
// séance change toujours la révision relue : l'état qu'elle a écrit, ou le
|
||||
// dernier instant du journal qu'un .precedent lui cède (relire). Une
|
||||
// lecture refusée rend le verrou — ses avertissements deviennent ceux du
|
||||
|
|
@ -744,7 +764,8 @@ export function creerSeance({
|
|||
etat.avertissementsGeste = await rendreVerrou(ouvert);
|
||||
throw erreur;
|
||||
}
|
||||
const { frais, source } = relu;
|
||||
const { source } = relu;
|
||||
const frais = avecCoursesDe(relu.frais, ouvert);
|
||||
etat.ouvert = frais;
|
||||
etat.source = source;
|
||||
etat.formatLu = formatDe(frais.texteEtat);
|
||||
|
|
@ -851,14 +872,16 @@ export function creerSeance({
|
|||
// le geste : options.horodatage, celui qui a daté la commande, ou une
|
||||
// lecture de l'horloge. Le jalon d'état qu'un refus empêche laisse le geste
|
||||
// enregistré : JALON_NON_ECRIT s'ajoute aux avertissements, et le support
|
||||
// est rompu sous ce même échec. Rend le libellé ; les avertissements
|
||||
// deviennent ceux du dernier geste.
|
||||
async function ecrireGeste(depart, { charge, libelle: texte, avertissements }, options = {}) {
|
||||
// est rompu sous ce même échec. courses, les entrées du compagnon des
|
||||
// courses qu'une génération rend à côté de la charge, vont au dépôt avec
|
||||
// l'entrée. Rend le libellé ; les avertissements deviennent ceux du
|
||||
// dernier geste.
|
||||
async function ecrireGeste(depart, { charge, libelle: texte, avertissements, courses = [] }, options = {}) {
|
||||
const { sens = null, retour = null, jalonAvant = null, horodatage = horloge.horodatage() } = options;
|
||||
const commun = { horodatage, libelleReprise };
|
||||
let ouvert = depart;
|
||||
if (jalonAvant !== null) ouvert = await ecrire(ouvert, (o) => nommerSurDisque(o, { nom: jalonAvant, ...commun }));
|
||||
const entree = { libelle: texte, produitVersion, sens, retour, ...commun };
|
||||
const entree = { libelle: texte, produitVersion, sens, retour, courses, ...commun };
|
||||
ouvert = await ecrire(ouvert, (o) => enregistrer(o, charge, entree));
|
||||
etat.source = null;
|
||||
const suite = [...avertissements];
|
||||
|
|
@ -1492,6 +1515,16 @@ export function creerSeance({
|
|||
* lecture, admise dans tout mode, qui n'écrit rien.
|
||||
*/
|
||||
formeNommee: (cible) => enFile(async () => formeNommeeDe(exigerOuvert().charge, cible)),
|
||||
/**
|
||||
* La course de la recherche de la proposition id de l'événement ouvert
|
||||
* (§ 5.10), en plans par identifiants : { evalues, releves: [{mouvements,
|
||||
* plan}] }. null sans événement, sans proposition id, ou quand sa course
|
||||
* n'est pas gardée : le compagnon des courses absent, illisible ou
|
||||
* contradictoire, une proposition d'une version antérieure, ou effacée
|
||||
* puis rendue après une réouverture. Une lecture, hors de la file : elle
|
||||
* lit l'Ouvert courant sans rien écrire.
|
||||
*/
|
||||
courseDe: (id) => (etat.ouvert === null ? null : courseDe(etat.ouvert, id)),
|
||||
/**
|
||||
* L'annonce de la planche de badges (planche_seance.js), avant de
|
||||
* produire : une lecture, admise dans tout mode, qui n'écrit rien.
|
||||
|
|
|
|||
|
|
@ -46,9 +46,9 @@ const CREATION = "Création de l'événement « Soirée des Lucioles »";
|
|||
const deux = (valeur) => String(valeur).padStart(2, '0');
|
||||
|
||||
// Horloge d'épreuve : une minute de plus à chaque lecture, à partir de 8 h le
|
||||
// 17 mai 2031, à l'heure de l'Est.
|
||||
function creerHorloge() {
|
||||
let n = 0;
|
||||
// 17 mai 2031, à l'heure de l'Est, décalée de debut minutes.
|
||||
function creerHorloge(debut = 0) {
|
||||
let n = debut;
|
||||
return {
|
||||
horodatage() {
|
||||
const minutes = 8 * 60 + n;
|
||||
|
|
@ -3505,3 +3505,207 @@ describe('séance : la planche, câblée dans la file des appels (§ 11.6, § 13
|
|||
assert.equal(essais, 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('séance : la course de la recherche dans son compagnon (§ 5.10, § 8.9, B31)', () => {
|
||||
const COURSES = `${BASE}${SUFFIXES.courses}`;
|
||||
|
||||
// Les identifiants des propositions dont le compagnon sur le disque porte
|
||||
// la course, dans son ordre ; null sans compagnon.
|
||||
const dansLeCompagnon = (fs) => {
|
||||
const texte = fs.contenu('documents', COURSES);
|
||||
return texte === null ? null : JSON.parse(texte).courses.map(({ id }) => id);
|
||||
};
|
||||
|
||||
test("une génération écrit le compagnon, que courseDe lit ; un geste qui garde les propositions ne le réécrit pas ; défaire et refaire la génération le réécrivent, la course gardée par la séance, même relue au passage en écriture", async () => {
|
||||
const fs = creerFichiersSimules();
|
||||
await deposerEssai(fs);
|
||||
const montage = monter({ fs });
|
||||
await ouvrirSansEcrire(montage);
|
||||
const { seance } = montage;
|
||||
await seance.passerEnEcriture();
|
||||
assert.equal(seance.courseDe(1), null);
|
||||
|
||||
const args = generer(seance.charge);
|
||||
await seance.executer('enregistrerGeneration', args);
|
||||
assert.deepEqual(dansLeCompagnon(fs), [1, 2]);
|
||||
assert.equal(seance.courseDe(2).evalues, args.propositions[1].evalues);
|
||||
assert.equal(etatDe(fs).charge.propositions.some((p) => Object.hasOwn(p, 'course')), false);
|
||||
|
||||
const texte = fs.contenu('documents', COURSES);
|
||||
const ecrire = fs.ecrireAtomique;
|
||||
const ecrits = [];
|
||||
fs.ecrireAtomique = (racine, chemin, contenu) => {
|
||||
ecrits.push(chemin);
|
||||
return ecrire(racine, chemin, contenu);
|
||||
};
|
||||
await seance.executer('retenirProposition', { id: 1 });
|
||||
await seance.executer('exclureParticipant', { id: 2 });
|
||||
assert.equal(ecrits.includes(COURSES), false);
|
||||
assert.equal(fs.contenu('documents', COURSES), texte);
|
||||
|
||||
await seance.defaire();
|
||||
await seance.defaire();
|
||||
await seance.defaire();
|
||||
assert.deepEqual(seance.charge.propositions, []);
|
||||
assert.deepEqual(dansLeCompagnon(fs), []);
|
||||
assert.equal(seance.courseDe(1), null);
|
||||
|
||||
await seance.passerEnLecture();
|
||||
await seance.passerEnEcriture();
|
||||
await seance.refaire();
|
||||
assert.deepEqual(dansLeCompagnon(fs), [1, 2]);
|
||||
assert.equal(seance.courseDe(2).evalues, args.propositions[1].evalues);
|
||||
assert.equal(fs.contenu('documents', COURSES), texte);
|
||||
});
|
||||
|
||||
// Le compteur des propositions ne recule jamais sous défaire ni revenir ;
|
||||
// il recule quand l'opérateur reprend un état d'avant la génération, ici
|
||||
// son .precedent. La génération suivante, sous la même graine, porte
|
||||
// alors les mêmes id, graine, arret et historique que celles du journal,
|
||||
// et après un changement qui laisse la déclaration intacte — ici une
|
||||
// appartenance — un autre placement. Le retour à la première génération
|
||||
// ne rend jamais la course de la seconde, et réécrit le compagnon.
|
||||
test("une génération refaite sous la même graine après un changement d'appartenance ne prête jamais sa course à la première ; le retour à la première réécrit le compagnon", async () => {
|
||||
const fs = creerFichiersSimules();
|
||||
await deposerEssai(fs);
|
||||
const montage = monter({ fs });
|
||||
await ouvrirSansEcrire(montage);
|
||||
const { seance } = montage;
|
||||
await seance.passerEnEcriture();
|
||||
await seance.executer('enregistrerGeneration', generer(seance.charge));
|
||||
const revision = seance.ouvert.revision;
|
||||
const premieres = seance.charge.propositions;
|
||||
assert.notEqual(seance.courseDe(1), null);
|
||||
await seance.passerEnLecture();
|
||||
|
||||
await seance.ouvrirSecours(BASE, 'precedent');
|
||||
await seance.passerEnEcriture();
|
||||
assert.deepEqual(seance.charge.propositions, []);
|
||||
await seance.executer('modifierParticipant', { id: 2, champs: { appartenance: 'Société Alpha' } });
|
||||
await seance.executer('enregistrerGeneration', generer(seance.charge));
|
||||
const identite = ({ id, graine, arret, historique }) => [id, graine, arret, historique];
|
||||
assert.deepEqual(seance.charge.propositions.map(identite), premieres.map(identite));
|
||||
assert.notDeepEqual(seance.charge.propositions[0].placement, premieres[0].placement);
|
||||
assert.notEqual(seance.courseDe(1), null);
|
||||
const texteSeconde = fs.contenu('documents', COURSES);
|
||||
|
||||
await seance.revenir(revision);
|
||||
const placements = (propositions) => propositions.map(({ placement }) => placement);
|
||||
assert.deepEqual(placements(seance.charge.propositions), placements(premieres));
|
||||
// La séance ne connaît plus la course de la première génération.
|
||||
assert.equal(seance.courseDe(1), null);
|
||||
assert.notEqual(fs.contenu('documents', COURSES), texteSeconde);
|
||||
assert.deepEqual(dansLeCompagnon(fs), []);
|
||||
|
||||
// Relu par une autre séance, le compagnon ne rend aucune course.
|
||||
await seance.fermer();
|
||||
const relecture = monter({ fs, prefixe: 'relecture' });
|
||||
await ouvrirSansEcrire(relecture);
|
||||
assert.equal(relecture.seance.courseDe(1), null);
|
||||
});
|
||||
|
||||
// Douze personnes, la sixième seule du Club des Merles, trois tables de
|
||||
// quatre, trois tours.
|
||||
function chargeDouze() {
|
||||
const charge = creerCharge({ id: 'evt-lucioles', nom: BASE, siegesParDefaut: 4, tours: 3 });
|
||||
const prenoms = ['Iris', 'Théo', 'Ondine', 'Basile', 'Clémence', 'Gaspard', 'Lison', 'Marius', 'Nora', 'Octave', 'Perle', 'Rémi'];
|
||||
charge.participants = prenoms.map((prenom, i) => personne(i + 1, 'Lucane', prenom, i === 5 ? 'Club des Merles' : null));
|
||||
charge.tables = [1, 2, 3].map((id) => ({ id, numero: id, sieges: null, forme: 'ronde', position: { x: 250 * (id - 1), y: 0 } }));
|
||||
charge.prochainsIds = { participant: 13, table: 4, proposition: 1 };
|
||||
return charge;
|
||||
}
|
||||
|
||||
// Les arguments d'enregistrerGeneration : graine 1, arrêt à 400 mouvements.
|
||||
const generationDouze = (charge) => {
|
||||
const configuration = configurationDepuisCharge(charge);
|
||||
return { propositions: rechercher(configuration, { graine: 1, arret: 400, nombre: 2 }), configuration, produitVersion: V1 };
|
||||
};
|
||||
|
||||
// Une deuxième personne au Club des Merles ne change pas le placement
|
||||
// final de la recherche sous la même graine, mais sa course : la seconde
|
||||
// génération porte alors la même identité de recherche et la même
|
||||
// empreinte que la première. Seul l'horodatage de la génération les
|
||||
// départage.
|
||||
test("une génération refaite sous la même graine qui finit sur le même placement ne prête jamais sa course à la première", async () => {
|
||||
const fs = creerFichiersSimules();
|
||||
await deposerEssai(fs, chargeDouze());
|
||||
const montage = monter({ fs });
|
||||
await ouvrirSansEcrire(montage);
|
||||
const { seance } = montage;
|
||||
await seance.passerEnEcriture();
|
||||
await seance.executer('enregistrerGeneration', generationDouze(seance.charge));
|
||||
const revision = seance.ouvert.revision;
|
||||
const premieres = seance.charge.propositions;
|
||||
const premiereCourse = seance.courseDe(1);
|
||||
assert.notEqual(premiereCourse, null);
|
||||
await seance.passerEnLecture();
|
||||
|
||||
await seance.ouvrirSecours(BASE, 'precedent');
|
||||
await seance.passerEnEcriture();
|
||||
await seance.executer('modifierParticipant', { id: 2, champs: { appartenance: 'Club des Merles' } });
|
||||
await seance.executer('enregistrerGeneration', generationDouze(seance.charge));
|
||||
const [p1] = premieres;
|
||||
const [q1] = seance.charge.propositions;
|
||||
const identite = ({ id, graine, arret, historique, placement }) => ({ id, graine, arret, historique, placement });
|
||||
assert.deepEqual(identite(q1), identite(p1));
|
||||
assert.notEqual(q1.horodatage, p1.horodatage);
|
||||
const secondeCourse = seance.courseDe(1);
|
||||
assert.notDeepEqual(secondeCourse, premiereCourse);
|
||||
|
||||
await seance.revenir(revision);
|
||||
assert.deepEqual(seance.charge.propositions, premieres);
|
||||
// La séance a quitté la course de la première en ouvrant le .precedent,
|
||||
// et celle de la seconde n'est pas la sienne.
|
||||
assert.equal(seance.courseDe(1), null);
|
||||
assert.deepEqual(dansLeCompagnon(fs), []);
|
||||
|
||||
await seance.fermer();
|
||||
const relecture = monter({ fs, prefixe: 'relecture' });
|
||||
await ouvrirSansEcrire(relecture);
|
||||
assert.equal(relecture.seance.courseDe(1), null);
|
||||
});
|
||||
|
||||
// La première séance garde en mémoire la course de sa génération ; une
|
||||
// autre, sur une horloge plus tardive, reprend le .precedent et régénère
|
||||
// sur le même placement. Le passage en écriture de la première relit le
|
||||
// compagnon de la seconde sans perdre sa course.
|
||||
test("la course qu'une séance garde ne cède pas à celle qu'une autre séance écrit pour une génération qui finit sur le même placement", async () => {
|
||||
const fs = creerFichiersSimules();
|
||||
await deposerEssai(fs, chargeDouze());
|
||||
const premiere = monter({ fs });
|
||||
await ouvrirSansEcrire(premiere);
|
||||
await premiere.seance.passerEnEcriture();
|
||||
await premiere.seance.executer('enregistrerGeneration', generationDouze(premiere.seance.charge));
|
||||
const revision = premiere.seance.ouvert.revision;
|
||||
const premieres = premiere.seance.charge.propositions;
|
||||
const premiereCourse = premiere.seance.courseDe(1);
|
||||
assert.notEqual(premiereCourse, null);
|
||||
await premiere.seance.passerEnLecture();
|
||||
|
||||
const seconde = monter({ fs, prefixe: 'seconde', horloge: creerHorloge(60) });
|
||||
await ouvrirSansEcrire(seconde);
|
||||
await seconde.seance.ouvrirSecours(BASE, 'precedent');
|
||||
await seconde.seance.passerEnEcriture();
|
||||
await seconde.seance.executer('modifierParticipant', { id: 2, champs: { appartenance: 'Club des Merles' } });
|
||||
await seconde.seance.executer('enregistrerGeneration', generationDouze(seconde.seance.charge));
|
||||
assert.deepEqual(seconde.seance.charge.propositions[0].placement, premieres[0].placement);
|
||||
assert.notDeepEqual(seconde.seance.courseDe(1), premiereCourse);
|
||||
await seconde.seance.passerEnLecture();
|
||||
|
||||
await premiere.seance.passerEnEcriture();
|
||||
await premiere.seance.revenir(revision);
|
||||
assert.deepEqual(premiere.seance.charge.propositions, premieres);
|
||||
assert.deepEqual(premiere.seance.courseDe(1), premiereCourse);
|
||||
assert.deepEqual(dansLeCompagnon(fs), [1, 2]);
|
||||
|
||||
await premiere.seance.fermer();
|
||||
const relecture = monter({ fs, prefixe: 'relecture' });
|
||||
await ouvrirSansEcrire(relecture);
|
||||
assert.deepEqual(relecture.seance.courseDe(1), premiereCourse);
|
||||
});
|
||||
|
||||
test("la séance sans événement n'a aucune course", () => {
|
||||
const { seance } = monter();
|
||||
assert.equal(seance.courseDe(1), null);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,11 +6,18 @@
|
|||
// l'application — système de fichiers, horloge, exécuteur de génération,
|
||||
// presse-papiers, identifiants, langue, provenance, démonstrations, format
|
||||
// courant, version du produit et sa forme Windows, chargement de la police
|
||||
// embarquée, imprimante de la plateforme, et en option le thème de
|
||||
// départ — que src/main.js construit pour la machine et monterApp pour
|
||||
// une épreuve : App ne choisit aucune plateforme, et ne lit la version que
|
||||
// dans ses propriétés, la même au bandeau, au titre, à l'accueil et dans
|
||||
// le bloc « À propos ».
|
||||
// embarquée, imprimante de la plateforme, chargement de la section de la
|
||||
// qualité, et en option le thème de départ — que src/main.js construit
|
||||
// pour la machine et monterApp pour une épreuve : App ne choisit aucune
|
||||
// plateforme, et ne lit la version que dans ses propriétés, la même au
|
||||
// bandeau, au titre, à l'accueil et dans le bloc « À propos ».
|
||||
//
|
||||
// La section de la qualité forme son propre morceau dans la construction :
|
||||
// src/main.js passe une fonction qui la charge par un import différé,
|
||||
// monterApp une fonction qui rend le composant importé d'emblée. App en
|
||||
// fait une demande unique (creerChargeurSection), qu'il donne à
|
||||
// l'événement : le morceau ne se charge qu'à la première ouverture de la
|
||||
// section, et une fois.
|
||||
//
|
||||
// À son initialisation, App crée une fois chacun la séance, sans racine,
|
||||
// le contrôleur de génération et la façade, et les donne aux composants par
|
||||
|
|
@ -64,6 +71,7 @@
|
|||
import Accueil from './accueil/Accueil.svelte';
|
||||
import { creerPas } from './accueil/chargement.js';
|
||||
import Bandeau from './Bandeau.svelte';
|
||||
import { creerChargeurSection } from './evenement/differe.js';
|
||||
import Evenement from './evenement/Evenement.svelte';
|
||||
import Apercu from './impression/Apercu.svelte';
|
||||
import { blocAPropos, provenanceVisible, versionVisible } from './apropos.js';
|
||||
|
|
@ -93,6 +101,9 @@
|
|||
// frontière « impression » de la plateforme (§ 13.4), que les
|
||||
// composants reçoivent par le contexte, sans importer src/impression.
|
||||
imprimante,
|
||||
// () => Component | PromiseLike<Component> : le composant de la section
|
||||
// de la qualité, ou la promesse de son morceau.
|
||||
sectionQualite,
|
||||
themeInitial,
|
||||
} = untrack(() => dependances);
|
||||
|
||||
|
|
@ -153,6 +164,8 @@
|
|||
},
|
||||
};
|
||||
const uiApp = creerEtatUi();
|
||||
// Les sections chargées à part, chacune par sa demande unique.
|
||||
const chargeurs = { qualite: creerChargeurSection(sectionQualite) };
|
||||
|
||||
setContext('seance', seanceApp);
|
||||
setContext('facade', facadeContexte);
|
||||
|
|
@ -350,7 +363,7 @@
|
|||
{:else if $uiApp.apercu !== null}
|
||||
<Apercu {provenance} version={produitVersion} fermer={fermerApercu} />
|
||||
{:else}
|
||||
<Evenement />
|
||||
<Evenement {chargeurs} />
|
||||
{/if}
|
||||
</main>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -314,7 +314,7 @@ describe('les planchers des objectifs (§ 5.6, A10, D16, D20)', () => {
|
|||
test('sous « le plus de places servies », le seul plancher des tours hors objectif, avec son remède, dans la section et le panneau du plan', async () => {
|
||||
const m = await ouvrirCharge(quaranteFinance());
|
||||
m.ui.allerA('diagnostic');
|
||||
expect(diagnostiquer(configurationDepuisCharge(charge(m))).objectifsInevitables).toEqual({ horsObjectif: 128, jamaisServies: 8, horsObjectifMax: 4 });
|
||||
expect(diagnostiquer(configurationDepuisCharge(charge(m))).objectifsInevitables).toEqual({ horsObjectif: 128, jamaisServies: 8, horsObjectifMax: 4, parTour: [32, 32, 32, 32] });
|
||||
await attendre(() =>
|
||||
expect(planchers(section(m))).toEqual([['toursHorsObjectif', t('diagnostic.objectifs.toursHorsObjectif', { plancher: 128 })]]),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -500,7 +500,7 @@ describe('enonces : les planchers des objectifs (§ 5.6, A10, D16, D20)', () =>
|
|||
|
||||
test('dix personnes « finance », une table de 8, quatre tours : 8 tours hors objectif ; les deux autres planchers, nuls, se taisent sous chaque mode', () => {
|
||||
const { diagnostic, liste } = lire(chargeObjectifs(10, [2]));
|
||||
assert.deepEqual(diagnostic.objectifsInevitables, { horsObjectif: 8, jamaisServies: 0, horsObjectifMax: 1 });
|
||||
assert.deepEqual(diagnostic.objectifsInevitables, { horsObjectif: 8, jamaisServies: 0, horsObjectifMax: 1, parTour: [2, 2, 2, 2] });
|
||||
assert.deepEqual(desObjectifs(liste), [
|
||||
[
|
||||
'toursHorsObjectif',
|
||||
|
|
@ -526,7 +526,7 @@ describe('enonces : les planchers des objectifs (§ 5.6, A10, D16, D20)', () =>
|
|||
test('quarante personnes « finance » : 128, 8 et 4 ; chaque mode écrit les planchers qu’il compare, dans l’ordre où il les compare, et jamais les autres', () => {
|
||||
const autres = fois(4, 8);
|
||||
const { diagnostic, liste } = lire(chargeObjectifs(40, autres));
|
||||
assert.deepEqual(diagnostic.objectifsInevitables, { horsObjectif: 128, jamaisServies: 8, horsObjectifMax: 4 });
|
||||
assert.deepEqual(diagnostic.objectifsInevitables, { horsObjectif: 128, jamaisServies: 8, horsObjectifMax: 4, parTour: [32, 32, 32, 32] });
|
||||
const grandeurs = (mode) => desObjectifs(lire(chargeObjectifs(40, autres, { mode })).liste).map(([grandeur, plancher]) => [grandeur, plancher]);
|
||||
assert.deepEqual(desObjectifs(liste).map(([grandeur, plancher]) => [grandeur, plancher]), [['toursHorsObjectif', 128]]);
|
||||
assert.deepEqual(grandeurs('chacun'), [
|
||||
|
|
|
|||
|
|
@ -417,13 +417,17 @@ describe('la navigation groupée par phase', () => {
|
|||
for (const theme of THEMES) await sousTheme(theme, () => exigerTextesLisibles(nav, theme));
|
||||
});
|
||||
|
||||
test('avec un avertissement à côté de la tête, les huit onglets tiennent sur une ligne', async () => {
|
||||
test('avec un avertissement à côté de la tête, tous les onglets tiennent sur une ligne, quelle que soit la section ouverte, dont le libellé passe en gras', async () => {
|
||||
const m = await neuf();
|
||||
await enManque(m);
|
||||
expect(dans(m, '[data-annonces] [data-avertissement]')).not.toBeNull();
|
||||
const hauts = tous(m, '[data-commande="section"]').map((bouton) => boite(bouton).top);
|
||||
expect(hauts.length).toBe(SECTIONS.length);
|
||||
expect(new Set(hauts).size).toBe(1);
|
||||
for (const nom of SECTIONS) {
|
||||
m.ui.allerA(nom);
|
||||
flushSync();
|
||||
const hauts = tous(m, '[data-commande="section"]').map((bouton) => boite(bouton).top);
|
||||
expect(hauts.length, nom).toBe(SECTIONS.length);
|
||||
expect(new Set(hauts).size, nom).toBe(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -180,7 +180,7 @@ describe('aucun assistant (§ 2.1)', () => {
|
|||
const { charge } = m.seance.etat();
|
||||
expect([charge.participants.length, charge.tables.length]).toEqual([0, 0]);
|
||||
const ordre = creerPcg32(20261006, FLUX.DEMO).melanger([...SECTIONS]);
|
||||
expect(ordre.length).toBe(8);
|
||||
expect(ordre.length).toBe(SECTIONS.length);
|
||||
for (const nom of [...ordre, ...[...ordre].reverse()]) {
|
||||
const bouton = dans(m, `[data-commande="section"][data-cible="${nom}"]`);
|
||||
expect(visible(bouton), nom).toBe(true);
|
||||
|
|
@ -202,7 +202,7 @@ describe('aucun assistant (§ 2.1)', () => {
|
|||
});
|
||||
|
||||
describe('la place fixe (§ 8.4, § 8.5)', () => {
|
||||
test('« Modifier » aux mêmes coordonnées sur les huit sections ; plein écran, il reste visible et atteint ; « Revenir en lecture » puis « Débloquer » au même endroit', async () => {
|
||||
test('« Modifier » aux mêmes coordonnées sur chaque section ; plein écran, il reste visible et atteint ; « Revenir en lecture » puis « Débloquer » au même endroit', async () => {
|
||||
const m = await neuf();
|
||||
const coins = [];
|
||||
for (const nom of SECTIONS) {
|
||||
|
|
@ -211,7 +211,7 @@ describe('la place fixe (§ 8.4, § 8.5)', () => {
|
|||
expect(visible(fixe(m, 'modifier')), nom).toBe(true);
|
||||
coins.push(coin(fixe(m, 'modifier')));
|
||||
}
|
||||
expect(coins.length).toBe(8);
|
||||
expect(coins.length).toBe(SECTIONS.length);
|
||||
for (const lu of coins) expect(lu).toEqual(coins[0]);
|
||||
// Un refus posé à côté d'un état, au-dessus de la surface, ne déplace
|
||||
// pas la place fixe, et ne la couvre pas.
|
||||
|
|
|
|||
|
|
@ -38,6 +38,12 @@
|
|||
// borne de largeur de son <main>, tant qu'il porte cet écran, et à lui
|
||||
// seul.
|
||||
//
|
||||
// La section de la qualité forme son propre morceau dans la construction :
|
||||
// Evenement ne l'importe pas, il reçoit d'App sa demande unique
|
||||
// (chargeurs.qualite, differe.js), et la monte par SectionDifferee, qui
|
||||
// montre son état de chargement tant que le composant n'est pas là. Toute
|
||||
// autre section est importée ici.
|
||||
//
|
||||
// Le support rompu et les avertissements se posent dans la zone des
|
||||
// annonces, à côté de la tête ou sous elle, qui défile seule au-delà de
|
||||
// son plafond, et non au-dessus de la section : la section ne cède à la
|
||||
|
|
@ -56,6 +62,7 @@
|
|||
import Avertissements from './Avertissements.svelte';
|
||||
import BandeMode from './BandeMode.svelte';
|
||||
import Commande from '../primitives/Commande.svelte';
|
||||
import SectionDifferee from './SectionDifferee.svelte';
|
||||
import SurfaceEvenement from './SurfaceEvenement.svelte';
|
||||
import EnteteEvenement from './EnteteEvenement.svelte';
|
||||
import EtatsPlan from './EtatsPlan.svelte';
|
||||
|
|
@ -63,12 +70,16 @@
|
|||
import { avertissementsOrdonnes } from './modele.js';
|
||||
import { PHASES, cleDeSection } from './sections.js';
|
||||
|
||||
// chargeurs : { qualite } — la demande unique de la section de la qualité,
|
||||
// que rend creerChargeurSection (differe.js).
|
||||
const { chargeurs } = $props();
|
||||
const t = getContext('t');
|
||||
const ui = getContext('ui');
|
||||
const seance = getContext('seance');
|
||||
const uid = $props.id();
|
||||
|
||||
// Le composant de chaque section.
|
||||
// Le composant de chaque section importée ici ; la qualité, chargée à
|
||||
// part, n'y est pas.
|
||||
const COMPOSANTS = new Map([
|
||||
['import', SectionImport],
|
||||
['participants', SectionParticipants],
|
||||
|
|
@ -80,7 +91,8 @@
|
|||
['badges', SectionBadges],
|
||||
]);
|
||||
|
||||
const Section = $derived(COMPOSANTS.get($ui.section));
|
||||
const Section = $derived(COMPOSANTS.get($ui.section) ?? null);
|
||||
const demander = $derived(chargeurs[$ui.section] ?? null);
|
||||
// La zone des annonces n'existe que lorsqu'elle a quelque chose à dire :
|
||||
// sans annonce, aucune hauteur ni aucun trait ne lui est gardé.
|
||||
const annoncer = $derived($seance.supportRompu !== null || avertissementsOrdonnes($seance).length > 0);
|
||||
|
|
@ -139,7 +151,13 @@
|
|||
<div class="evenement" data-ecran="evenement">
|
||||
<BandeMode />
|
||||
<SurfaceEvenement outils={$ui.pleinEcran ? null : outils} actions={$ui.pleinEcran ? null : actions} tete={$ui.pleinEcran ? null : tete} annonces={annoncer ? annonces : null}>
|
||||
<Section />
|
||||
{#if Section !== null}
|
||||
<Section />
|
||||
{:else if demander !== null}
|
||||
{#key $ui.section}
|
||||
<SectionDifferee nom={$ui.section} {demander} />
|
||||
{/key}
|
||||
{/if}
|
||||
</SurfaceEvenement>
|
||||
</div>
|
||||
|
||||
|
|
@ -174,7 +192,7 @@
|
|||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Les phases côte à côte, séparées d'un blanc : les huit onglets tiennent
|
||||
/* Les phases côte à côte, séparées d'un blanc : les neuf onglets tiennent
|
||||
sur une ligne à côté de la place des annonces dans une fenêtre de
|
||||
1 366 px (SurfaceEvenement.svelte). */
|
||||
nav {
|
||||
|
|
|
|||
148
src/interface/evenement/SectionDifferee.navigateur.test.js
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// La section de la qualité, chargée à part, dans un vrai moteur de rendu :
|
||||
// une fonction qui rend une promesse montre d'abord l'état de chargement —
|
||||
// la région [data-chargement-section], titrée par la section, et sa phrase
|
||||
// d'attente —, puis la page ; la page chargée revient ensuite sans état
|
||||
// intermédiaire, et le chargement ne se refait pas. Un rejet dit, à la
|
||||
// place de la page, qu'elle ne s'est pas chargée, porte l'erreur au
|
||||
// collecteur, et le retour à la section la redemande. Le montage par défaut
|
||||
// des épreuves rend le composant importé d'emblée : la page est là au rendu
|
||||
// même qui l'ouvre. Chaque épreuve monte App par monterApp, sur le système
|
||||
// de fichiers simulé où un fichier d'état d'épreuve est posé puis ouvert, et
|
||||
// lit le rendu par ses attributs data-… et son texte. Le texte attendu vient
|
||||
// de la même traduction que celle de l'application ; les noms d'épreuve
|
||||
// sont inventés.
|
||||
import { afterEach, describe, expect, test } from 'vitest';
|
||||
import { flushSync } from 'svelte';
|
||||
import { TABLES_APPLICATION } from '../../application/libelles.js';
|
||||
import { creerTraduction } from '../../application/traduction.js';
|
||||
import { serialiser } from '../../stockage/canonique.js';
|
||||
import { creerCharge } from '../../stockage/document.js';
|
||||
import { SUFFIXES } from '../../stockage/noms.js';
|
||||
import { VERSION } from '../../version.genere.js';
|
||||
import { monterApp } from '../../../test/navigateur/montage.js';
|
||||
import { TABLES_INTERFACE } from '../libelles.js';
|
||||
import SectionQualite from '../qualite/SectionQualite.svelte';
|
||||
import { cleDeSection } from './sections.js';
|
||||
|
||||
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
|
||||
const BASE = 'atelier';
|
||||
|
||||
// Les montages de l'épreuve, démontés après elle. Le collecteur n'a reçu,
|
||||
// pendant l'épreuve, que les erreurs qu'elle annonce dans attendues.
|
||||
let montes = [];
|
||||
let attendues = [];
|
||||
afterEach(() => {
|
||||
const erreurs = montes.flatMap((m) => m.erreurs());
|
||||
for (const m of montes.reverse()) m.demonter();
|
||||
montes = [];
|
||||
const annoncees = attendues;
|
||||
attendues = [];
|
||||
expect(erreurs, 'erreurs reçues par le collecteur').toEqual(annoncees);
|
||||
});
|
||||
|
||||
// Attend que l'assertion passe, le rendu de Svelte à jour.
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Monte App sur le chargement donné de la section de la qualité, pose un
|
||||
// événement vide et l'ouvre, sur la section du plan.
|
||||
async function ouvrir(sectionQualite) {
|
||||
const charge = creerCharge({ id: 'evt-differe', nom: 'Atelier Ressac', siegesParDefaut: 3, tours: 2 });
|
||||
const texte = serialiser(charge, { revision: 1, produitVersion: VERSION.affichee });
|
||||
const m = await monterApp({
|
||||
fichiers: [{ racine: 'documents', chemin: `${BASE}${SUFFIXES.etat}`, texte }],
|
||||
...(sectionQualite === undefined ? {} : { sectionQualite }),
|
||||
});
|
||||
montes.push(m);
|
||||
await m.seance.ouvrir(BASE);
|
||||
flushSync();
|
||||
return m;
|
||||
}
|
||||
|
||||
// Un chargement qui rend, à chaque appel, une promesse neuve que l'épreuve
|
||||
// règle elle-même : appels compte les appels, reglages garde les règlements.
|
||||
function chargementPilote() {
|
||||
const pilote = { appels: 0, reglages: [] };
|
||||
pilote.charger = () => {
|
||||
pilote.appels += 1;
|
||||
return new Promise((resoudre, rejeter) => pilote.reglages.push({ resoudre, rejeter }));
|
||||
};
|
||||
return pilote;
|
||||
}
|
||||
|
||||
const page = (m) => m.racine.querySelector('[data-section="qualite"]');
|
||||
const attente = (m) => m.racine.querySelector('[data-chargement-section="qualite"]');
|
||||
const texteDe = (element) => element.textContent.replace(/\s+/g, ' ').trim();
|
||||
const aller = (m, section) => {
|
||||
m.ui.allerA(section);
|
||||
flushSync();
|
||||
};
|
||||
|
||||
describe('la section de la qualité chargée à part (Evenement.svelte, differe.js)', () => {
|
||||
test('l’état de chargement, titré par la section, puis la page ; revenue, la page est là sans attente ni second chargement', async () => {
|
||||
const pilote = chargementPilote();
|
||||
const m = await ouvrir(pilote.charger);
|
||||
expect(pilote.appels).toBe(0);
|
||||
aller(m, 'qualite');
|
||||
expect(pilote.appels).toBe(1);
|
||||
expect(page(m)).toBeNull();
|
||||
expect(texteDe(attente(m).querySelector('h2'))).toBe(t(cleDeSection('qualite')));
|
||||
expect(texteDe(attente(m).querySelector('[role="status"]'))).toBe(t('evenement.section.chargement'));
|
||||
expect(attente(m).getAttribute('aria-busy')).toBe('true');
|
||||
|
||||
pilote.reglages[0].resoudre(SectionQualite);
|
||||
await attendre(() => expect(page(m)).not.toBeNull());
|
||||
expect(attente(m)).toBeNull();
|
||||
|
||||
aller(m, 'plan');
|
||||
expect(page(m)).toBeNull();
|
||||
aller(m, 'qualite');
|
||||
expect(page(m)).not.toBeNull();
|
||||
expect(attente(m)).toBeNull();
|
||||
expect(pilote.appels).toBe(1);
|
||||
});
|
||||
|
||||
test('un rejet : la région dit que la section ne s’est pas chargée, l’erreur va au collecteur, et le retour la redemande', async () => {
|
||||
const pilote = chargementPilote();
|
||||
const panne = new Error('morceau de la qualité introuvable');
|
||||
const m = await ouvrir(pilote.charger);
|
||||
aller(m, 'qualite');
|
||||
pilote.reglages[0].rejeter(panne);
|
||||
await attendre(() => expect(attente(m).querySelector('[data-echec-section="qualite"]')).not.toBeNull());
|
||||
expect(texteDe(attente(m).querySelector('[data-echec-section="qualite"]'))).toBe(t('evenement.section.echec'));
|
||||
expect(attente(m).getAttribute('aria-busy')).toBe('false');
|
||||
expect(page(m)).toBeNull();
|
||||
// Le collecteur reçoit l'erreur rapportée ; le lanceur des épreuves, qui
|
||||
// voit un écouteur d'erreurs posé, la recopie en console.error, que le
|
||||
// collecteur reçoit aussi. Toute entrée nomme la panne.
|
||||
expect(m.erreurs()).toContainEqual({ type: 'error', message: panne.message });
|
||||
for (const { message } of m.erreurs()) expect(message).toContain(panne.message);
|
||||
attendues = m.erreurs();
|
||||
|
||||
aller(m, 'plan');
|
||||
aller(m, 'qualite');
|
||||
expect(pilote.appels).toBe(2);
|
||||
expect(attente(m).querySelector('[data-echec-section]')).toBeNull();
|
||||
pilote.reglages[1].resoudre(SectionQualite);
|
||||
await attendre(() => expect(page(m)).not.toBeNull());
|
||||
});
|
||||
|
||||
test('le montage par défaut des épreuves rend le composant importé d’emblée : la page est là au rendu qui l’ouvre', async () => {
|
||||
const m = await ouvrir(undefined);
|
||||
aller(m, 'qualite');
|
||||
expect(page(m)).not.toBeNull();
|
||||
expect(attente(m)).toBeNull();
|
||||
});
|
||||
});
|
||||
76
src/interface/evenement/SectionDifferee.svelte
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||
|
||||
<script>
|
||||
// L'hôte d'une section que la construction range dans son propre morceau
|
||||
// (differe.js). demander, appelée une fois au montage de l'hôte, rend le
|
||||
// composant ou sa promesse : un composant déjà connu se monte aussitôt,
|
||||
// sans état intermédiaire ; une promesse montre d'abord l'état de
|
||||
// chargement — la région de la section, son titre et une phrase
|
||||
// d'attente, région role="status" —, puis la section. Une promesse rejetée
|
||||
// est une faute : l'erreur va au collecteur de la page (reportError), et
|
||||
// la région dit, à la place de la section, qu'elle ne s'est pas chargée.
|
||||
// Le retour à la section la redemande.
|
||||
//
|
||||
// nom : le nom de la section (sections.js), qui titre la région d'attente
|
||||
// et la marque [data-chargement-section] ; demander : la demande unique de
|
||||
// creerChargeurSection.
|
||||
import { getContext, untrack } from 'svelte';
|
||||
import { cleDeSection } from './sections.js';
|
||||
|
||||
const proprietes = $props();
|
||||
const nom = $derived(proprietes.nom);
|
||||
const t = getContext('t');
|
||||
|
||||
// La demande se fait au montage, une seule fois : un changement de
|
||||
// propriété ne la refait pas (Evenement.svelte remonte l'hôte à chaque
|
||||
// changement de section).
|
||||
const rendu = untrack(() => proprietes.demander());
|
||||
const attendu = typeof rendu?.then === 'function';
|
||||
let Composant = $state.raw(attendu ? null : rendu);
|
||||
let echec = $state(false);
|
||||
if (attendu) {
|
||||
rendu.then(
|
||||
(lu) => {
|
||||
Composant = lu;
|
||||
},
|
||||
(erreur) => {
|
||||
echec = true;
|
||||
reportError(erreur);
|
||||
},
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if Composant !== null}
|
||||
<Composant />
|
||||
{:else}
|
||||
<section data-chargement-section={nom} aria-busy={!echec}>
|
||||
<h2>{t(cleDeSection(nom))}</h2>
|
||||
{#if echec}
|
||||
<p role="status" data-echec-section={nom}>{t('evenement.section.echec')}</p>
|
||||
{:else}
|
||||
<p role="status">{t('evenement.section.chargement')}</p>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-3);
|
||||
color: var(--couleur-page-texte);
|
||||
font-size: var(--taille-texte);
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: var(--taille-titre);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--couleur-page-texte-secondaire);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -335,11 +335,13 @@
|
|||
une part fixe de la largeur, et le contenu le reste :
|
||||
la zone ne bouge pas d'une section à l'autre, quand le libellé de la
|
||||
section ouverte, en gras, change la largeur de la navigation. Sa part,
|
||||
36 %, laisse au contenu les huit onglets sur une ligne dans une fenêtre
|
||||
de 1 366 px. Une tête de moins de 70rem — celle d'une fenêtre de
|
||||
1 024 px — passe la place à la ligne, sous le contenu, sur toute sa
|
||||
largeur, où un avertissement d'une phrase tient en deux lignes, son
|
||||
action à côté. La tête est pour cela un conteneur de largeur. */
|
||||
32 %, laisse au contenu 886 px dans une fenêtre de 1 366 px : les neuf
|
||||
onglets et les blancs entre les phases y tiennent sur une ligne, quelle
|
||||
que soit la section ouverte, la plus large en demandant 885. Une tête
|
||||
de moins de 70rem — celle d'une fenêtre de 1 024 px — passe la place
|
||||
à la ligne, sous le contenu, sur toute sa largeur, où un avertissement
|
||||
d'une phrase tient en deux lignes, son action à côté. La tête est pour
|
||||
cela un conteneur de largeur. */
|
||||
.tete.cote {
|
||||
flex-flow: row wrap;
|
||||
container: tete / inline-size;
|
||||
|
|
@ -362,7 +364,7 @@
|
|||
}
|
||||
|
||||
.place {
|
||||
flex: 0 0 36%;
|
||||
flex: 0 0 32%;
|
||||
min-width: 16rem;
|
||||
min-height: 3rem;
|
||||
border-left: 1px solid var(--couleur-carte-filet);
|
||||
|
|
@ -379,7 +381,7 @@
|
|||
}
|
||||
|
||||
/* Dans la place, la zone a un retrait vertical de 2 px et un plafond,
|
||||
retrait compris : 7,25rem tiennent entier, dans la place de 36 % d'une
|
||||
retrait compris : 7,25rem tiennent entier, dans la place de 32 % d'une
|
||||
fenêtre de 1 366 px, un avertissement de trois lignes de texte et son
|
||||
action dessous (Avertissements.svelte). Au-delà, la zone défile, et la
|
||||
tête, qui ne grandit pas plus, laisse le reste du composant à la surface. */
|
||||
|
|
|
|||
58
src/interface/evenement/differe.js
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Le chargeur d'une section que la construction range dans son propre
|
||||
// morceau. L'interface ne charge aucun module elle-même : src/main.js lui
|
||||
// passe une fonction qui rend la promesse du composant, chargée par un
|
||||
// import différé ; le montage des épreuves lui passe une fonction qui rend
|
||||
// le composant tout de suite, importé d'emblée. creerChargeurSection en
|
||||
// fait une demande unique, que l'hôte de la section (SectionDifferee.svelte)
|
||||
// appelle à chaque montage.
|
||||
|
||||
/**
|
||||
* La demande d'une section chargée à part.
|
||||
*
|
||||
* charger n'est appelé qu'à la première demande, puis à la demande qui suit
|
||||
* un échec : une section chargée, ou en cours de chargement, ne se charge
|
||||
* pas deux fois. Chaque demande rend ce qui est déjà connu — le composant,
|
||||
* de façon synchrone, quand charger l'a rendu tel quel ou que sa promesse
|
||||
* est résolue ; sinon la promesse en cours, la même d'une demande à
|
||||
* l'autre. Une promesse rejetée oublie son chargement : la demande suivante
|
||||
* rappelle charger.
|
||||
*
|
||||
* @param {() => Function | PromiseLike<Function>} charger rend le
|
||||
* composant de la section, ou une promesse de lui
|
||||
* @returns {() => Function | Promise<Function>}
|
||||
* @throws {TypeError} (à la demande, ou par rejet de la promesse rendue)
|
||||
* quand charger rend autre chose qu'un composant, une fonction : la
|
||||
* section ne se monterait pas, et le rejet le dit en nommant ce qui
|
||||
* a été rendu
|
||||
*/
|
||||
export function creerChargeurSection(charger) {
|
||||
if (typeof charger !== 'function') throw new TypeError(`creerChargeurSection : fonction attendue, reçu ${typeof charger}`);
|
||||
let composant = null;
|
||||
let enCours = null;
|
||||
const exiger = (rendu) => {
|
||||
if (typeof rendu !== 'function') throw new TypeError(`section chargée à part : composant attendu, reçu ${rendu === null ? 'null' : typeof rendu}`);
|
||||
return rendu;
|
||||
};
|
||||
return function demander() {
|
||||
if (composant !== null) return composant;
|
||||
if (enCours !== null) return enCours;
|
||||
const rendu = charger();
|
||||
if (typeof rendu?.then !== 'function') {
|
||||
composant = exiger(rendu);
|
||||
return composant;
|
||||
}
|
||||
enCours = Promise.resolve(rendu)
|
||||
.then(exiger)
|
||||
.then((lu) => {
|
||||
composant = lu;
|
||||
return lu;
|
||||
})
|
||||
.finally(() => {
|
||||
enCours = null;
|
||||
});
|
||||
return enCours;
|
||||
};
|
||||
}
|
||||
70
src/interface/evenement/differe.test.js
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// La demande unique d'une section chargée à part (differe.js) : un
|
||||
// composant rendu tel quel se rend de façon synchrone, sans rappel ; une
|
||||
// promesse se partage d'une demande à l'autre, puis cède la place au
|
||||
// composant résolu ; un rejet oublie le chargement, et la demande suivante
|
||||
// rappelle charger ; ce qui n'est pas un composant est refusé. Les
|
||||
// composants sont des fonctions inventées.
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, test } from '../../../test/lanceur.js';
|
||||
import { creerChargeurSection } from './differe.js';
|
||||
|
||||
// Un chargeur qui compte ses appels et rend, à chacun, la valeur suivante
|
||||
// de rendus.
|
||||
function compteur(...rendus) {
|
||||
const suivi = { appels: 0 };
|
||||
suivi.charger = () => {
|
||||
suivi.appels += 1;
|
||||
return rendus[suivi.appels - 1];
|
||||
};
|
||||
return suivi;
|
||||
}
|
||||
|
||||
function Composant() {}
|
||||
|
||||
describe('creerChargeurSection (differe.js)', () => {
|
||||
test('un composant rendu tel quel se rend aussitôt, sans promesse, et charger ne sert qu’une fois', () => {
|
||||
const suivi = compteur(Composant);
|
||||
const demander = creerChargeurSection(suivi.charger);
|
||||
assert.equal(suivi.appels, 0);
|
||||
assert.equal(demander(), Composant);
|
||||
assert.equal(demander(), Composant);
|
||||
assert.equal(suivi.appels, 1);
|
||||
});
|
||||
|
||||
test('une promesse se partage entre les demandes, puis le composant résolu se rend de façon synchrone', async () => {
|
||||
let resoudre;
|
||||
const suivi = compteur(new Promise((r) => (resoudre = r)));
|
||||
const demander = creerChargeurSection(suivi.charger);
|
||||
const premiere = demander();
|
||||
assert.equal(typeof premiere.then, 'function');
|
||||
assert.equal(demander(), premiere);
|
||||
resoudre(Composant);
|
||||
assert.equal(await premiere, Composant);
|
||||
assert.equal(demander(), Composant);
|
||||
assert.equal(suivi.appels, 1);
|
||||
});
|
||||
|
||||
test('un rejet oublie le chargement : la demande suivante rappelle charger', async () => {
|
||||
const panne = new Error('morceau introuvable');
|
||||
const suivi = compteur(Promise.reject(panne), Promise.resolve(Composant));
|
||||
const demander = creerChargeurSection(suivi.charger);
|
||||
await assert.rejects(demander(), (erreur) => erreur === panne);
|
||||
const reprise = demander();
|
||||
assert.equal(suivi.appels, 2);
|
||||
assert.equal(await reprise, Composant);
|
||||
assert.equal(demander(), Composant);
|
||||
});
|
||||
|
||||
test('ce qui n’est pas un composant est refusé : levé tout de suite, ou rejeté, puis redemandé', async () => {
|
||||
assert.throws(() => creerChargeurSection(null), TypeError);
|
||||
assert.throws(() => creerChargeurSection(() => ({ default: Composant }))(), /composant attendu, reçu object/);
|
||||
const suivi = compteur(Promise.resolve(null), Promise.resolve(Composant));
|
||||
const demander = creerChargeurSection(suivi.charger);
|
||||
await assert.rejects(demander(), /composant attendu, reçu null/);
|
||||
assert.equal(await demander(), Composant);
|
||||
assert.equal(suivi.appels, 2);
|
||||
});
|
||||
});
|
||||
|
|
@ -15,21 +15,23 @@ const CLES = new Map([
|
|||
['diagnostic', 'coquille.section.diagnostic'],
|
||||
['generation', 'coquille.section.generation'],
|
||||
['propositions', 'coquille.section.propositions'],
|
||||
['qualite', 'coquille.section.qualite'],
|
||||
['badges', 'coquille.section.badges'],
|
||||
]);
|
||||
|
||||
/** Les sections de l'événement, dans l'ordre du § 2.1 : import,
|
||||
* participants, tables, plan, diagnostic, generation, propositions, badges
|
||||
* — la planche, dernière étape (§ 2.1 étape 12). Aucune n'en exige une
|
||||
* autre. Le titre et le nom de navigation de chacune sont la
|
||||
* clé coquille.section.<nom> de coquille.js. */
|
||||
* participants, tables, plan, diagnostic, generation, propositions,
|
||||
* qualite — la page de qualité, lue avant de retenir (§ 2.1 étape 10) —,
|
||||
* badges — la planche, dernière étape (§ 2.1 étape 12). Aucune n'en exige
|
||||
* une autre. Le titre et le nom de navigation de chacune sont la clé
|
||||
* coquille.section.<nom> de coquille.js. */
|
||||
export const SECTIONS = Object.freeze([...CLES.keys()]);
|
||||
|
||||
// Les phases de la navigation, chacune avec ses sections, dans l'ordre du
|
||||
// § 2.1 : préparer la liste et le mobilier, placer, produire la planche.
|
||||
const PHASES_ET_SECTIONS = [
|
||||
['preparer', ['import', 'participants', 'tables']],
|
||||
['placer', ['plan', 'diagnostic', 'generation', 'propositions']],
|
||||
['placer', ['plan', 'diagnostic', 'generation', 'propositions', 'qualite']],
|
||||
['produire', ['badges']],
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -1,22 +1,27 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Les sections de l'événement ouvert (§ 2.1) : huit noms distincts, dans
|
||||
// l'ordre du parcours de l'opérateur — les badges, dernière étape, après les
|
||||
// propositions (§ 2.1 étape 12) —, chacun nommé par sa clé
|
||||
// coquille.section.<nom> de la table de la coquille ; la section d'ouverture
|
||||
// en est une. La navigation les groupe en trois phases — préparer, placer,
|
||||
// produire —, qui les couvrent chacune une fois, dans leur ordre.
|
||||
// Les sections de l'événement ouvert (§ 2.1) : neuf noms distincts, dans
|
||||
// l'ordre du parcours de l'opérateur — la qualité après les propositions
|
||||
// (§ 2.1 étape 10), les badges, dernière étape, après elle (§ 2.1 étape
|
||||
// 12) —, chacun nommé par sa clé coquille.section.<nom> de la table de la
|
||||
// coquille ; la section d'ouverture en est une. La navigation les groupe en
|
||||
// trois phases — préparer, placer, produire —, qui les couvrent chacune une
|
||||
// fois, dans leur ordre. La section de la qualité se lit à l'écran et ne
|
||||
// s'imprime pas (§ 11.7) : aucune vue de l'aperçu ne la porte. Qu'aucune
|
||||
// règle d'impression ne la vise s'éprouve au navigateur, sur les feuilles
|
||||
// appliquées (Qualite.navigateur.test.js).
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, test } from '../../../test/lanceur.js';
|
||||
import { VUES_IMPRESSION } from '../etat_ui.js';
|
||||
import { TABLE as COQUILLE } from '../libelles/coquille.js';
|
||||
import { TABLE as EVENEMENT } from '../libelles/evenement.js';
|
||||
import { PHASES, SECTION_OUVERTURE, SECTIONS, cleDeSection } from './sections.js';
|
||||
|
||||
describe('sections de l’événement (§ 2.1)', () => {
|
||||
test('huit noms distincts, dans l’ordre du parcours, les badges après les propositions, figés', () => {
|
||||
assert.deepEqual(SECTIONS, ['import', 'participants', 'tables', 'plan', 'diagnostic', 'generation', 'propositions', 'badges']);
|
||||
assert.equal(new Set(SECTIONS).size, 8);
|
||||
test('neuf noms distincts, dans l’ordre du parcours, la qualité après les propositions, les badges après elle, figés', () => {
|
||||
assert.deepEqual(SECTIONS, ['import', 'participants', 'tables', 'plan', 'diagnostic', 'generation', 'propositions', 'qualite', 'badges']);
|
||||
assert.equal(new Set(SECTIONS).size, 9);
|
||||
assert.ok(Object.isFrozen(SECTIONS));
|
||||
});
|
||||
|
||||
|
|
@ -48,7 +53,7 @@ describe('les phases de la navigation', () => {
|
|||
PHASES.map(({ nom, sections }) => [nom, sections]),
|
||||
[
|
||||
['preparer', ['import', 'participants', 'tables']],
|
||||
['placer', ['plan', 'diagnostic', 'generation', 'propositions']],
|
||||
['placer', ['plan', 'diagnostic', 'generation', 'propositions', 'qualite']],
|
||||
['produire', ['badges']],
|
||||
],
|
||||
);
|
||||
|
|
@ -78,3 +83,10 @@ describe('les phases de la navigation', () => {
|
|||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('la section de la qualité ne s’imprime pas (§ 11.7, § 12)', () => {
|
||||
test('les vues de l’aperçu restent le plan, l’accueil et les tables', () => {
|
||||
assert.ok(SECTIONS.includes('qualite'));
|
||||
assert.deepEqual(VUES_IMPRESSION, ['plan', 'accueil', 'tables']);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -18,11 +18,13 @@ import { TABLE as DIAGNOSTIC } from './libelles/diagnostic.js';
|
|||
import { TABLE as EVENEMENT } from './libelles/evenement.js';
|
||||
import { TABLE as FEUILLES } from './libelles/feuilles.js';
|
||||
import { TABLE as GENERATION } from './libelles/generation.js';
|
||||
import { TABLE as GRAPHIQUES } from './libelles/graphiques.js';
|
||||
import { TABLE as IMPORT } from './libelles/import.js';
|
||||
import { TABLE as IMPRESSION } from './libelles/impression.js';
|
||||
import { TABLE as PARTICIPANTS } from './libelles/participants.js';
|
||||
import { TABLE as PLAN } from './libelles/plan.js';
|
||||
import { TABLE as PROPOSITIONS } from './libelles/propositions.js';
|
||||
import { TABLE as QUALITE } from './libelles/qualite.js';
|
||||
import { TABLE as RACCOURCIS } from './libelles/raccourcis.js';
|
||||
import { TABLE as TABLES } from './libelles/tables.js';
|
||||
|
||||
|
|
@ -36,11 +38,13 @@ export const TABLES_INTERFACE = Object.freeze([
|
|||
EVENEMENT,
|
||||
FEUILLES,
|
||||
GENERATION,
|
||||
GRAPHIQUES,
|
||||
IMPORT,
|
||||
IMPRESSION,
|
||||
PARTICIPANTS,
|
||||
PLAN,
|
||||
PROPOSITIONS,
|
||||
QUALITE,
|
||||
RACCOURCIS,
|
||||
TABLES,
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -65,12 +65,12 @@ async function tablesDuDossier() {
|
|||
}
|
||||
|
||||
describe('libellés : les tables des écrans (§ 14.6)', () => {
|
||||
test('chaque module de libelles/ exporte TABLE, une Map ; TABLES_INTERFACE les assemble, quinze, par ordre alphabétique', async () => {
|
||||
test('chaque module de libelles/ exporte TABLE, une Map ; TABLES_INTERFACE les assemble, dix-sept, par ordre alphabétique', async () => {
|
||||
const tables = await tablesDuDossier();
|
||||
assert.equal(tables.length, 15);
|
||||
assert.equal(tables.length, 17);
|
||||
assert.deepEqual(tables.map(({ table }) => table), [
|
||||
'accueil', 'badges', 'communs', 'coquille', 'diagnostic', 'evenement', 'feuilles', 'generation',
|
||||
'import', 'impression', 'participants', 'plan', 'propositions', 'raccourcis', 'tables',
|
||||
'accueil', 'badges', 'communs', 'coquille', 'diagnostic', 'evenement', 'feuilles', 'generation', 'graphiques',
|
||||
'import', 'impression', 'participants', 'plan', 'propositions', 'qualite', 'raccourcis', 'tables',
|
||||
]);
|
||||
for (const { table, module } of tables) assert.ok(module.TABLE instanceof Map, table);
|
||||
assert.equal(TABLES_INTERFACE.length, tables.length);
|
||||
|
|
|
|||
|
|
@ -71,5 +71,6 @@ export const TABLE = new Map([
|
|||
['coquille.section.diagnostic', 'Diagnostic'],
|
||||
['coquille.section.generation', 'Génération'],
|
||||
['coquille.section.propositions', 'Propositions'],
|
||||
['coquille.section.qualite', 'Qualité'],
|
||||
['coquille.section.badges', 'Badges'],
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -3,12 +3,13 @@
|
|||
|
||||
// Les libellés de l'événement ouvert : son en-tête et son résumé chiffré,
|
||||
// ses états, la bande de mode et la place fixe de sa barre, les phases de
|
||||
// sa navigation, la reprise d'un verrou, ses avertissements, le support
|
||||
// rompu (§ 8.3 à § 8.9, § 9, § 14.6). Chaque clé commence par
|
||||
// « evenement. ». Une donnée qui désigne — un numéro, une séance, un
|
||||
// instant, une date — n'entre dans aucun de ces textes : l'écran la pose
|
||||
// dans son propre nœud, à côté. Les comptes du résumé, eux, entrent dans le
|
||||
// leur, qui s'accorde au nombre et l'écrit par creerFormat.
|
||||
// sa navigation, l'attente d'une section chargée à part, la reprise d'un
|
||||
// verrou, ses avertissements, le support rompu (§ 8.3 à § 8.9, § 9,
|
||||
// § 14.6). Chaque clé commence par « evenement. ». Une donnée qui
|
||||
// désigne — un numéro, une séance, un instant, une date — n'entre dans
|
||||
// aucun de ces textes : l'écran la pose dans son propre nœud, à côté. Les
|
||||
// comptes du résumé, eux, entrent dans le leur, qui s'accorde au nombre et
|
||||
// l'écrit par creerFormat.
|
||||
import { creerFormat } from '../../application/format.js';
|
||||
import { cite } from '../../application/libelles/aides.js';
|
||||
|
||||
|
|
@ -53,6 +54,10 @@ export const TABLE = new Map([
|
|||
['evenement.phase.placer', 'Placer'],
|
||||
['evenement.phase.produire', 'Produire'],
|
||||
|
||||
// --- Une section chargée à part (SectionDifferee.svelte) --------------------
|
||||
['evenement.section.chargement', 'Chargement de la section\u{2026}'],
|
||||
['evenement.section.echec', 'Cette section ne s\u{2019}est pas chargée ; y revenir la recharge.'],
|
||||
|
||||
// --- La barre de l'événement et sa place fixe (§ 8.3, § 8.4, § 8.5) ------
|
||||
['evenement.barre.commandes', 'Commandes de l\u{2019}événement'],
|
||||
['evenement.commande.defaire', 'Défaire'],
|
||||
|
|
|
|||
35
src/interface/libelles/graphiques.js
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Les libellés des composants de graphique de la page de qualité (§ 12.3,
|
||||
// § 12.7, § 12.14) : les titres des abscisses, la commande du jumeau, la
|
||||
// légende et les signes des rangs marqués, la marque de troncature d'un nom,
|
||||
// la phrase des petits multiples. Chaque clé commence par « graphiques. ».
|
||||
// Les questions des vues, leurs phrases de synthèse et leurs pourcentages
|
||||
// sont des libellés de la page (qualite.*) : la vue les écrit et les passe
|
||||
// au composant déjà rendus. Aucune valeur de cette table n'écrit le signe du
|
||||
// pourcentage (contrainte 44).
|
||||
export const TABLE = new Map([
|
||||
// --- Les axes ---------------------------------------------------------------
|
||||
['graphiques.axe.rangs', 'rang'],
|
||||
['graphiques.axe.mouvements', 'mouvements évalués'],
|
||||
|
||||
// --- La légende et les signes des rangs marqués (§ 12.3) --------------------
|
||||
['graphiques.legende', 'Légende'],
|
||||
['graphiques.signe.ancre', 'ancré'],
|
||||
['graphiques.signe.partiel', 'partiellement fixé'],
|
||||
['graphiques.signe.reserve', 'en réserve à un tour au moins'],
|
||||
['graphiques.signe.plafondNul', 'plafond réalisé nul'],
|
||||
|
||||
// --- Le jumeau tabulaire (§ 12.7, D44) --------------------------------------
|
||||
['graphiques.jumeau.ouvrir', 'Voir le tableau'],
|
||||
['graphiques.jumeau.fermer', 'Masquer le tableau'],
|
||||
|
||||
// --- Le texte des graphiques ------------------------------------------------
|
||||
// La marque d'un nom tronqué sur le tracé ; le nom entier reste dans
|
||||
// l'infobulle et le jumeau (§ 12.6).
|
||||
['graphiques.troncature', '\u{2026}'],
|
||||
// Les petits multiples de la performance ne marquent que les points de
|
||||
// relevé (D49) : la phrase de synthèse le dit.
|
||||
['graphiques.synthese.pointsDeReleve', 'Valeurs aux seuls points de relevé, reliées par un trait mince.'],
|
||||
]);
|
||||
|
|
@ -351,6 +351,9 @@ export const TABLE = new Map([
|
|||
// --- Les commandes (§ 2.1 étape 10, § 5.7) ------------------------------------
|
||||
['propositions.commande.voir', 'Voir sur le plan'],
|
||||
['propositions.commande.retenir', 'Retenir'],
|
||||
// « Qualité » montre la proposition de sa ligne au plan et ouvre la page
|
||||
// de qualité, qui la mesure (§ 12).
|
||||
['propositions.commande.qualite', 'Qualité'],
|
||||
['propositions.commande.effacer', 'Effacer les propositions'],
|
||||
|
||||
// --- Retenir sur un retenu ajusté (§ 2.1) ----------------------------------------
|
||||
|
|
|
|||
377
src/interface/libelles/qualite.js
Normal file
|
|
@ -0,0 +1,377 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Les libellés de la page de qualité (§ 12 à § 12.14, § 14.6), une table.
|
||||
// Chaque clé commence par « qualite. » : la source et ses cas, la liste des
|
||||
// vues, le refus de dessiner, les pourcentages, puis un sous-préfixe par vue
|
||||
// — tuile, profil, collisions, tables, paires, objectifs, animateurs,
|
||||
// diversite, performance.
|
||||
//
|
||||
// Le signe « % » ne s'écrit que sous qualite.pourcentage.* : la valeur
|
||||
// « N % », les graduations d'un axe en pourcentages et le libellé de sa
|
||||
// règle, et la part des tours possibles servis (§ 12.8, D34, D52). Chaque
|
||||
// pourcentage est un entier que le détail du placement porte déjà ; la page
|
||||
// écrit dessous, juste après lui, le compte du pire, que ses clés de vue
|
||||
// portent : sur une tuile, une synthèse ou la soirée, le pire cas de la
|
||||
// population ; sur une ligne de jumeau ou d'infobulle, le compte de la ligne.
|
||||
//
|
||||
// Le vocabulaire est celui du glossaire (§ 5.5) : « borne de diversité » et
|
||||
// « borne des animateurs », jamais « plafond » pour l'une ou l'autre ; les
|
||||
// « points de relevé » de la recherche ; « le moins varié », « le plus
|
||||
// varié » et « au minimum » pour la diversité d'un animateur ; « des tours
|
||||
// possibles servis » pour la part servie des objectifs.
|
||||
import { compte } from '../../application/libelles/aides.js';
|
||||
|
||||
export const TABLE = new Map([
|
||||
// --- La source et les cas sans source mesurable (§ 12, B9, § 12.6) ---------
|
||||
// La ligne de source nomme le placement mesuré, après cet intitulé, par le
|
||||
// libellé que la barre de vue du plan lui donne (plan.source.*) ; une
|
||||
// proposition fautive, que le plan ne montre jamais, a le sien.
|
||||
['qualite.source.intitule', 'Placement mesuré :'],
|
||||
['qualite.source.propositionFautive', ({ proposition }) => `Proposition n° ${proposition}, fautive : le plan ne la montre pas`],
|
||||
['qualite.source.montrerPremiere', ({ proposition }) => `Montrer la proposition n° ${proposition}`],
|
||||
[
|
||||
'qualite.phrase.aucunPlacement',
|
||||
"Aucun placement à mesurer : ni proposition ni placement retenu. Le diagnostic dit ce que la configuration rend inévitable.",
|
||||
],
|
||||
[
|
||||
'qualite.phrase.aucuneMontree',
|
||||
"Le plan ne montre aucune proposition : la page mesure la proposition demandée au plan, sinon le retenu. « Qualité », sur une ligne du tableau des propositions, en demande une.",
|
||||
],
|
||||
['qualite.phrase.aucuneComparable', 'Aucune proposition ne se compare : chacune est en dérive ou fautive.'],
|
||||
['qualite.phrase.derive', 'Ce placement est en dérive : la page ne le mesure pas, pour ces raisons.'],
|
||||
['qualite.phrase.fautif', 'Ce placement est fautif : la page ne le mesure pas, pour ces raisons.'],
|
||||
['qualite.phrase.configuration', 'La configuration ne se normalise pas : la page ne mesure aucun placement.'],
|
||||
|
||||
// --- Le refus de dessiner (§ 12.3) -----------------------------------------
|
||||
[
|
||||
'qualite.refus.phrase',
|
||||
'La page ne dessine pas ce placement : une mesure y contredit sa borne, et le calcul est faux. Chaque contradiction est nommée ci-dessous.',
|
||||
],
|
||||
|
||||
// --- Les vues, leurs titres et leurs questions (§ 12.1, B9, B10) -----------
|
||||
['qualite.vues.titre', 'Vues de la page de qualité'],
|
||||
['qualite.vue.entete.titre', 'En-tête chiffré'],
|
||||
['qualite.vue.entete.question', "Ce placement est-il bon, et qui s'y trouve mal servi ?"],
|
||||
['qualite.vue.profil.titre', 'Profil trié des rencontres'],
|
||||
['qualite.vue.profil.question', 'Qui est le plus mal servi, de combien, et combien sont-ils ?'],
|
||||
['qualite.vue.collisions.titre', "Collisions d'appartenance"],
|
||||
['qualite.vue.collisions.question', "D'où vient le problème, et relancer peut-il y changer quelque chose ?"],
|
||||
['qualite.vue.tables.titre', 'Appartenances à chaque table'],
|
||||
['qualite.vue.tables.question', 'Où les mêmes cercles se retrouvent-ils à une même table ?'],
|
||||
['qualite.vue.paires.titre', 'Paires répétées'],
|
||||
['qualite.vue.paires.question', 'Qui revoit trop souvent la même personne ?'],
|
||||
['qualite.vue.objectifs.titre', 'Objectifs des tables'],
|
||||
[
|
||||
'qualite.vue.objectifs.question',
|
||||
"Qui n'est jamais assis à une table qui lui correspond, et à quel tour l'objectif perd-il ?",
|
||||
],
|
||||
['qualite.vue.animateurs.titre', 'Animateurs rencontrés'],
|
||||
['qualite.vue.animateurs.question', "Qui a rencontré le moins d'animateurs ?"],
|
||||
['qualite.vue.diversite.titre', 'Diversité par animateur'],
|
||||
['qualite.vue.diversite.question', 'Quel animateur voit le moins de diversité à sa table ?'],
|
||||
['qualite.vue.performance.titre', 'Performance du calcul'],
|
||||
['qualite.vue.performance.question', 'Relancer plus longtemps aurait-il donné mieux ?'],
|
||||
|
||||
// --- Les pourcentages, seules clés du signe (§ 12.8, D34, D52) -------------
|
||||
['qualite.pourcentage.valeur', ({ pourcent }) => `${pourcent}\u{202F}%`],
|
||||
['qualite.pourcentage.axe', ({ valeur }) => `${valeur}\u{202F}%`],
|
||||
['qualite.pourcentage.cent', '100\u{202F}%'],
|
||||
['qualite.pourcentage.servis', ({ pourcent }) => `${pourcent}\u{202F}% des tours possibles servis`],
|
||||
|
||||
// --- L'en-tête chiffré (§ 12.2, B11, D29, D48) -----------------------------
|
||||
// Une tuile qui a sa colonne au tableau des propositions en lit le libellé ;
|
||||
// une tuile sans colonne — critère décoché, valeur que le mode réglé ne
|
||||
// compare pas — prend le sien ci-dessous, et dit qu'elle ne classe pas.
|
||||
['qualite.tuile.neClassePas', 'ne classe pas'],
|
||||
['qualite.tuile.population', ({ mesuree, exclue }) =>
|
||||
exclue === 0
|
||||
? `Population mesurée : ${compte(mesuree, 'personne', 'personnes')}.`
|
||||
: `Population mesurée : ${compte(mesuree, 'personne', 'personnes')}, ${compte(exclue, 'exclue', 'exclues')} à part.`],
|
||||
['qualite.tuile.jamaisServies', 'Personnes jamais servies'],
|
||||
['qualite.tuile.horsObjectifMax', "Plus de tours hors objectif d'une personne"],
|
||||
['qualite.tuile.toursHorsObjectif', 'Tours hors objectif'],
|
||||
['qualite.tuile.toursSelonObjectif', "Tours selon l'objectif, minimum"],
|
||||
['qualite.tuile.animateursRencontres', 'Animateurs rencontrés, minimum'],
|
||||
['qualite.tuile.animateursNonRencontres', 'Animateurs non rencontrés'],
|
||||
['qualite.tuile.personne', ({ nom }) => `chez ${nom}`],
|
||||
['qualite.tuile.plafondRealise', ({ plafond }) => `plafond réalisé ${plafond}`],
|
||||
['qualite.tuile.plafondAPriori', ({ plafond }) => `plafond a priori ${plafond}`],
|
||||
['qualite.tuile.effectifManque', ({ effectif }) => `${compte(effectif, 'personne', 'personnes')} au manque maximal`],
|
||||
['qualite.tuile.borneDiversite', ({ borne }) => `borne de diversité ${borne}`],
|
||||
['qualite.tuile.borneAnimateurs', ({ borne }) => `borne des animateurs ${borne}`],
|
||||
['qualite.tuile.plancher', ({ plancher }) => `plancher ${plancher}`],
|
||||
['qualite.tuile.toursPossibles', ({ possibles }) => compte(possibles, 'tour possible', 'tours possibles')],
|
||||
['qualite.tuile.imposees', ({ imposees }) => `${compte(imposees, 'imposée', 'imposées')}, à part`],
|
||||
['qualite.tuile.imposes', ({ imposes }) => `${compte(imposes, 'imposé', 'imposés')}, à part`],
|
||||
['qualite.tuile.sansAppartenance', "Aucune personne présente ne porte d'appartenance."],
|
||||
['qualite.tuile.pireRencontres', ({ rencontres, plafond }) => `minimum ${rencontres} sur ${plafond}`],
|
||||
['qualite.tuile.pireServis', ({ servis, possibles }) => `minimum ${servis} sur ${possibles}`],
|
||||
[
|
||||
'qualite.tuile.pireJamaisServies',
|
||||
({ personnes }) => compte(personnes, 'personne jamais servie', 'personnes jamais servies'),
|
||||
],
|
||||
|
||||
// --- Le profil trié (§ 12.3, B12, D28, D30, D41, D51) ----------------------
|
||||
['qualite.profil.graphique.comptes', 'Profil trié des rencontres, en comptes de personnes'],
|
||||
['qualite.profil.graphique.pourcentages', 'Profil trié des rencontres, en pourcentage du plafond réalisé'],
|
||||
['qualite.profil.axe.rangs', 'Personnes, de la moins servie à la mieux servie'],
|
||||
['qualite.profil.axe.comptes', 'Personnes distinctes'],
|
||||
['qualite.profil.axe.pourcentages', 'Rencontres en pourcentage du plafond réalisé'],
|
||||
['qualite.profil.serie.rencontres', 'rencontres'],
|
||||
['qualite.profil.serie.appartenances', 'appartenances rencontrées'],
|
||||
['qualite.profil.repere.plafond', 'plafond réalisé'],
|
||||
['qualite.profil.repere.borne', 'borne de diversité'],
|
||||
[
|
||||
'qualite.profil.synthese',
|
||||
({ minimum, effectifAuMinimum, effectifSousPlafond }) =>
|
||||
`Minimum ${compte(minimum, 'personne rencontrée', 'personnes rencontrées')}, ` +
|
||||
`${compte(effectifAuMinimum, 'personne', 'personnes')} au minimum, ` +
|
||||
`${compte(effectifSousPlafond, 'personne', 'personnes')} sous leur plafond réalisé.`,
|
||||
],
|
||||
['qualite.profil.pire', ({ rencontres, plafond }) => `minimum ${rencontres} sur ${plafond}`],
|
||||
['qualite.profil.ligne', ({ rencontres, plafond }) => `${rencontres} sur ${plafond}`],
|
||||
['qualite.profil.marque.ancre', 'ancré'],
|
||||
['qualite.profil.marque.partiel', 'partiellement fixé'],
|
||||
['qualite.profil.marque.reserve', 'en réserve à un tour au moins'],
|
||||
['qualite.profil.marque.plafondNul', 'plafond réalisé nul'],
|
||||
['qualite.profil.colonne.rang', 'Rang'],
|
||||
['qualite.profil.colonne.nom', 'Personne'],
|
||||
['qualite.profil.colonne.appartenance', 'Appartenance'],
|
||||
['qualite.profil.colonne.rencontres', 'Personnes distinctes rencontrées'],
|
||||
['qualite.profil.colonne.plafondRealise', 'Plafond réalisé'],
|
||||
['qualite.profil.colonne.pourcent', 'Rencontres sur le plafond réalisé'],
|
||||
['qualite.profil.colonne.plafondAPriori', 'Plafond a priori'],
|
||||
['qualite.profil.colonne.appartenances', 'Appartenances rencontrées'],
|
||||
['qualite.profil.colonne.borneDiversite', 'Borne de diversité'],
|
||||
['qualite.profil.colonne.toursNonAssis', 'Tours non assis'],
|
||||
['qualite.profil.repartition.titre', 'Répartition des rencontres'],
|
||||
['qualite.profil.repartition.valeur', 'Personnes distinctes rencontrées'],
|
||||
['qualite.profil.repartition.effectif', 'Effectif'],
|
||||
['qualite.profil.repartition.cumul', 'Effectif cumulé'],
|
||||
['qualite.profil.repartition.auPlafond', 'À leur plafond réalisé'],
|
||||
[
|
||||
'qualite.profil.uneTable',
|
||||
"Une seule table : chacun y rencontre tous les autres, et le profil dirait la même chose de chacun.",
|
||||
],
|
||||
[
|
||||
'qualite.profil.uneAppartenance',
|
||||
"Une seule appartenance : le profil n'a qu'une série, les rencontres, et pas de borne de diversité.",
|
||||
],
|
||||
[
|
||||
'qualite.profil.aucuneAppartenance',
|
||||
"Aucune appartenance : le profil n'a qu'une série, les rencontres, et pas de borne de diversité.",
|
||||
],
|
||||
['qualite.profil.plafondNul', 'Plafond réalisé nul : ce rang n’a pas de point en pourcentage.'],
|
||||
|
||||
// --- Les collisions d'appartenance (§ 12.4, B13, D31) ----------------------
|
||||
['qualite.collisions.graphique', "Collisions d'appartenance, par appartenance"],
|
||||
['qualite.collisions.axe', 'Collisions cumulées (paire et tour)'],
|
||||
['qualite.collisions.segment.prouve', 'prouvé inévitable'],
|
||||
['qualite.collisions.segment.reste', 'reste'],
|
||||
['qualite.collisions.colonne.appartenance', 'Appartenance'],
|
||||
['qualite.collisions.colonne.effectif', 'Effectif'],
|
||||
['qualite.collisions.colonne.cumulees', 'Collisions cumulées'],
|
||||
['qualite.collisions.colonne.prouve', 'Prouvé inévitable'],
|
||||
['qualite.collisions.colonne.reste', 'Reste'],
|
||||
['qualite.collisions.colonne.pairesDistinctes', 'Paires distinctes'],
|
||||
['qualite.collisions.colonne.excedent', 'Excédent de collisions'],
|
||||
[
|
||||
'qualite.collisions.synthese',
|
||||
({ appartenance, excedent }) => `L'excédent le plus grand est celui de « ${appartenance} » : ${compte(excedent, 'collision', 'collisions')}.`,
|
||||
],
|
||||
[
|
||||
'qualite.collisions.horsDomaine',
|
||||
"Ce placement laisse quelqu'un en réserve ou n'honore pas une réservation : le plancher du diagnostic ne s'y applique pas, et chaque barre n'a qu'un segment, les collisions cumulées.",
|
||||
],
|
||||
['qualite.collisions.aucune', "Aucune collision d'appartenance : à chaque tour, chaque table réunit des appartenances distinctes."],
|
||||
['qualite.collisions.aucuneAppartenance', "Aucune appartenance : il n'y a pas de collision à montrer."],
|
||||
['qualite.collisions.uneTable', 'Une seule table : chacun y retrouve chaque membre de son appartenance, à chaque tour.'],
|
||||
|
||||
// --- Les appartenances à chaque table (§ 12.11, B20, D35, D54) -------------
|
||||
['qualite.tables.selecteur', 'Ce que la grille compte'],
|
||||
['qualite.tables.position.sieges', 'appartenances par siège'],
|
||||
['qualite.tables.position.assis', 'appartenances par personne assise'],
|
||||
['qualite.tables.position.doublons', 'doublons parmi les affiliés'],
|
||||
['qualite.tables.compte.sieges', 'sièges sans appartenance nouvelle'],
|
||||
['qualite.tables.compte.assis', 'assis sans appartenance nouvelle'],
|
||||
['qualite.tables.compte.doublons', "doublons d'appartenance"],
|
||||
[
|
||||
'qualite.tables.ligne.sieges',
|
||||
({ appartenances, sieges }) => `${compte(appartenances, 'appartenance', 'appartenances')} pour ${compte(sieges, 'siège', 'sièges')}`,
|
||||
],
|
||||
[
|
||||
'qualite.tables.ligne.assis',
|
||||
({ appartenances, assis }) => `${compte(appartenances, 'appartenance', 'appartenances')} pour ${assis} assis`,
|
||||
],
|
||||
[
|
||||
'qualite.tables.ligne.doublons',
|
||||
({ appartenances, affilies }) => `${compte(appartenances, 'appartenance', 'appartenances')} pour ${compte(affilies, 'affilié', 'affiliés')}`,
|
||||
],
|
||||
['qualite.tables.graphique', 'Appartenances à chaque table, tables par tours'],
|
||||
['qualite.tables.table', ({ numero }) => `Table ${numero}`],
|
||||
['qualite.tables.tour', ({ tour }) => `Tour ${tour}`],
|
||||
['qualite.tables.colonne.occupants', 'Occupants'],
|
||||
['qualite.tables.colonne.affilies', 'Affiliés'],
|
||||
['qualite.tables.colonne.capacite', 'Capacité'],
|
||||
[
|
||||
'qualite.tables.synthese',
|
||||
({ numero, valeur }) => `La table ${numero} porte la case la plus haute : ${valeur}.`,
|
||||
],
|
||||
['qualite.tables.tropLarge', 'La grille ne tient pas dans la largeur de la carte : son tableau jumeau la remplace.'],
|
||||
['qualite.tables.aucuneAppartenance', "Aucune appartenance : aucune table n'a d'appartenance à mélanger."],
|
||||
['qualite.tables.uneTable', 'Une seule table : elle réunit toutes les appartenances à chaque tour.'],
|
||||
|
||||
// --- Les paires répétées, la matrice (§ 12.5, B14, D32, D33, D50) ----------
|
||||
['qualite.paires.choisies', "Paires que la recherche réunit plus d'une fois"],
|
||||
['qualite.paires.imposees', 'Paires que les réservations imposent'],
|
||||
['qualite.paires.colonne.paire', 'Paire'],
|
||||
['qualite.paires.colonne.fois', 'Rencontres de la paire'],
|
||||
['qualite.paires.colonne.tours', 'Tours'],
|
||||
['qualite.paires.premierTour', ({ tour }) => `Voir au tour ${tour}`],
|
||||
['qualite.paires.tour', ({ tour }) => `Tour ${tour}`],
|
||||
['qualite.paires.fois', ({ fois }) => `${fois} fois`],
|
||||
['qualite.paires.aucune', 'Aucune paire ne se revoit : chaque rencontre est unique.'],
|
||||
['qualite.paires.uneTable', 'Une seule table : chaque paire se revoit à chaque tour.'],
|
||||
[
|
||||
'qualite.paires.pages.etendue',
|
||||
({ premier, dernier, total }) => (premier === dernier ? `Paire ${premier} sur ${total}` : `Paires ${premier} à ${dernier} sur ${total}`),
|
||||
],
|
||||
['qualite.paires.matrice.titre', 'Qui a rencontré qui'],
|
||||
['qualite.paires.matrice.diagonale', 'la personne elle-même'],
|
||||
[
|
||||
'qualite.paires.matrice.seuil',
|
||||
({ participants, seuil }) =>
|
||||
`La matrice n'est pas dessinée : ${participants} personnes dépassent le seuil de ${seuil}. La fiche de chaque personne porte ses rencontres.`,
|
||||
],
|
||||
[
|
||||
'qualite.paires.matrice.etroite',
|
||||
"La matrice n'est pas dessinée : ses cases ne tiendraient pas dans la largeur de la carte. La fiche de chaque personne porte ses rencontres.",
|
||||
],
|
||||
|
||||
// --- Les objectifs des tables, tour par tour (§ 12.12, B21, D36) -----------
|
||||
['qualite.objectifs.jamaisServies', 'Personnes jamais servies'],
|
||||
['qualite.objectifs.toursPossibles', ({ possibles }) => compte(possibles, 'tour possible', 'tours possibles')],
|
||||
['qualite.objectifs.pire', ({ personnes }) => compte(personnes, 'personne jamais servie', 'personnes jamais servies')],
|
||||
[
|
||||
'qualite.objectifs.comptes',
|
||||
({ servis, possibles }) => `${compte(servis, 'servi', 'servis')} sur ${compte(possibles, 'possible', 'possibles')}`,
|
||||
],
|
||||
[
|
||||
'qualite.objectifs.horsObjectif',
|
||||
({ hors, plancher }) => `${compte(hors, 'tour hors objectif', 'tours hors objectif')}, plancher ${plancher}`,
|
||||
],
|
||||
['qualite.objectifs.soiree', 'La soirée'],
|
||||
['qualite.objectifs.tour', ({ tour }) => `Tour ${tour}`],
|
||||
['qualite.objectifs.assisSansTable', ({ assis }) => `${assis} assis sans table possible`],
|
||||
['qualite.objectifs.sansCouple', 'aucun couple possible à ce tour'],
|
||||
['qualite.objectifs.segment.prouve', 'prouvé inévitable'],
|
||||
['qualite.objectifs.segment.reste', 'reste'],
|
||||
['qualite.objectifs.graphique', 'Tours hors objectif, tour par tour'],
|
||||
['qualite.objectifs.colonne.possibles', 'Possibles'],
|
||||
['qualite.objectifs.colonne.servis', 'Servis'],
|
||||
['qualite.objectifs.colonne.pourcent', 'Des tours possibles servis'],
|
||||
['qualite.objectifs.colonne.hors', 'Hors objectif'],
|
||||
['qualite.objectifs.colonne.plancher', 'Plancher'],
|
||||
['qualite.objectifs.colonne.assis', 'Assis sans table possible'],
|
||||
['qualite.objectifs.repartition.titre', "Répartition des tours hors objectif d'une personne"],
|
||||
['qualite.objectifs.repartition.valeur', 'Tours hors objectif'],
|
||||
['qualite.objectifs.repartition.effectif', 'Effectif'],
|
||||
[
|
||||
'qualite.objectifs.critereDecoche',
|
||||
"Le critère des tours hors objectif n'est pas coché : cette vue ne classe pas.",
|
||||
],
|
||||
[
|
||||
'qualite.objectifs.aucunConcerne',
|
||||
"Aucune personne n'est concernée par les objectifs des tables : la vue n'a rien à montrer.",
|
||||
],
|
||||
[
|
||||
'qualite.objectifs.horsDomaine',
|
||||
"Ce placement laisse quelqu'un en réserve ou n'honore pas une réservation : le plancher des tours ne s'y applique pas, et chaque barre n'a qu'un segment.",
|
||||
],
|
||||
|
||||
// --- Les animateurs rencontrés (§ 12.13, B22, D37) -------------------------
|
||||
['qualite.animateurs.graphique', 'Animateurs rencontrés, de la personne la moins servie à la mieux servie'],
|
||||
['qualite.animateurs.serie', 'animateurs rencontrés'],
|
||||
['qualite.animateurs.repere.borne', 'borne des animateurs'],
|
||||
['qualite.animateurs.colonne.nom', 'Personne'],
|
||||
['qualite.animateurs.colonne.rencontres', 'Animateurs rencontrés'],
|
||||
['qualite.animateurs.colonne.borne', 'Borne des animateurs'],
|
||||
['qualite.animateurs.colonne.statut', 'Statut'],
|
||||
['qualite.animateurs.repartition.titre', 'Répartition des animateurs rencontrés'],
|
||||
[
|
||||
'qualite.animateurs.synthese',
|
||||
({ minimum, effectif }) =>
|
||||
`Minimum ${compte(minimum, 'animateur rencontré', 'animateurs rencontrés')}, ${compte(effectif, 'personne', 'personnes')} au minimum.`,
|
||||
],
|
||||
[
|
||||
'qualite.animateurs.aucun',
|
||||
"Aucune personne n'est concernée par les animateurs, ou aucun animateur n'est désigné : la vue n'a rien à montrer.",
|
||||
],
|
||||
|
||||
// --- La diversité par animateur (§ 12.13, B22, D38) ------------------------
|
||||
['qualite.diversite.graphique', 'Diversité rencontrée en animant, sur la soirée'],
|
||||
['qualite.diversite.colonne.appartenances', 'Appartenances rencontrées en animant'],
|
||||
['qualite.diversite.colonne.etiquettes', 'Étiquettes rencontrées en animant'],
|
||||
['qualite.diversite.colonne.toursAnimes', 'Tours animés'],
|
||||
['qualite.diversite.colonne.personnes', 'Personnes rencontrées en animant'],
|
||||
['qualite.diversite.moins', 'le moins varié'],
|
||||
['qualite.diversite.plus', 'le plus varié'],
|
||||
['qualite.diversite.auMinimum', 'au minimum'],
|
||||
['qualite.diversite.soiree', 'la soirée'],
|
||||
['qualite.diversite.tour', ({ tour, table }) => `tour ${tour}, table ${table}`],
|
||||
['qualite.diversite.toursAnimes', ({ tours }) => compte(tours, 'tour animé', 'tours animés')],
|
||||
['qualite.diversite.personnes', ({ personnes }) => `${compte(personnes, 'personne rencontrée', 'personnes rencontrées')} en animant`],
|
||||
[
|
||||
'qualite.diversite.sansEtiquette',
|
||||
"Aucune personne présente ne porte d'étiquette : seule la colonne des appartenances se dessine.",
|
||||
],
|
||||
[
|
||||
'qualite.diversite.sansAppartenance',
|
||||
'Une appartenance au plus : seule la colonne des étiquettes se dessine, triée sur les étiquettes.',
|
||||
],
|
||||
[
|
||||
'qualite.diversite.rien',
|
||||
"Ni étiquette, ni plus d'une appartenance : la diversité par animateur n'a rien à dessiner. Son tableau jumeau garde les tours animés et les personnes rencontrées en animant.",
|
||||
],
|
||||
['qualite.diversite.aucunAnimateur', "Aucun animateur n'est désigné : la vue n'a rien à montrer."],
|
||||
|
||||
// --- La performance du calcul (§ 12.14, B23, D40, D49) ---------------------
|
||||
['qualite.performance.mouvements', ({ evalues, arret }) => `${evalues} mouvements évalués sur ${arret}`],
|
||||
['qualite.performance.duree', ({ duree }) => `durée observée ${duree}`],
|
||||
['qualite.performance.dureeInconnue', 'durée inconnue'],
|
||||
['qualite.performance.cadence', ({ cadence }) => `cadence ${cadence} mouvements par seconde`],
|
||||
['qualite.performance.coeurs', ({ coeurs }) => compte(coeurs, 'cœur de calcul', 'cœurs de calcul')],
|
||||
['qualite.performance.propositions', ({ nombre }) => `génération de ${compte(nombre, 'proposition', 'propositions')}`],
|
||||
[
|
||||
'qualite.performance.rienAReprendre',
|
||||
({ evalues, arret }) => `rien à reprendre après ${evalues} mouvements sur ${arret}`,
|
||||
],
|
||||
['qualite.performance.planInitial', "le plan initial n'avait rien à reprendre"],
|
||||
[
|
||||
'qualite.performance.dernierChangement',
|
||||
({ de, a }) => `aucune valeur n'a changé entre ${de} et ${a} mouvements ; une autre graine est l'autre façon de chercher`,
|
||||
],
|
||||
['qualite.performance.changeAuDernier', ({ a }) => `une valeur a changé au dernier point de relevé, à ${a} mouvements`],
|
||||
['qualite.performance.graphique', ({ indicateur }) => `${indicateur}, aux points de relevé`],
|
||||
['qualite.performance.axe', 'Mouvements évalués, échelle logarithmique'],
|
||||
['qualite.performance.minimumBrut', 'minimum brut'],
|
||||
['qualite.performance.colonne.mouvements', 'Mouvements évalués'],
|
||||
[
|
||||
'qualite.performance.synthese',
|
||||
"Valeurs mesurées aux seuls points de relevé, sous les réglages courants, reliées par un trait : entre deux points, rien n'est mesuré.",
|
||||
],
|
||||
[
|
||||
'qualite.performance.sansCourse',
|
||||
"Sa course n'est pas gardée.",
|
||||
],
|
||||
[
|
||||
'qualite.performance.retenu',
|
||||
({ proposition }) =>
|
||||
`La vue mesure une proposition : le placement retenu vient de la proposition n° ${proposition}, dont elle montre la course.`,
|
||||
],
|
||||
[
|
||||
'qualite.performance.retenuSansCourse',
|
||||
"La vue mesure une proposition : le placement retenu a été ajusté, ou sa proposition effacée, et n'a pas de course.",
|
||||
],
|
||||
]);
|
||||
|
|
@ -528,6 +528,37 @@ describe('retenir sur un retenu ajusté', () => {
|
|||
|
||||
// --- Effacer (§ 5.7) ----------------------------------------------------------------
|
||||
|
||||
// --- « Qualité », sur chaque ligne (§ 12, D25) ------------------------------------
|
||||
|
||||
describe('« Qualité », sur chaque ligne', () => {
|
||||
test('chaque ligne — classée, hors classement, celle du retenu — offre « Qualité » après « Voir sur le plan » ; elle montre sa proposition au plan, ou le retenu, et ouvre la page de qualité, sans écriture', async () => {
|
||||
let chargeLue = executer(avecPlans(chargeDemo('petite-conflit'), PLANS_CONFLIT), 'retenirProposition', { id: 3 });
|
||||
chargeLue = executer(chargeLue, 'exclureParticipant', { id: 12 });
|
||||
chargeLue = generee(chargeLue, { graine: 2, nombre: 2, arret: 40 });
|
||||
const m = await ouvrir(chargeLue);
|
||||
const ecritures = m.trace.length;
|
||||
expect(section(m).querySelector('[data-hors-classement] tr[data-proposition]')).not.toBeNull();
|
||||
const rangees = [ligneRetenu(m), ...lignes(m)];
|
||||
expect(rangees.length).toBe(1 + chargeLue.propositions.length);
|
||||
for (const tr of rangees) {
|
||||
const noms = [...tr.querySelectorAll('td[data-colonne="commandes"] [data-commande]')].map((bouton) => bouton.dataset.commande);
|
||||
expect(noms.slice(0, 2), tr.dataset.proposition ?? 'retenu').toEqual(['voir', 'qualite']);
|
||||
expect(texteDe(commande(tr, 'qualite'))).toBe(t('propositions.commande.qualite'));
|
||||
}
|
||||
for (const id of [...ids(m), null]) {
|
||||
m.ui.allerA('propositions');
|
||||
flushSync();
|
||||
const tr = id === null ? ligneRetenu(m) : ligne(m, id);
|
||||
commande(tr, 'qualite').click();
|
||||
await attendre(() => expect(m.racine.querySelector('[data-section="qualite"]')).not.toBeNull());
|
||||
expect(m.ui.etat().proposition).toBe(id);
|
||||
expect(m.racine.querySelector('[data-refus]')).toBeNull();
|
||||
}
|
||||
expect(m.trace.length).toBe(ecritures);
|
||||
expect(charge(m).retenu.proposition).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('effacer', () => {
|
||||
test('retenir la 2, effacer : seule la 2 reste, charge.retenu intact, une entrée', async () => {
|
||||
const m = await ouvrir(avecPlans(chargeDemo('petite-conflit'), PLANS_CONFLIT));
|
||||
|
|
|
|||
|
|
@ -14,17 +14,20 @@
|
|||
// vue : ce que rend tableau(), libellés et étendues compris — ses colonnes
|
||||
// et ses groupes, ceux que porte la table, le sixième groupe selon les
|
||||
// critères qui comptent —, ses lignes et ses lignes hors classement
|
||||
// réduites à celles de la page montrée
|
||||
// (pagine, modele.js) ; chaque ligne garde son rang global. nommer(id) :
|
||||
// le nom affiché d'une personne, null pour id null ou pour une personne que
|
||||
// la charge n'a plus. voir(id) et
|
||||
// réduites à celles de la page montrée (pagine, modele.js) ; chaque ligne
|
||||
// garde son rang global. nommer(id) : le nom affiché d'une personne, null
|
||||
// pour id null ou pour une personne que la charge n'a plus. voir(id) et
|
||||
// retenir(id) : les gestes d'une ligne, que la section câble ; voir(null)
|
||||
// montre le retenu. Un refus de « Retenir » se pose à côté du bouton, par
|
||||
// la Commande. surlignee : le nom de la colonne que la phrase de l'ordre
|
||||
// désigne — une colonne de mesure, ou celle des propositions —, null sans
|
||||
// surlignage ; son en-tête et chacune de ses cellules se surlignent, hors
|
||||
// la cellule des raisons d'une ligne hors classement, qui en couvre
|
||||
// plusieurs.
|
||||
// la Commande. « Qualité », sur chaque ligne, demande sa proposition au
|
||||
// plan — le retenu sur la ligne du retenu — et ouvre la page de qualité,
|
||||
// qui mesure la proposition de sa ligne, ou le retenu (§ 12), que le plan
|
||||
// la montre ou non : l'état de navigation seul change, sans écriture ni
|
||||
// refus, en lecture comme sur un plan bloqué. surlignee : le nom de la
|
||||
// colonne que la phrase de l'ordre désigne — une colonne de mesure, ou
|
||||
// celle des propositions —, null sans surlignage ; son en-tête et chacune
|
||||
// de ses cellules se surlignent, hors la cellule des raisons d'une ligne
|
||||
// hors classement, qui en couvre plusieurs.
|
||||
//
|
||||
// Chaque en-tête explique sa colonne dans l'infobulle de la section : il se
|
||||
// focalise au clavier (tabindex 0), et son survol ou son focus appelle
|
||||
|
|
@ -63,8 +66,16 @@
|
|||
|
||||
const t = getContext('t');
|
||||
const format = getContext('format');
|
||||
const ui = getContext('ui');
|
||||
const outils = { t, format };
|
||||
|
||||
// Demande la proposition id au plan, ou le retenu pour null, puis ouvre
|
||||
// la page de qualité.
|
||||
function qualite(id) {
|
||||
ui.montrerProposition(id);
|
||||
ui.allerA('qualite');
|
||||
}
|
||||
|
||||
// Les noms des colonnes de nombres.
|
||||
const nombres = $derived(new Set(vue.colonnes.filter((colonne) => colonne.nombre).map((colonne) => colonne.nom)));
|
||||
</script>
|
||||
|
|
@ -119,6 +130,7 @@
|
|||
<td data-colonne="commandes">
|
||||
<div class="commandes">
|
||||
<Commande cle="propositions.commande.voir" nom="voir" executer={() => voir(ligne.id)} />
|
||||
<Commande cle="propositions.commande.qualite" nom="qualite" executer={() => qualite(ligne.id)} />
|
||||
<Commande cle="propositions.commande.retenir" executer={() => retenir(ligne.id)} />
|
||||
</div>
|
||||
</td>
|
||||
|
|
@ -175,6 +187,7 @@
|
|||
<td data-colonne="commandes">
|
||||
<div class="commandes">
|
||||
<Commande cle="propositions.commande.voir" nom="voir" executer={() => voir(null)} />
|
||||
<Commande cle="propositions.commande.qualite" nom="qualite" executer={() => qualite(null)} />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
|
|||
466
src/interface/qualite/Qualite.navigateur.test.js
Normal file
|
|
@ -0,0 +1,466 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// La section de la qualité dans un vrai moteur de rendu (§ 12, B9, D24,
|
||||
// D25, § 8.4) : la page vit en lecture, en écriture et sur un plan bloqué,
|
||||
// sans refus ni écriture ; elle mesure la source que le plan montre et la
|
||||
// nomme sur sa ligne de source ; la commande « Qualité » du tableau des
|
||||
// propositions l'ouvre sur sa proposition ; les quatre cas sans source
|
||||
// mesurable ne dessinent aucune vue ; une génération qui retire la
|
||||
// proposition montrée rend la page au plan ; les neuf vues d'attente,
|
||||
// chacune sous son [data-vue-qualite], et la liste qui mène à chacune ;
|
||||
// aucune règle d'impression ne la vise. Chaque épreuve monte App par
|
||||
// monterApp, sur le système de fichiers simulé où un fichier d'état
|
||||
// d'épreuve est posé puis ouvert, au cadre de 1 366 × 700, et lit le rendu :
|
||||
// le texte, les attributs data-…, l'élément focalisé — jamais une classe.
|
||||
// Le texte attendu vient de la même traduction que celle de l'application ;
|
||||
// les noms d'épreuve sont inventés.
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
|
||||
import { flushSync } from 'svelte';
|
||||
import { appliquerCommande } from '../../application/commandes.js';
|
||||
import { evaluer, premiereDuClassement } 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 '../impression.css';
|
||||
import { TABLES_INTERFACE } from '../libelles.js';
|
||||
import { sourceQualite } from './source.js';
|
||||
|
||||
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
|
||||
const V1 = VERSION.affichee;
|
||||
const BASE = 'soiree';
|
||||
|
||||
// Les vues de la page, dans leur ordre à l'écran (§ 12, B9).
|
||||
const VUES = ['entete', 'profil', 'collisions', 'tables', 'paires', 'objectifs', 'animateurs', 'diversite', 'performance'];
|
||||
|
||||
// --- Les charges d'épreuve -------------------------------------------------------
|
||||
|
||||
// Six personnes inventées, deux appartenances, deux tables de trois places,
|
||||
// deux tours, sans proposition ni retenu.
|
||||
function chargeSix() {
|
||||
const charge = creerCharge({ id: 'evt-qualite', nom: 'Atelier Ressac', siegesParDefaut: 3, tours: 2 });
|
||||
const noms = [
|
||||
['Ombrelle', 'Iris', 'Coopérative Aubier'],
|
||||
['Pervenche', 'Théo', 'Coopérative Aubier'],
|
||||
['Lacasse', 'Ondine', 'Atelier Ressac'],
|
||||
['Tourteau', 'Basile', 'Atelier Ressac'],
|
||||
['Quenouille', 'Maëlle', null],
|
||||
['Varech', 'Sidonie', null],
|
||||
];
|
||||
charge.participants = noms.map(([nom, prenom, appartenance], rang) => ({
|
||||
id: rang + 1,
|
||||
nom,
|
||||
prenom,
|
||||
appartenance,
|
||||
courriel: null,
|
||||
titrePressenti: null,
|
||||
notes: null,
|
||||
exclu: false,
|
||||
}));
|
||||
charge.tables = [1, 2].map((id) => ({ id, numero: id, sieges: null, forme: 'ronde', position: { x: 300 * id, y: 0 } }));
|
||||
charge.prochainsIds = { participant: 7, table: 3, proposition: 1 };
|
||||
return charge;
|
||||
}
|
||||
|
||||
// La charge, une génération de plus, enregistrée par la commande.
|
||||
function generee(charge, reglages = { graine: 3, arret: 40, nombre: 3 }) {
|
||||
const configuration = configurationDepuisCharge(charge);
|
||||
const propositions = rechercher(configuration, reglages);
|
||||
return appliquerCommande('enregistrerGeneration', charge, { propositions, configuration, produitVersion: V1 }).charge;
|
||||
}
|
||||
|
||||
const executer = (charge, nom, args) => appliquerCommande(nom, charge, args).charge;
|
||||
|
||||
// Une copie de la charge, modifiée en place par change.
|
||||
function avec(charge, change) {
|
||||
const copie = structuredClone(charge);
|
||||
change(copie);
|
||||
return copie;
|
||||
}
|
||||
|
||||
// La charge générée, une proposition retenue, puis le plan bloqué.
|
||||
const bloquee = () => {
|
||||
const charge = generee(chargeSix());
|
||||
return executer(executer(charge, 'retenirProposition', { id: charge.propositions[1].id }), 'changerEtat', { etat: 'bloque' });
|
||||
};
|
||||
|
||||
// --- Le montage --------------------------------------------------------------------
|
||||
|
||||
// Les montages de l'épreuve, démontés après elle ; le collecteur d'erreurs
|
||||
// n'a rien reçu pendant l'épreuve.
|
||||
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([]);
|
||||
});
|
||||
|
||||
// Attend que l'assertion passe, le rendu de Svelte à jour.
|
||||
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 par la séance — en écriture
|
||||
// sauf mode 'lecture' —, la section donnée ouverte.
|
||||
async function ouvrir(charge, { mode = 'ecriture', section = 'qualite' } = {}) {
|
||||
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();
|
||||
m.ui.allerA(section);
|
||||
flushSync();
|
||||
return m;
|
||||
}
|
||||
|
||||
// --- Les lectures du rendu ---------------------------------------------------------
|
||||
|
||||
const page = (m) => m.racine.querySelector('[data-section="qualite"]');
|
||||
const commande = (racine, nom) => racine.querySelector(`[data-commande="${nom}"]`);
|
||||
const texteDe = (element) => element.textContent.replace(/\s+/g, ' ').trim();
|
||||
const vuesRendues = (m) => [...page(m).querySelectorAll('[data-vue-qualite]')].map((vue) => vue.dataset.vueQualite);
|
||||
const phrases = (m) => [...page(m).querySelectorAll('[data-phrase-qualite]')].map((p) => p.dataset.phraseQualite);
|
||||
const ligneDeSource = (m) => texteDe(page(m).querySelector('[data-source-qualite]'));
|
||||
const attendueDeSource = (cle, details = {}) => `${t('qualite.source.intitule')} ${t(cle, details)}`;
|
||||
const charge = (m) => m.seance.etat().charge;
|
||||
|
||||
// Les règles d'impression des feuilles du document : le texte de chaque
|
||||
// règle @media print, à toute profondeur.
|
||||
function reglesImpression() {
|
||||
const textes = [];
|
||||
const parcourir = (regles) => {
|
||||
for (const regle of regles) {
|
||||
if (regle instanceof CSSMediaRule && /\bprint\b/.test(regle.conditionText)) textes.push(regle.cssText);
|
||||
else if (regle.cssRules !== undefined) parcourir(regle.cssRules);
|
||||
}
|
||||
};
|
||||
for (const feuille of document.styleSheets) parcourir(feuille.cssRules);
|
||||
return textes;
|
||||
}
|
||||
|
||||
// --- Les épreuves -----------------------------------------------------------------
|
||||
|
||||
describe('la section de la qualité dans la navigation (§ 2.1, D24)', () => {
|
||||
test('« Qualité » est le cinquième onglet de la phase Placer, après « Propositions » ; il ouvre la section, titrée', async () => {
|
||||
const m = await ouvrir(generee(chargeSix()), { section: 'plan' });
|
||||
const onglets = [...m.racine.querySelectorAll('[data-commande="section"]')].map((bouton) => bouton.dataset.cible);
|
||||
expect(onglets.indexOf('qualite')).toBe(onglets.indexOf('propositions') + 1);
|
||||
const onglet = m.racine.querySelector('[data-commande="section"][data-cible="qualite"]');
|
||||
expect(texteDe(onglet)).toBe(t('coquille.section.qualite'));
|
||||
const groupe = onglet.closest('[role="group"]');
|
||||
expect([...groupe.querySelectorAll('[data-commande="section"]')].map((bouton) => bouton.dataset.cible)).toEqual([
|
||||
'plan', 'diagnostic', 'generation', 'propositions', 'qualite',
|
||||
]);
|
||||
onglet.click();
|
||||
await attendre(() => expect(page(m)).not.toBeNull());
|
||||
expect(m.ui.etat().section).toBe('qualite');
|
||||
expect(texteDe(page(m).querySelector('h2'))).toBe(t('coquille.section.qualite'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('vivante en lecture, en écriture et sur un plan bloqué (§ 8.4, B9)', () => {
|
||||
for (const [cas, fabrique, mode] of [
|
||||
['en lecture', () => generee(chargeSix()), 'lecture'],
|
||||
['en écriture', () => generee(chargeSix()), 'ecriture'],
|
||||
['sur un plan bloqué', bloquee, 'lecture'],
|
||||
]) {
|
||||
test(`${cas} : la page se lit, ses vues sont là, sans refus ni écriture`, async () => {
|
||||
const lue = fabrique();
|
||||
const m = await ouvrir(lue, { mode });
|
||||
const ecritures = m.trace.length;
|
||||
if (lue.retenu === null) {
|
||||
commande(page(m), 'montrerPremiere').click();
|
||||
flushSync();
|
||||
}
|
||||
await attendre(() => expect(vuesRendues(m)).toEqual(VUES));
|
||||
expect(page(m).querySelector('[data-refus]')).toBeNull();
|
||||
for (const lien of page(m).querySelectorAll('nav a')) {
|
||||
lien.click();
|
||||
flushSync();
|
||||
}
|
||||
expect(page(m).querySelector('[data-refus]')).toBeNull();
|
||||
expect(m.trace.length).toBe(ecritures);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('la commande « Qualité » du tableau des propositions (§ 12, D25)', () => {
|
||||
test('en lecture, puis sur un plan bloqué : elle montre sa proposition au plan et ouvre la page, sans refus ni entrée au journal', async () => {
|
||||
for (const [lue, mode] of [
|
||||
[generee(chargeSix()), 'lecture'],
|
||||
[bloquee(), 'lecture'],
|
||||
]) {
|
||||
const m = await ouvrir(lue, { mode, section: 'propositions' });
|
||||
const ecritures = m.trace.length;
|
||||
const id = lue.propositions[2].id;
|
||||
const bouton = commande(m.racine.querySelector(`[data-proposition="${id}"]`), 'qualite');
|
||||
expect(texteDe(bouton)).toBe(t('propositions.commande.qualite'));
|
||||
bouton.click();
|
||||
await attendre(() => expect(page(m)).not.toBeNull());
|
||||
expect(m.ui.etat().proposition).toBe(id);
|
||||
expect(m.racine.querySelector('[data-refus]')).toBeNull();
|
||||
const cle = lue.retenu === null ? 'plan.source.propositionReservations' : 'plan.source.propositionVueSeule';
|
||||
expect(ligneDeSource(m)).toBe(attendueDeSource(cle, { proposition: id }));
|
||||
expect(m.trace.length).toBe(ecritures);
|
||||
}
|
||||
});
|
||||
|
||||
test('la page s’ouvre sur la source que le plan montre : la ligne de source et la barre de vue du plan nomment la même proposition', async () => {
|
||||
const m = await ouvrir(generee(chargeSix()), { section: 'propositions' });
|
||||
for (const proposition of charge(m).propositions) {
|
||||
m.ui.allerA('propositions');
|
||||
flushSync();
|
||||
commande(m.racine.querySelector(`[data-proposition="${proposition.id}"]`), 'qualite').click();
|
||||
await attendre(() => expect(page(m)).not.toBeNull());
|
||||
expect(ligneDeSource(m)).toBe(attendueDeSource('plan.source.propositionReservations', { proposition: proposition.id }));
|
||||
m.ui.allerA('plan');
|
||||
await attendre(() => expect(m.racine.querySelector('[data-source]')).not.toBeNull());
|
||||
expect(texteDe(m.racine.querySelector('[data-source]'))).toContain(t('plan.source.propositionReservations', { proposition: proposition.id }));
|
||||
}
|
||||
});
|
||||
|
||||
// Une proposition fautive ne se lit pas d'un fichier, l'ouverture l'écarte :
|
||||
// la ligne hors classement qu'une séance porte est en dérive. La source
|
||||
// fautive demandée est éprouvée sous node (source.test.js).
|
||||
test('« Qualité » sur la ligne hors classement d’une proposition en dérive : la page donne ses raisons, sans retenu comme sous un retenu', async () => {
|
||||
const generees = generee(chargeSix());
|
||||
const id = generees.propositions[0].id;
|
||||
const exclue = (c) => (c.participants[5].exclu = true);
|
||||
const retenue = executer(generees, 'retenirProposition', { id: generees.propositions[1].id });
|
||||
for (const lue of [avec(generees, exclue), avec(retenue, exclue)]) {
|
||||
const m = await ouvrir(lue, { mode: 'lecture', section: 'propositions' });
|
||||
const ligne = await attendre(() => {
|
||||
const trouvee = m.racine.querySelector(`[data-hors-classement] [data-proposition="${id}"]`);
|
||||
expect(trouvee).not.toBeNull();
|
||||
return trouvee;
|
||||
});
|
||||
commande(ligne, 'qualite').click();
|
||||
await attendre(() => expect(page(m)).not.toBeNull());
|
||||
expect(m.ui.etat().proposition).toBe(id);
|
||||
expect(phrases(m)).toEqual(['derive']);
|
||||
const raisons = evaluer(lue).propositions.find((candidate) => candidate.id === id).raisons;
|
||||
expect(raisons.length).toBeGreaterThan(0);
|
||||
const textes = [...page(m).querySelectorAll('li')].map(texteDe);
|
||||
expect(textes.length).toBe(raisons.length);
|
||||
raisons.forEach((raison, rang) => expect(textes[rang]).toContain(t(raison.code, raison)));
|
||||
expect(vuesRendues(m)).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
test('un tour ajouté à l’événement : la page mesure le placement entier, en dérive au tour que la proposition ne porte pas comme au premier', async () => {
|
||||
const generees = generee(chargeSix());
|
||||
const id = generees.propositions[0].id;
|
||||
const lue = executer(generees, 'changerTours', { tours: 3 });
|
||||
const m = await ouvrir(lue, { mode: 'lecture', section: 'propositions' });
|
||||
const ligne = await attendre(() => {
|
||||
const trouvee = m.racine.querySelector(`[data-proposition="${id}"]`);
|
||||
expect(trouvee).not.toBeNull();
|
||||
return trouvee;
|
||||
});
|
||||
commande(ligne, 'qualite').click();
|
||||
await attendre(() => expect(page(m)).not.toBeNull());
|
||||
const raisons = evaluer(lue).propositions.find((candidate) => candidate.id === id).raisons;
|
||||
expect(raisons.length).toBeGreaterThan(0);
|
||||
for (const tour of [1, 3]) {
|
||||
m.ui.montrerTour(tour);
|
||||
flushSync();
|
||||
await attendre(() => expect(phrases(m), String(tour)).toEqual(['derive']));
|
||||
expect(ligneDeSource(m), String(tour)).toBe(attendueDeSource('plan.source.propositionReservations', { proposition: id }));
|
||||
expect(page(m).querySelectorAll('li').length, String(tour)).toBe(raisons.length);
|
||||
expect(vuesRendues(m), String(tour)).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
test('« Qualité » de la ligne du retenu ouvre la page sur le retenu, même après une proposition montrée', async () => {
|
||||
const generees = generee(chargeSix());
|
||||
const m = await ouvrir(executer(generees, 'retenirProposition', { id: generees.propositions[0].id }), { section: 'propositions' });
|
||||
commande(m.racine.querySelector(`[data-proposition="${generees.propositions[1].id}"]`), 'qualite').click();
|
||||
await attendre(() => expect(page(m)).not.toBeNull());
|
||||
m.ui.allerA('propositions');
|
||||
flushSync();
|
||||
commande(m.racine.querySelector('[data-retenu]'), 'qualite').click();
|
||||
await attendre(() => expect(page(m)).not.toBeNull());
|
||||
expect(m.ui.etat().proposition).toBe(null);
|
||||
expect(ligneDeSource(m)).toBe(attendueDeSource('plan.source.retenu'));
|
||||
expect(vuesRendues(m)).toEqual(VUES);
|
||||
});
|
||||
});
|
||||
|
||||
describe('les quatre cas sans source mesurable (§ 12, § 12.6, B9)', () => {
|
||||
test('ni proposition ni retenu : le diagnostic préalable et sa phrase, aucune vue, aucun axe', async () => {
|
||||
const m = await ouvrir(chargeSix());
|
||||
expect(phrases(m)).toEqual(['aucunPlacement']);
|
||||
expect(texteDe(page(m).querySelector('[data-phrase-qualite="aucunPlacement"]'))).toBe(t('qualite.phrase.aucunPlacement'));
|
||||
expect(texteDe(page(m))).toContain(t('coquille.section.diagnostic'));
|
||||
expect(page(m).querySelector('[data-enonce]')).not.toBeNull();
|
||||
expect(ligneDeSource(m)).toBe(attendueDeSource('plan.source.reservations'));
|
||||
expect(vuesRendues(m)).toEqual([]);
|
||||
expect(page(m).querySelector('svg, nav')).toBeNull();
|
||||
});
|
||||
|
||||
test('des propositions, aucune montrée : la commande montre la première comparable du classement, et la page la mesure', async () => {
|
||||
const generees = generee(chargeSix());
|
||||
// La première proposition par identifiant est fautive : la commande
|
||||
// vise la première comparable du classement, jamais elle.
|
||||
const lue = avec(generees, (c) => {
|
||||
const [premiere, seconde] = c.propositions[0].placement[0].sieges;
|
||||
seconde[0] = premiere[0];
|
||||
});
|
||||
const premiere = premiereDuClassement(evaluer(lue));
|
||||
expect(premiere.id).not.toBe(lue.propositions[0].id);
|
||||
expect(sourceQualite(lue).premiere).toBe(premiere.id);
|
||||
const m = await ouvrir(lue);
|
||||
expect(phrases(m)).toEqual(['aucuneMontree']);
|
||||
expect(vuesRendues(m)).toEqual([]);
|
||||
const montrer = commande(page(m), 'montrerPremiere');
|
||||
expect(texteDe(montrer)).toBe(t('qualite.source.montrerPremiere', { proposition: premiere.id }));
|
||||
montrer.click();
|
||||
await attendre(() => expect(vuesRendues(m)).toEqual(VUES));
|
||||
expect(m.ui.etat().proposition).toBe(premiere.id);
|
||||
expect(m.ui.etat().section).toBe('qualite');
|
||||
expect(ligneDeSource(m)).toBe(attendueDeSource('plan.source.propositionReservations', { proposition: premiere.id }));
|
||||
});
|
||||
|
||||
test('des propositions dont aucune ne se compare : la phrase seule, sans commande', async () => {
|
||||
const m = await ouvrir(avec(generee(chargeSix()), (c) => (c.participants[5].exclu = true)));
|
||||
expect(phrases(m)).toEqual(['aucuneMontree', 'aucuneComparable']);
|
||||
expect(commande(page(m), 'montrerPremiere')).toBeNull();
|
||||
expect(vuesRendues(m)).toEqual([]);
|
||||
});
|
||||
|
||||
test('une source en dérive : ses raisons, comme au tableau des propositions, et aucune vue', async () => {
|
||||
const lue = avec(generee(chargeSix()), (c) => (c.participants[5].exclu = true));
|
||||
const id = lue.propositions[0].id;
|
||||
const m = await ouvrir(lue);
|
||||
m.ui.montrerProposition(id);
|
||||
flushSync();
|
||||
await attendre(() => expect(phrases(m)).toEqual(['derive']));
|
||||
const raisons = evaluer(lue).propositions.find((ligne) => ligne.id === id).raisons;
|
||||
expect(raisons.length).toBeGreaterThan(0);
|
||||
const textes = [...page(m).querySelectorAll('li')].map(texteDe);
|
||||
expect(textes.length).toBe(raisons.length);
|
||||
raisons.forEach((raison, rang) => expect(textes[rang]).toContain(t(raison.code, raison)));
|
||||
expect(vuesRendues(m)).toEqual([]);
|
||||
});
|
||||
|
||||
test('un retenu fautif : ses fautes, et la ligne de source dit que le retenu ne se lit pas', async () => {
|
||||
const generees = generee(chargeSix());
|
||||
const lue = avec(executer(generees, 'retenirProposition', { id: generees.propositions[0].id }), (c) => {
|
||||
const [premiere, seconde] = c.retenu.placement[0].sieges;
|
||||
seconde[0] = premiere[0];
|
||||
});
|
||||
const m = await ouvrir(lue, { mode: 'lecture' });
|
||||
expect(phrases(m)).toEqual(['fautif']);
|
||||
expect(ligneDeSource(m)).toBe(attendueDeSource('plan.source.retenuFautif'));
|
||||
expect(page(m).querySelectorAll('li').length).toBe(evaluer(lue).retenu.raisons.length);
|
||||
expect(vuesRendues(m)).toEqual([]);
|
||||
});
|
||||
|
||||
test('une configuration qui ne se normalise pas l’emporte, même sur une proposition montrée : son refus, aucune vue', async () => {
|
||||
const lue = avec(generee(chargeSix()), (c) => {
|
||||
c.reservations.push(
|
||||
{ participant: 1, table: 1, siege: null, portee: 'tous', tour: null },
|
||||
{ participant: 1, table: 2, siege: null, portee: 'tour', tour: 1 },
|
||||
);
|
||||
});
|
||||
const m = await ouvrir(lue);
|
||||
m.ui.montrerProposition(lue.propositions[0].id);
|
||||
flushSync();
|
||||
expect(phrases(m)).toEqual(['configuration']);
|
||||
expect(page(m).querySelector('[data-refus]')).not.toBeNull();
|
||||
expect(vuesRendues(m)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('une source qui disparaît sous la page ouverte (§ 12, B9)', () => {
|
||||
test('une génération qui remplace la proposition montrée : la page suit le plan, qui revient aux réservations ; « Revenir » oublie la demande', async () => {
|
||||
const m = await ouvrir(generee(chargeSix()));
|
||||
const id = charge(m).propositions[0].id;
|
||||
m.ui.montrerProposition(id);
|
||||
await attendre(() => expect(vuesRendues(m)).toEqual(VUES));
|
||||
await m.seance.executer('effacerPropositions', {});
|
||||
const configuration = configurationDepuisCharge(charge(m));
|
||||
const propositions = rechercher(configuration, { graine: 11, arret: 40, nombre: 2 });
|
||||
await m.seance.executer('enregistrerGeneration', { propositions, configuration, produitVersion: V1 });
|
||||
await attendre(() => expect(phrases(m)).toEqual(['aucuneMontree']));
|
||||
expect(charge(m).propositions.some((proposition) => proposition.id === id)).toBe(false);
|
||||
expect(ligneDeSource(m)).toBe(attendueDeSource('plan.source.reservations'));
|
||||
expect(vuesRendues(m)).toEqual([]);
|
||||
const revenir = commande(page(m), 'revenir');
|
||||
expect(texteDe(revenir)).toBe(t('plan.source.revenirReservations'));
|
||||
revenir.click();
|
||||
flushSync();
|
||||
expect(m.ui.etat().proposition).toBe(null);
|
||||
expect(commande(page(m), 'revenir')).toBeNull();
|
||||
});
|
||||
|
||||
test('sous un retenu : la page revient au retenu', async () => {
|
||||
const generees = generee(chargeSix());
|
||||
const m = await ouvrir(executer(generees, 'retenirProposition', { id: generees.propositions[2].id }));
|
||||
m.ui.montrerProposition(generees.propositions[0].id);
|
||||
flushSync();
|
||||
expect(ligneDeSource(m)).toBe(attendueDeSource('plan.source.propositionVueSeule', { proposition: generees.propositions[0].id }));
|
||||
await m.seance.executer('effacerPropositions', {});
|
||||
await attendre(() => expect(ligneDeSource(m)).toBe(attendueDeSource('plan.source.retenu')));
|
||||
expect(vuesRendues(m)).toEqual(VUES);
|
||||
});
|
||||
});
|
||||
|
||||
describe('les vues d’attente et la liste qui y mène (§ 12, B9, B10)', () => {
|
||||
test('les neuf vues, dans leur ordre : chacune un <div> [data-vue-qualite] qui enveloppe la carte titrée de sa question', async () => {
|
||||
const generees = generee(chargeSix());
|
||||
const m = await ouvrir(executer(generees, 'retenirProposition', { id: generees.propositions[0].id }));
|
||||
expect(vuesRendues(m)).toEqual(VUES);
|
||||
for (const nom of VUES) {
|
||||
const vue = page(m).querySelector(`[data-vue-qualite="${nom}"]`);
|
||||
expect(vue.tagName, nom).toBe('DIV');
|
||||
const carte = vue.querySelector('section');
|
||||
expect(carte, nom).not.toBeNull();
|
||||
expect(texteDe(carte.querySelector('h3')), nom).toBe(t(`qualite.vue.${nom}.titre`));
|
||||
expect(texteDe(carte), nom).toContain(t(`qualite.vue.${nom}.question`));
|
||||
}
|
||||
});
|
||||
|
||||
test('la liste nomme chaque vue dans son ordre, et chaque lien porte le focus sur sa vue sans changer l’adresse', async () => {
|
||||
const generees = generee(chargeSix());
|
||||
const m = await ouvrir(executer(generees, 'retenirProposition', { id: generees.propositions[0].id }));
|
||||
const adresse = window.location.href;
|
||||
const liens = [...page(m).querySelectorAll('nav a')];
|
||||
expect(liens.map(texteDe)).toEqual(VUES.map((nom) => t(`qualite.vue.${nom}.titre`)));
|
||||
for (const [rang, lien] of liens.entries()) {
|
||||
lien.click();
|
||||
flushSync();
|
||||
expect(document.activeElement.querySelector('[data-vue-qualite]')?.dataset.vueQualite).toBe(VUES[rang]);
|
||||
}
|
||||
expect(window.location.href).toBe(adresse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('la page ne s’imprime pas (§ 11.7, B8)', () => {
|
||||
test('aucune règle d’impression des feuilles du document ne vise la section de la qualité', async () => {
|
||||
const generees = generee(chargeSix());
|
||||
await ouvrir(executer(generees, 'retenirProposition', { id: generees.propositions[0].id }));
|
||||
const regles = reglesImpression();
|
||||
expect(regles.length).toBeGreaterThan(0);
|
||||
expect(regles.filter((texte) => /qualite/i.test(texte))).toEqual([]);
|
||||
});
|
||||
});
|
||||
275
src/interface/qualite/SectionQualite.svelte
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||
|
||||
<script>
|
||||
// La section de la qualité (§ 12, B9, D24, D25) : la page qui mesure la
|
||||
// proposition demandée au plan, sinon le retenu, que le plan la montre ou
|
||||
// non. Elle ne modifie rien : elle se lit en lecture, en écriture et sur un
|
||||
// plan bloqué, et reste ouverte pendant une génération ; aucune commande
|
||||
// n'y passe par la séance (§ 8.4).
|
||||
//
|
||||
// La source se lit par sourceQualite (source.js), sur la charge de l'état
|
||||
// observable et la proposition demandée au plan — le placement entier, à
|
||||
// chaque tour du plan : la ligne de source reprend le libellé de la barre
|
||||
// de vue du plan, ou dit qu'une proposition fautive n'y est pas montrée,
|
||||
// et sa commande de retour ne montre plus la proposition demandée. Sans
|
||||
// source mesurable, la page ne dessine aucune vue, et un seul de ses cas
|
||||
// s'écrit, chacun par sa phrase [data-phrase-qualite] :
|
||||
//
|
||||
// - configuration : la configuration ne se normalise pas, et le refus de
|
||||
// la génération, son code traduit ;
|
||||
// - aucunPlacement : ni proposition ni retenu, et le diagnostic préalable,
|
||||
// sous sa forme compacte ;
|
||||
// - aucuneMontree : des propositions, aucune demandée au plan ni retenue,
|
||||
// et la commande qui montre la première comparable du classement, absente
|
||||
// quand aucune ne se compare ;
|
||||
// - derive, fautif : la source ne se mesure pas, et ses raisons, chacune
|
||||
// suivie de la personne qu'elle nomme, comme au tableau des propositions.
|
||||
//
|
||||
// Une source mesurable dont une mesure contredit sa borne (refusDessin,
|
||||
// § 12.3) n'est pas dessinée : la phrase du refus, puis chaque
|
||||
// contradiction, son code traduit et la personne nommée.
|
||||
//
|
||||
// Sinon, la liste des vues, dans leur ordre à l'écran, mène à chacune :
|
||||
// un lien porte le focus sur la vue, qui se fait voir. Puis les neuf vues,
|
||||
// chacune sous le contrat de propriétés {detail, ligne, table} — le
|
||||
// détail du placement, la ligne d'évaluation de la source, la table
|
||||
// d'évaluation de la charge. Le détail n'est pas encore calculé : detail
|
||||
// vaut null.
|
||||
import { getContext } from 'svelte';
|
||||
import Diagnostic from '../diagnostic/Diagnostic.svelte';
|
||||
import Carte from '../primitives/Carte.svelte';
|
||||
import Commande from '../primitives/Commande.svelte';
|
||||
import Refus from '../primitives/Refus.svelte';
|
||||
import { evaluation, nomDe } from '../propositions/modele.js';
|
||||
import { sourceQualite } from './source.js';
|
||||
import VueAnimateurs from './VueAnimateurs.svelte';
|
||||
import VueCollisions from './VueCollisions.svelte';
|
||||
import VueDiversite from './VueDiversite.svelte';
|
||||
import VueEntete from './VueEntete.svelte';
|
||||
import VueObjectifs from './VueObjectifs.svelte';
|
||||
import VuePaires from './VuePaires.svelte';
|
||||
import VuePerformance from './VuePerformance.svelte';
|
||||
import VueProfil from './VueProfil.svelte';
|
||||
import VueTables from './VueTables.svelte';
|
||||
|
||||
const t = getContext('t');
|
||||
const ui = getContext('ui');
|
||||
const seance = getContext('seance');
|
||||
const uid = $props.id();
|
||||
|
||||
// Les vues, dans leur ordre à l'écran (§ 12, B9), chacune sous le nom de
|
||||
// la clé de son titre, qualite.vue.<nom>.titre.
|
||||
const VUES = [
|
||||
['entete', VueEntete],
|
||||
['profil', VueProfil],
|
||||
['collisions', VueCollisions],
|
||||
['tables', VueTables],
|
||||
['paires', VuePaires],
|
||||
['objectifs', VueObjectifs],
|
||||
['animateurs', VueAnimateurs],
|
||||
['diversite', VueDiversite],
|
||||
['performance', VuePerformance],
|
||||
];
|
||||
|
||||
const charge = $derived($seance.charge);
|
||||
const source = $derived(charge === null ? null : sourceQualite(charge, { demandee: $ui.proposition }));
|
||||
const table = $derived(charge === null ? null : evaluation(charge).table);
|
||||
const ligne = $derived(source?.ligne ?? null);
|
||||
const detail = null;
|
||||
// Les contradictions qui retirent le dessin : celles de la ligne
|
||||
// d'évaluation de la source, une liste vide sans source mesurable.
|
||||
const refusDessin = $derived(ligne?.refusDessin ?? []);
|
||||
const nommer = (id) => (id === null || id === undefined ? null : nomDe(charge, id));
|
||||
|
||||
// L'identifiant de l'élément qui porte une vue.
|
||||
const idVue = (nom) => `${uid}-vue-${nom}`;
|
||||
|
||||
// Porte le focus sur la vue nommée, qui se fait voir ; le lien ne change
|
||||
// pas l'adresse de la page.
|
||||
function allerALaVue(evenement, nom) {
|
||||
evenement.preventDefault();
|
||||
const cible = document.getElementById(idVue(nom));
|
||||
cible?.scrollIntoView({ block: 'start' });
|
||||
cible?.focus({ preventScroll: true });
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet personne(id)}
|
||||
{@const nom = nommer(id)}
|
||||
{#if nom !== null}
|
||||
<span class="nom">{nom}</span>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<section data-section="qualite">
|
||||
<h2>{t('coquille.section.qualite')}</h2>
|
||||
|
||||
{#if source !== null}
|
||||
<div class="source">
|
||||
<p data-source-qualite>
|
||||
<span class="intitule">{t('qualite.source.intitule')}</span>
|
||||
<span class="placement">{t(source.libelle.cle, source.libelle.details)}</span>
|
||||
</p>
|
||||
{#if source.revenir !== null}
|
||||
<Commande cle={source.revenir} nom="revenir" executer={() => ui.montrerProposition(null)} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if source.cas === 'configuration'}
|
||||
<Carte>
|
||||
<div class="cas">
|
||||
<p data-phrase-qualite="configuration">{t('qualite.phrase.configuration')}</p>
|
||||
<Refus refus={source.refus} />
|
||||
</div>
|
||||
</Carte>
|
||||
{:else if source.cas === 'aucunPlacement'}
|
||||
<Carte>
|
||||
<p data-phrase-qualite="aucunPlacement">{t('qualite.phrase.aucunPlacement')}</p>
|
||||
</Carte>
|
||||
<Diagnostic compact />
|
||||
{:else if source.cas === 'aucuneMontree'}
|
||||
<Carte>
|
||||
<div class="cas">
|
||||
<p data-phrase-qualite="aucuneMontree">{t('qualite.phrase.aucuneMontree')}</p>
|
||||
{#if source.premiere === null}
|
||||
<p data-phrase-qualite="aucuneComparable">{t('qualite.phrase.aucuneComparable')}</p>
|
||||
{:else}
|
||||
{@const premiere = source.premiere}
|
||||
<Commande
|
||||
cle="qualite.source.montrerPremiere"
|
||||
details={{ proposition: premiere }}
|
||||
nom="montrerPremiere"
|
||||
executer={() => ui.montrerProposition(premiere)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</Carte>
|
||||
{:else if source.cas === 'derive' || source.cas === 'fautif'}
|
||||
<Carte>
|
||||
<div class="cas">
|
||||
<p data-phrase-qualite={source.cas}>{t(`qualite.phrase.${source.cas}`)}</p>
|
||||
<ul class="raisons">
|
||||
{#each source.raisons as raison, rang (rang)}
|
||||
<li><span>{t(raison.cle, raison.details)}</span>{@render personne(raison.participant)}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</Carte>
|
||||
{:else if refusDessin.length > 0}
|
||||
<Carte>
|
||||
<div class="cas">
|
||||
<p data-phrase-qualite="refusDessin">{t('qualite.refus.phrase')}</p>
|
||||
<ul class="raisons">
|
||||
{#each refusDessin as violation, rang (rang)}
|
||||
<li><span>{t(violation.code, violation)}</span>{@render personne(violation.participant)}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</Carte>
|
||||
{:else}
|
||||
<nav class="vues" aria-label={t('qualite.vues.titre')}>
|
||||
<ul>
|
||||
{#each VUES as [nom] (nom)}
|
||||
<li><a href="#{idVue(nom)}" onclick={(evenement) => allerALaVue(evenement, nom)}>{t(`qualite.vue.${nom}.titre`)}</a></li>
|
||||
{/each}
|
||||
</ul>
|
||||
</nav>
|
||||
{#each VUES as [nom, Vue] (nom)}
|
||||
<div class="vue" id={idVue(nom)} tabindex="-1">
|
||||
<Vue {detail} {ligne} {table} />
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-3);
|
||||
min-width: 0;
|
||||
color: var(--couleur-page-texte);
|
||||
font-size: var(--taille-texte);
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: var(--taille-titre);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* La ligne de source : l'intitulé en encre secondaire, le placement en
|
||||
gras, puis la commande de retour, sur la même ligne quand elle tient. */
|
||||
.source {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--espace-2) var(--espace-3);
|
||||
}
|
||||
|
||||
.source p {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0 var(--espace-1);
|
||||
}
|
||||
|
||||
.intitule {
|
||||
color: var(--couleur-page-texte-secondaire);
|
||||
}
|
||||
|
||||
.placement {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.cas {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--espace-2);
|
||||
}
|
||||
|
||||
.raisons {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-1);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.raisons li {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0 var(--espace-2);
|
||||
}
|
||||
|
||||
.nom {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* La liste des vues : des liens côte à côte, qui passent à la ligne. */
|
||||
.vues ul {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--espace-1) var(--espace-4);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.vues a {
|
||||
color: var(--couleur-action-encre);
|
||||
text-underline-offset: 0.2em;
|
||||
}
|
||||
|
||||
.vues a:focus-visible,
|
||||
.vue:focus-visible {
|
||||
outline: 2px solid var(--couleur-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
</style>
|
||||
22
src/interface/qualite/VueAnimateurs.svelte
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||
|
||||
<script>
|
||||
// La vue « Animateurs rencontrés » (§ 12.13) de la page de qualité, en
|
||||
// attente de son contenu : la carte titrée de sa question, et rien d'autre.
|
||||
// Elle reçoit le contrat de propriétés des vues — detail, le détail du
|
||||
// placement mesuré, null tant qu'il n'est pas calculé ; ligne, sa ligne
|
||||
// d'évaluation ; table, la table d'évaluation de la charge — et n'en lit
|
||||
// rien. Son <div> porte data-vue-qualite, que la carte ne transmet pas : il
|
||||
// enveloppe la carte.
|
||||
import { getContext } from 'svelte';
|
||||
import Carte from '../primitives/Carte.svelte';
|
||||
|
||||
let { detail = null, ligne = null, table = null } = $props();
|
||||
|
||||
const t = getContext('t');
|
||||
</script>
|
||||
|
||||
<div data-vue-qualite="animateurs">
|
||||
<Carte titre={t('qualite.vue.animateurs.titre')} sousTitre={t('qualite.vue.animateurs.question')} niveau={3} />
|
||||
</div>
|
||||
22
src/interface/qualite/VueCollisions.svelte
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||
|
||||
<script>
|
||||
// La vue « Collisions d'appartenance » (§ 12.4) de la page de qualité, en
|
||||
// attente de son contenu : la carte titrée de sa question, et rien d'autre.
|
||||
// Elle reçoit le contrat de propriétés des vues — detail, le détail du
|
||||
// placement mesuré, null tant qu'il n'est pas calculé ; ligne, sa ligne
|
||||
// d'évaluation ; table, la table d'évaluation de la charge — et n'en lit
|
||||
// rien. Son <div> porte data-vue-qualite, que la carte ne transmet pas : il
|
||||
// enveloppe la carte.
|
||||
import { getContext } from 'svelte';
|
||||
import Carte from '../primitives/Carte.svelte';
|
||||
|
||||
let { detail = null, ligne = null, table = null } = $props();
|
||||
|
||||
const t = getContext('t');
|
||||
</script>
|
||||
|
||||
<div data-vue-qualite="collisions">
|
||||
<Carte titre={t('qualite.vue.collisions.titre')} sousTitre={t('qualite.vue.collisions.question')} niveau={3} />
|
||||
</div>
|
||||
22
src/interface/qualite/VueDiversite.svelte
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||
|
||||
<script>
|
||||
// La vue « Diversité par animateur » (§ 12.13) de la page de qualité, en
|
||||
// attente de son contenu : la carte titrée de sa question, et rien d'autre.
|
||||
// Elle reçoit le contrat de propriétés des vues — detail, le détail du
|
||||
// placement mesuré, null tant qu'il n'est pas calculé ; ligne, sa ligne
|
||||
// d'évaluation ; table, la table d'évaluation de la charge — et n'en lit
|
||||
// rien. Son <div> porte data-vue-qualite, que la carte ne transmet pas : il
|
||||
// enveloppe la carte.
|
||||
import { getContext } from 'svelte';
|
||||
import Carte from '../primitives/Carte.svelte';
|
||||
|
||||
let { detail = null, ligne = null, table = null } = $props();
|
||||
|
||||
const t = getContext('t');
|
||||
</script>
|
||||
|
||||
<div data-vue-qualite="diversite">
|
||||
<Carte titre={t('qualite.vue.diversite.titre')} sousTitre={t('qualite.vue.diversite.question')} niveau={3} />
|
||||
</div>
|
||||
21
src/interface/qualite/VueEntete.svelte
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||
|
||||
<script>
|
||||
// La vue « En-tête chiffré » (§ 12.2) de la page de qualité, en attente de
|
||||
// son contenu : la carte titrée de sa question, et rien d'autre. Elle reçoit
|
||||
// le contrat de propriétés des vues — detail, le détail du placement mesuré,
|
||||
// null tant qu'il n'est pas calculé ; ligne, sa ligne d'évaluation ; table,
|
||||
// la table d'évaluation de la charge — et n'en lit rien. Son <div> porte
|
||||
// data-vue-qualite, que la carte ne transmet pas : il enveloppe la carte.
|
||||
import { getContext } from 'svelte';
|
||||
import Carte from '../primitives/Carte.svelte';
|
||||
|
||||
let { detail = null, ligne = null, table = null } = $props();
|
||||
|
||||
const t = getContext('t');
|
||||
</script>
|
||||
|
||||
<div data-vue-qualite="entete">
|
||||
<Carte titre={t('qualite.vue.entete.titre')} sousTitre={t('qualite.vue.entete.question')} niveau={3} />
|
||||
</div>
|
||||
22
src/interface/qualite/VueObjectifs.svelte
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||
|
||||
<script>
|
||||
// La vue « Objectifs des tables » (§ 12.12) de la page de qualité, en
|
||||
// attente de son contenu : la carte titrée de sa question, et rien d'autre.
|
||||
// Elle reçoit le contrat de propriétés des vues — detail, le détail du
|
||||
// placement mesuré, null tant qu'il n'est pas calculé ; ligne, sa ligne
|
||||
// d'évaluation ; table, la table d'évaluation de la charge — et n'en lit
|
||||
// rien. Son <div> porte data-vue-qualite, que la carte ne transmet pas : il
|
||||
// enveloppe la carte.
|
||||
import { getContext } from 'svelte';
|
||||
import Carte from '../primitives/Carte.svelte';
|
||||
|
||||
let { detail = null, ligne = null, table = null } = $props();
|
||||
|
||||
const t = getContext('t');
|
||||
</script>
|
||||
|
||||
<div data-vue-qualite="objectifs">
|
||||
<Carte titre={t('qualite.vue.objectifs.titre')} sousTitre={t('qualite.vue.objectifs.question')} niveau={3} />
|
||||
</div>
|
||||
21
src/interface/qualite/VuePaires.svelte
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||
|
||||
<script>
|
||||
// La vue « Paires répétées » (§ 12.5) de la page de qualité, en attente de
|
||||
// son contenu : la carte titrée de sa question, et rien d'autre. Elle reçoit
|
||||
// le contrat de propriétés des vues — detail, le détail du placement mesuré,
|
||||
// null tant qu'il n'est pas calculé ; ligne, sa ligne d'évaluation ; table,
|
||||
// la table d'évaluation de la charge — et n'en lit rien. Son <div> porte
|
||||
// data-vue-qualite, que la carte ne transmet pas : il enveloppe la carte.
|
||||
import { getContext } from 'svelte';
|
||||
import Carte from '../primitives/Carte.svelte';
|
||||
|
||||
let { detail = null, ligne = null, table = null } = $props();
|
||||
|
||||
const t = getContext('t');
|
||||
</script>
|
||||
|
||||
<div data-vue-qualite="paires">
|
||||
<Carte titre={t('qualite.vue.paires.titre')} sousTitre={t('qualite.vue.paires.question')} niveau={3} />
|
||||
</div>
|
||||
22
src/interface/qualite/VuePerformance.svelte
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||
|
||||
<script>
|
||||
// La vue « Performance du calcul » (§ 12.14) de la page de qualité, en
|
||||
// attente de son contenu : la carte titrée de sa question, et rien d'autre.
|
||||
// Elle reçoit le contrat de propriétés des vues — detail, le détail du
|
||||
// placement mesuré, null tant qu'il n'est pas calculé ; ligne, sa ligne
|
||||
// d'évaluation ; table, la table d'évaluation de la charge — et n'en lit
|
||||
// rien. Son <div> porte data-vue-qualite, que la carte ne transmet pas : il
|
||||
// enveloppe la carte.
|
||||
import { getContext } from 'svelte';
|
||||
import Carte from '../primitives/Carte.svelte';
|
||||
|
||||
let { detail = null, ligne = null, table = null } = $props();
|
||||
|
||||
const t = getContext('t');
|
||||
</script>
|
||||
|
||||
<div data-vue-qualite="performance">
|
||||
<Carte titre={t('qualite.vue.performance.titre')} sousTitre={t('qualite.vue.performance.question')} niveau={3} />
|
||||
</div>
|
||||
22
src/interface/qualite/VueProfil.svelte
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||
|
||||
<script>
|
||||
// La vue « Profil trié des rencontres » (§ 12.3) de la page de qualité, en
|
||||
// attente de son contenu : la carte titrée de sa question, et rien d'autre.
|
||||
// Elle reçoit le contrat de propriétés des vues — detail, le détail du
|
||||
// placement mesuré, null tant qu'il n'est pas calculé ; ligne, sa ligne
|
||||
// d'évaluation ; table, la table d'évaluation de la charge — et n'en lit
|
||||
// rien. Son <div> porte data-vue-qualite, que la carte ne transmet pas : il
|
||||
// enveloppe la carte.
|
||||
import { getContext } from 'svelte';
|
||||
import Carte from '../primitives/Carte.svelte';
|
||||
|
||||
let { detail = null, ligne = null, table = null } = $props();
|
||||
|
||||
const t = getContext('t');
|
||||
</script>
|
||||
|
||||
<div data-vue-qualite="profil">
|
||||
<Carte titre={t('qualite.vue.profil.titre')} sousTitre={t('qualite.vue.profil.question')} niveau={3} />
|
||||
</div>
|
||||
22
src/interface/qualite/VueTables.svelte
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||
|
||||
<script>
|
||||
// La vue « Appartenances à chaque table » (§ 12.11) de la page de qualité,
|
||||
// en attente de son contenu : la carte titrée de sa question, et rien
|
||||
// d'autre. Elle reçoit le contrat de propriétés des vues — detail, le détail
|
||||
// du placement mesuré, null tant qu'il n'est pas calculé ; ligne, sa ligne
|
||||
// d'évaluation ; table, la table d'évaluation de la charge — et n'en lit
|
||||
// rien. Son <div> porte data-vue-qualite, que la carte ne transmet pas : il
|
||||
// enveloppe la carte.
|
||||
import { getContext } from 'svelte';
|
||||
import Carte from '../primitives/Carte.svelte';
|
||||
|
||||
let { detail = null, ligne = null, table = null } = $props();
|
||||
|
||||
const t = getContext('t');
|
||||
</script>
|
||||
|
||||
<div data-vue-qualite="tables">
|
||||
<Carte titre={t('qualite.vue.tables.titre')} sousTitre={t('qualite.vue.tables.question')} niveau={3} />
|
||||
</div>
|
||||
234
src/interface/qualite/graphiques/Barres.svelte
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||
|
||||
<script>
|
||||
// Des barres horizontales qui partent de zéro (§ 12.4, § 12.12, § 12.13,
|
||||
// D31, D38) : une ligne par entrée, dans l'ordre reçu du détail, intitulée
|
||||
// de son nom tronqué à LARGEUR_TRONCATURE_NOM_PX (registre des constantes)
|
||||
// sur des largeurs mesurées — le nom entier reste dans l'infobulle et le
|
||||
// jumeau —, précédé d'une pastille d'appartenance quand la ligne en porte
|
||||
// une : la liste garde sa teinte, le tracé n'en prend aucune (§ 12.7).
|
||||
//
|
||||
// lignes : [{id, nom, appartenance?, valeurs, secondaire?}] — appartenance,
|
||||
// le rang de teinte de 1 à 6 (teintesAppartenances), null pour la pastille
|
||||
// neutre, absente sans pastille ; secondaire, un texte déjà écrit ou null,
|
||||
// les autres chiffres de la ligne, ou « — » d'une ligne sans barre, écrit
|
||||
// en encre secondaire après la barre et les libellés posés sur elle
|
||||
// (dispositionBarres). Deux formes, exclusives :
|
||||
// segments, un ou deux [{genre, libelle}], empilés depuis zéro sur un
|
||||
// axe commun, valeurs[k] celle du segment k ; le libellé de chaque
|
||||
// segment se pose sur la première barre où il est non nul, et dans la
|
||||
// légende ;
|
||||
// colonnes, deux [{genre, libelle}], juxtaposées, chacune son axe et son
|
||||
// titre, jamais empilées ni sur un axe commun, valeurs[k] celle de la
|
||||
// colonne k, les lignes dans le même ordre.
|
||||
// genre : serie1, serie2, prouve ou reste, chacun son jeton (REMPLISSAGES,
|
||||
// pointage.js). Un segment nul ne se trace pas.
|
||||
//
|
||||
// Le pointage désigne la ligne sous le pointeur ; pointe, liable, en porte
|
||||
// l'indice ; infobulle, {colonnes, lignes}, une ligne du jumeau par ligne
|
||||
// de barres (Graphique). graduer(v) écrit une graduation, par défaut le
|
||||
// nombre mis en forme.
|
||||
//
|
||||
// Désignations : data-graphique sur le <svg>, data-ligne-graphique sur
|
||||
// chaque ligne, par l'identifiant de ce qu'elle porte, data-segment sur
|
||||
// chaque segment, par son genre, data-rang sur la désignation de la ligne
|
||||
// pointée, par son rang à partir de 1. Chaque marque porte data-marque.
|
||||
import { getContext } from 'svelte';
|
||||
import Graphique from './Graphique.svelte';
|
||||
import { ESPACE_PX, cibleLigne, dispositionBarres } from './pointage.js';
|
||||
|
||||
let {
|
||||
graphique,
|
||||
nom,
|
||||
synthese,
|
||||
lignes,
|
||||
segments = null,
|
||||
colonnes = null,
|
||||
graduer = null,
|
||||
infobulle = null,
|
||||
pointe = $bindable(null),
|
||||
} = $props();
|
||||
|
||||
const t = getContext('t');
|
||||
const format = getContext('format');
|
||||
|
||||
let largeur = $state(0);
|
||||
let mesures = $state(null);
|
||||
|
||||
const disposition = $derived(
|
||||
mesures === null || largeur === 0
|
||||
? null
|
||||
: dispositionBarres({
|
||||
largeur,
|
||||
lignes,
|
||||
segments,
|
||||
colonnes,
|
||||
mesurer: mesures.mesurer,
|
||||
hauteurTexte: mesures.hauteurTexte,
|
||||
graduer: graduer ?? ((v) => format.nombre(v)),
|
||||
marque: t('graphiques.troncature'),
|
||||
}),
|
||||
);
|
||||
|
||||
// La surface de pointage couvre les lignes, intitulés compris.
|
||||
const zone = $derived(
|
||||
disposition === null ? null : { x: 0, y: disposition.haut, largeur, hauteur: disposition.hauteurTotale - disposition.haut },
|
||||
);
|
||||
const cibler = (_x, y) => cibleLigne(y, disposition);
|
||||
const ancrer = (indice) => {
|
||||
const ligne = disposition.lignes[indice];
|
||||
return { x: ligne.fin, y: ligne.y + ligne.hauteur / 2 };
|
||||
};
|
||||
// La classe de teinte d'une pastille : son rang, ou la neutre.
|
||||
const teinte = (rang) => (rang === null ? 'neutre' : `appartenance-${rang}`);
|
||||
</script>
|
||||
|
||||
<Graphique
|
||||
{graphique}
|
||||
{nom}
|
||||
{synthese}
|
||||
{infobulle}
|
||||
hauteur={disposition?.hauteurTotale ?? 0}
|
||||
{zone}
|
||||
legende={disposition?.legende ?? []}
|
||||
{cibler}
|
||||
{ancrer}
|
||||
bind:largeur
|
||||
bind:mesures
|
||||
bind:pointe
|
||||
>
|
||||
{#if disposition !== null}
|
||||
{#each disposition.axes as axe, rang (rang)}
|
||||
{#if axe.titre !== null}
|
||||
<text class="titre" x={axe.titre.x} y={axe.titre.y} dominant-baseline="central">{axe.titre.texte}</text>
|
||||
{/if}
|
||||
{#each axe.graduations as graduation (graduation.valeur)}
|
||||
<line class="grille" x1={graduation.x} y1={axe.axe.y1} x2={graduation.x} y2={axe.axe.y2} />
|
||||
<text class="graduation" x={graduation.x} y={graduation.y} text-anchor="middle" dominant-baseline="central"
|
||||
>{graduation.texte}</text
|
||||
>
|
||||
{/each}
|
||||
<line class="axe" data-marque="graphiqueAxe" x1={axe.axe.x1} y1={axe.axe.y1} x2={axe.axe.x2} y2={axe.axe.y2} />
|
||||
{/each}
|
||||
{#if pointe !== null && disposition.lignes[pointe] !== undefined}
|
||||
{@const ligne = disposition.lignes[pointe]}
|
||||
<rect class="designation" data-rang={pointe + 1} x="0.75" y={ligne.y} width={largeur - 1.5} height={ligne.hauteur} />
|
||||
{/if}
|
||||
{#each disposition.lignes as ligne (ligne.id)}
|
||||
<g data-ligne-graphique={ligne.id}>
|
||||
{#if ligne.pastille !== null}
|
||||
{@const p = ligne.pastille}
|
||||
<rect class="pastille {teinte(ligne.appartenance)}" x={p.x} y={p.y} width={p.largeur} height={p.hauteur} rx={p.rayon} />
|
||||
<circle class="point {teinte(ligne.appartenance)}" cx={p.point.x} cy={p.point.y} r="4" />
|
||||
{/if}
|
||||
<text class="intitule" x={ligne.intitule.x} y={ligne.intitule.y} dominant-baseline="central">{ligne.intitule.texte}</text>
|
||||
{#if ligne.secondaire !== null}
|
||||
<text class="secondaire" x={ligne.secondaire.x} y={ligne.secondaire.y} dominant-baseline="central">{ligne.secondaire.texte}</text>
|
||||
{/if}
|
||||
{#each ligne.segments as segment (segment.colonne)}
|
||||
<rect
|
||||
data-segment={segment.genre}
|
||||
data-marque={segment.marque}
|
||||
x={segment.x}
|
||||
y={segment.y}
|
||||
width={segment.largeur}
|
||||
height={segment.hauteur}
|
||||
style:fill={`var(${segment.jeton})`}
|
||||
/>
|
||||
{/each}
|
||||
</g>
|
||||
{/each}
|
||||
{#each disposition.etiquettesSegments as etiquette (etiquette.genre)}
|
||||
<text class="etiquette" x={etiquette.x} y={etiquette.y} dominant-baseline="central">{etiquette.libelle}</text>
|
||||
{/each}
|
||||
{/if}
|
||||
</Graphique>
|
||||
|
||||
<style>
|
||||
.axe {
|
||||
stroke: var(--couleur-graphique-axe);
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.grille {
|
||||
stroke: var(--couleur-graphique-grille);
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.designation {
|
||||
fill: none;
|
||||
stroke: var(--couleur-graphique-axe);
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.graduation,
|
||||
.secondaire {
|
||||
fill: var(--couleur-page-texte-secondaire);
|
||||
}
|
||||
|
||||
.titre,
|
||||
.intitule,
|
||||
.etiquette {
|
||||
fill: var(--couleur-page-texte);
|
||||
}
|
||||
|
||||
/* La pastille d'appartenance d'un intitulé : le fond teinté et le point de
|
||||
la marque de sa teinte, comme la primitive Pastille ; le nom se lit de
|
||||
l'encre de la page. */
|
||||
.pastille.neutre {
|
||||
fill: var(--couleur-neutre-fond);
|
||||
}
|
||||
|
||||
.point.neutre {
|
||||
fill: var(--couleur-neutre-marque);
|
||||
}
|
||||
|
||||
.pastille.appartenance-1 {
|
||||
fill: var(--couleur-appartenance-1-fond);
|
||||
}
|
||||
|
||||
.point.appartenance-1 {
|
||||
fill: var(--couleur-appartenance-1-marque);
|
||||
}
|
||||
|
||||
.pastille.appartenance-2 {
|
||||
fill: var(--couleur-appartenance-2-fond);
|
||||
}
|
||||
|
||||
.point.appartenance-2 {
|
||||
fill: var(--couleur-appartenance-2-marque);
|
||||
}
|
||||
|
||||
.pastille.appartenance-3 {
|
||||
fill: var(--couleur-appartenance-3-fond);
|
||||
}
|
||||
|
||||
.point.appartenance-3 {
|
||||
fill: var(--couleur-appartenance-3-marque);
|
||||
}
|
||||
|
||||
.pastille.appartenance-4 {
|
||||
fill: var(--couleur-appartenance-4-fond);
|
||||
}
|
||||
|
||||
.point.appartenance-4 {
|
||||
fill: var(--couleur-appartenance-4-marque);
|
||||
}
|
||||
|
||||
.pastille.appartenance-5 {
|
||||
fill: var(--couleur-appartenance-5-fond);
|
||||
}
|
||||
|
||||
.point.appartenance-5 {
|
||||
fill: var(--couleur-appartenance-5-marque);
|
||||
}
|
||||
|
||||
.pastille.appartenance-6 {
|
||||
fill: var(--couleur-appartenance-6-fond);
|
||||
}
|
||||
|
||||
.point.appartenance-6 {
|
||||
fill: var(--couleur-appartenance-6-marque);
|
||||
}
|
||||
</style>
|
||||
255
src/interface/qualite/graphiques/Escalier.svelte
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||
|
||||
<script>
|
||||
// Un graphique à un seul axe vertical, qui part de zéro (§ 12.3, § 12.7,
|
||||
// § 12.14, D30, D49, D51), sous deux formes.
|
||||
//
|
||||
// forme 'escalier' : une ou deux séries par rang — la première en plein,
|
||||
// la seconde en tirets —, et jusqu'à deux repères — un plafond en trait
|
||||
// mixte, la règle à 100 % du même motif, une borne en pointillés —, chacun
|
||||
// en règle quand il est constant, en escalier sinon ; l'abscisse linéaire
|
||||
// des rangs. Un rang sans valeur (null) interrompt le chemin de sa série,
|
||||
// sans point ni segment. marques : une entrée par rang, null ou un genre
|
||||
// de SIGNES (pointage.js), dont le signe de forme se pose au rang.
|
||||
//
|
||||
// forme 'points' : les petits multiples de la performance — des points aux
|
||||
// seuls points de relevé, reliés par un trait mince, sur l'abscisse
|
||||
// logarithmique des mouvements évalués (abscisses), graduée à ces points.
|
||||
//
|
||||
// series : [{libelle, valeurs}] ; reperes : [{genre, libelle, valeurs}],
|
||||
// genre plafond, borne ou cent ; libellés déjà traduits, valeurs une par
|
||||
// rang ou par point, dans l'ordre du détail : rien ne se trie ni ne se
|
||||
// recompte ici. Chaque trace porte son libellé au bout de son tracé, et la
|
||||
// légende le reprend. graduer(v) et graduerX(v) écrivent une graduation —
|
||||
// par défaut le nombre mis en forme ; titreX nomme l'abscisse, null pour
|
||||
// aucun titre, par défaut celui de la forme.
|
||||
//
|
||||
// marges, liable en sortie : les marges que le graphique demande pour son
|
||||
// texte (margesVerticales, pointage.js) ; alignerSur : null, ou les marges
|
||||
// communes d'un groupe (margesCommunes) — deux graphiques empilés, ou des
|
||||
// petits multiples, qui reçoivent les mêmes alignent leur abscisse, rang
|
||||
// contre rang, point contre point (§ 12.3).
|
||||
//
|
||||
// pointe, liable : l'indice du rang ou du point pointé, null sans. Deux
|
||||
// graphiques empilés qui le lient au même état — le profil en comptes,
|
||||
// puis en pourcentages, ou les petits multiples — désignent la même cible
|
||||
// à la fois, l'infobulle dans le seul graphique survolé. infobulle :
|
||||
// {colonnes, lignes}, les colonnes et les lignes du jumeau, une ligne par
|
||||
// rang ou par point (Graphique).
|
||||
//
|
||||
// Désignations : data-graphique sur le <svg>, data-serie sur le tracé de
|
||||
// chaque série (serie1, serie2) puis sur son libellé, data-repere sur le
|
||||
// tracé de chaque repère (plafond, borne, cent) puis sur son libellé — le
|
||||
// tracé vient d'abord —, data-point-releve sur chaque point, par ses
|
||||
// mouvements, data-rang sur la désignation de la cible pointée, par son
|
||||
// rang à partir de 1. Chaque marque porte data-marque, son nom au registre
|
||||
// (marques.js).
|
||||
import { getContext } from 'svelte';
|
||||
import Graphique from './Graphique.svelte';
|
||||
import {
|
||||
ESPACE_PX,
|
||||
TRAITS,
|
||||
ancrePoint,
|
||||
ancreRang,
|
||||
ciblePoint,
|
||||
cibleRang,
|
||||
dispositionEscalier,
|
||||
dispositionPoints,
|
||||
margesVerticales,
|
||||
} from './pointage.js';
|
||||
|
||||
let {
|
||||
graphique,
|
||||
nom,
|
||||
synthese,
|
||||
series,
|
||||
reperes = [],
|
||||
marques = null,
|
||||
forme = 'escalier',
|
||||
abscisses = null,
|
||||
hauteur = 240,
|
||||
graduer = null,
|
||||
graduerX = null,
|
||||
titreX = undefined,
|
||||
infobulle = null,
|
||||
alignerSur = null,
|
||||
marges = $bindable(null),
|
||||
pointe = $bindable(null),
|
||||
} = $props();
|
||||
|
||||
const t = getContext('t');
|
||||
const format = getContext('format');
|
||||
const nombre = (v) => format.nombre(v);
|
||||
const enPoints = $derived(forme === 'points');
|
||||
|
||||
let largeur = $state(0);
|
||||
let mesures = $state(null);
|
||||
|
||||
const titre = $derived(titreX === undefined ? t(enPoints ? 'graphiques.axe.mouvements' : 'graphiques.axe.rangs') : titreX);
|
||||
const reperesTraces = $derived(enPoints ? [] : reperes);
|
||||
|
||||
// Les marges du seul texte de ce graphique : elles ne lisent pas
|
||||
// alignerSur, et un groupe qui s'aligne ne boucle pas.
|
||||
const requises = $derived(
|
||||
mesures === null
|
||||
? null
|
||||
: margesVerticales({ series, reperes: reperesTraces, mesurer: mesures.mesurer, graduer: graduer ?? nombre, titreX: titre }),
|
||||
);
|
||||
$effect(() => {
|
||||
marges = requises;
|
||||
});
|
||||
|
||||
const disposition = $derived.by(() => {
|
||||
if (mesures === null || largeur === 0) return null;
|
||||
const communes = {
|
||||
largeur,
|
||||
hauteur,
|
||||
series,
|
||||
mesurer: mesures.mesurer,
|
||||
hauteurTexte: mesures.hauteurTexte,
|
||||
graduer: graduer ?? nombre,
|
||||
titreX: titre,
|
||||
aligner: alignerSur,
|
||||
};
|
||||
return enPoints
|
||||
? dispositionPoints({ ...communes, abscisses, graduerX: graduerX ?? nombre })
|
||||
: dispositionEscalier({ ...communes, reperes, marques });
|
||||
});
|
||||
|
||||
const cibler = (x) => (enPoints ? ciblePoint(x, disposition) : cibleRang(x, disposition));
|
||||
const ancrer = (indice) => (enPoints ? ancrePoint(indice, disposition) : ancreRang(indice, disposition));
|
||||
// L'attribut de désignation d'une trace : data-serie pour une série,
|
||||
// data-repere pour un repère ; undefined l'omet.
|
||||
const estSerie = (genre) => genre.startsWith('serie');
|
||||
</script>
|
||||
|
||||
<Graphique
|
||||
{graphique}
|
||||
{nom}
|
||||
{synthese}
|
||||
{hauteur}
|
||||
{infobulle}
|
||||
zone={disposition?.zone ?? null}
|
||||
legende={disposition?.legende ?? []}
|
||||
{cibler}
|
||||
{ancrer}
|
||||
bind:largeur
|
||||
bind:mesures
|
||||
bind:pointe
|
||||
>
|
||||
{#if disposition !== null}
|
||||
{@const zone = disposition.zone}
|
||||
{@const bas = zone.y + zone.hauteur}
|
||||
{#each disposition.graduationsY as graduation (graduation.valeur)}
|
||||
<line class="grille" x1={zone.x} y1={graduation.y} x2={zone.x + zone.largeur} y2={graduation.y} />
|
||||
<text class="graduation" x={zone.x - ESPACE_PX} y={graduation.y} text-anchor="end" dominant-baseline="central"
|
||||
>{graduation.texte}</text
|
||||
>
|
||||
{/each}
|
||||
{#each disposition.graduationsX as graduation (graduation.valeur)}
|
||||
<text class="graduation" x={graduation.x} y={bas + ESPACE_PX} text-anchor="middle" dominant-baseline="hanging"
|
||||
>{graduation.texte}</text
|
||||
>
|
||||
{/each}
|
||||
{#if disposition.titreX !== null}
|
||||
<text class="graduation" x={disposition.titreX.x} y={bas + ESPACE_PX} dominant-baseline="hanging"
|
||||
>{disposition.titreX.texte}</text
|
||||
>
|
||||
{/if}
|
||||
{#each disposition.axes as axe, rang (rang)}
|
||||
<line class="axe" data-marque="graphiqueAxe" x1={axe.x1} y1={axe.y1} x2={axe.x2} y2={axe.y2} />
|
||||
{/each}
|
||||
{#if pointe !== null && disposition.traces[0].valeurs[pointe] !== undefined}
|
||||
{@const point = ancrer(pointe)}
|
||||
{#if enPoints}
|
||||
<circle class="designation" data-rang={pointe + 1} cx={point.x} cy={point.y} r="7" />
|
||||
{:else}
|
||||
<line class="designation" data-rang={pointe + 1} x1={point.x} y1={zone.y} x2={point.x} y2={bas} />
|
||||
{/if}
|
||||
{/if}
|
||||
{#each disposition.traces as trace (trace.genre)}
|
||||
<path
|
||||
class="trace"
|
||||
class:serie={estSerie(trace.genre)}
|
||||
data-serie={estSerie(trace.genre) ? trace.genre : undefined}
|
||||
data-repere={estSerie(trace.genre) ? undefined : trace.genre}
|
||||
data-marque={TRAITS[trace.genre].marque}
|
||||
d={trace.d}
|
||||
stroke-dasharray={TRAITS[trace.genre].motif}
|
||||
style:stroke={`var(${TRAITS[trace.genre].jeton})`}
|
||||
/>
|
||||
{#if enPoints}
|
||||
{#each trace.points as point (point.indice)}
|
||||
{#if point.y !== null}
|
||||
<circle
|
||||
data-point-releve={point.mouvements}
|
||||
data-marque={TRAITS[trace.genre].marque}
|
||||
cx={point.x}
|
||||
cy={point.y}
|
||||
r="3.5"
|
||||
style:fill={`var(${TRAITS[trace.genre].jeton})`}
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
<text
|
||||
class="etiquette"
|
||||
data-serie={estSerie(trace.genre) ? trace.genre : undefined}
|
||||
data-repere={estSerie(trace.genre) ? undefined : trace.genre}
|
||||
x={trace.etiquette.x}
|
||||
y={trace.etiquette.y}
|
||||
dominant-baseline="central">{trace.libelle}</text
|
||||
>
|
||||
{/each}
|
||||
{#each disposition.signes as signe (signe.indice)}
|
||||
<path class="signe" class:plein={signe.plein} data-marque="graphiqueMarque" d={signe.d} />
|
||||
{/each}
|
||||
{/if}
|
||||
</Graphique>
|
||||
|
||||
<style>
|
||||
/* Une série, d'un trait épais ; un repère, d'un trait mince (D30). */
|
||||
.trace {
|
||||
fill: none;
|
||||
stroke-width: 1.25;
|
||||
}
|
||||
|
||||
.trace.serie {
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.axe {
|
||||
stroke: var(--couleur-graphique-axe);
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.grille {
|
||||
stroke: var(--couleur-graphique-grille);
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.designation {
|
||||
fill: none;
|
||||
stroke: var(--couleur-graphique-axe);
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.signe {
|
||||
fill: none;
|
||||
stroke: var(--couleur-graphique-marque);
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.signe.plein {
|
||||
fill: var(--couleur-graphique-marque);
|
||||
}
|
||||
|
||||
.graduation {
|
||||
fill: var(--couleur-page-texte-secondaire);
|
||||
}
|
||||
|
||||
.etiquette {
|
||||
fill: var(--couleur-page-texte);
|
||||
}
|
||||
</style>
|
||||
340
src/interface/qualite/graphiques/Graphique.svelte
Normal file
|
|
@ -0,0 +1,340 @@
|
|||
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||
|
||||
<script>
|
||||
// Le cadre d'un graphique de la page de qualité (§ 12.7, § 14.5, D44) :
|
||||
// un <svg role="img"> sans viewBox — le pixel du dessin est celui de
|
||||
// l'écran —, nommé par la question de sa vue (aria-labelledby) et décrit
|
||||
// par sa phrase de synthèse, visible dessous (aria-describedby) ; la
|
||||
// légende ; la surface de pointage ; l'infobulle. Escalier, Barres et
|
||||
// Grille le remplissent : il ne dessine aucune marque et ne calcule rien.
|
||||
//
|
||||
// La largeur se mesure sur le conteneur (largeur, liable), et le texte sur
|
||||
// une sonde du dessin (mesures, liable : {mesurer, hauteurTexte}, null
|
||||
// tant que la sonde n'est pas rendue) : le composant qui remplit le cadre
|
||||
// en tire sa disposition (pointage.js), puis rend ses marques par
|
||||
// children.
|
||||
//
|
||||
// Le pointage au plus proche (§ 12.7) : zone, {x, y, largeur, hauteur} en
|
||||
// px du dessin, porte la surface de pointage, un <rect fill="none"
|
||||
// pointer-events="all"> posé après les marques, sœur d'elles, jamais leur
|
||||
// ancêtre : aucune opacité n'est nécessaire pour capter le pointeur.
|
||||
// cibler(x, y) rend l'indice de la cible sous le point local — selon la
|
||||
// règle de la forme : rang, ligne, case, point de relevé —, ou null ;
|
||||
// pointe, liable, porte cet indice : deux graphiques qui le lient à un
|
||||
// même état désignent la même cible. Le pointeur qui quitte la surface
|
||||
// remet pointe à null.
|
||||
//
|
||||
// L'infobulle porte la ligne du jumeau de la cible pointée : infobulle,
|
||||
// {colonnes, lignes}, la ligne d'indice pointe (ligneInfobulle,
|
||||
// pointage.js), un nombre écrit par format.nombre comme les graduations,
|
||||
// une cellule de pourcentage rendue par Pourcentage, son compte dessous.
|
||||
// Elle ne s'ouvre que dans le graphique survolé, sur le modèle de
|
||||
// l'infobulle primitive : un seul élément [role=tooltip], caché tant
|
||||
// qu'aucune cible n'est pointée, placé par placerInfobulle contre le point
|
||||
// d'ancrage de la cible (ancrer(indice) → {x, y} local), replacé au
|
||||
// défilement et au redimensionnement, invisible tant qu'il n'est pas
|
||||
// placé ; Échap la ferme. Elle ne capte aucun pointeur.
|
||||
//
|
||||
// legende : [{genre, libelle, signe}] — un trait, un remplissage ou un
|
||||
// signe, chacun dessiné de son jeton et de son motif, puis son libellé ;
|
||||
// libelle null pour un signe, que la table des graphiques nomme. synthese :
|
||||
// un texte déjà traduit, ou un snippet qui le rend.
|
||||
//
|
||||
// Aucune opacité, aucun filtre, aucun mode de fusion sur une marque, un
|
||||
// texte ou leur ascendance (§ 14.5).
|
||||
import { getContext } from 'svelte';
|
||||
import { creerMesureur, hauteurLigne, mesurable } from '../../plan/mesure_texte.js';
|
||||
import { placerInfobulle } from '../../primitives/infobulle.js';
|
||||
import { effetDe } from '../../raccourcis.js';
|
||||
import Pourcentage from './Pourcentage.svelte';
|
||||
import { REMPLISSAGES, TRAITS, cheminSigne, ligneInfobulle } from './pointage.js';
|
||||
|
||||
let {
|
||||
graphique,
|
||||
nom,
|
||||
synthese,
|
||||
hauteur,
|
||||
zone = null,
|
||||
cibler,
|
||||
ancrer,
|
||||
infobulle = null,
|
||||
legende = [],
|
||||
largeur = $bindable(0),
|
||||
mesures = $bindable(null),
|
||||
pointe = $bindable(null),
|
||||
children,
|
||||
} = $props();
|
||||
|
||||
const t = getContext('t');
|
||||
const format = getContext('format');
|
||||
const uid = $props.id();
|
||||
const idNom = `${uid}-nom`;
|
||||
const idSynthese = `${uid}-synthese`;
|
||||
|
||||
let svg = $state(null);
|
||||
let sonde = $state(null);
|
||||
let bulle = $state(null);
|
||||
let survole = $state(false);
|
||||
// Le coin haut gauche de l'infobulle dans la fenêtre, en px ; null tant
|
||||
// qu'elle n'est pas placée.
|
||||
let place = $state(null);
|
||||
|
||||
// La mesure du texte, relevée sur la sonde dès qu'elle est rendue : sous
|
||||
// un ancêtre caché, elle attend que la largeur mesurée change.
|
||||
$effect(() => {
|
||||
if (largeur > 0 && sonde !== null && mesures === null && mesurable(sonde)) {
|
||||
mesures = { mesurer: creerMesureur(sonde), hauteurTexte: hauteurLigne(sonde) };
|
||||
}
|
||||
});
|
||||
|
||||
const ligne = $derived(
|
||||
survole && pointe !== null && infobulle !== null && infobulle.lignes[pointe] !== undefined
|
||||
? ligneInfobulle(infobulle.colonnes, infobulle.lignes[pointe], format.nombre)
|
||||
: null,
|
||||
);
|
||||
|
||||
function pointer(evenement) {
|
||||
if (svg === null) return;
|
||||
const cadre = svg.getBoundingClientRect();
|
||||
pointe = cibler(evenement.clientX - cadre.left, evenement.clientY - cadre.top);
|
||||
survole = true;
|
||||
}
|
||||
|
||||
function quitter() {
|
||||
survole = false;
|
||||
pointe = null;
|
||||
}
|
||||
|
||||
function placer() {
|
||||
if (ligne === null || bulle === null || svg === null) return;
|
||||
const point = ancrer(pointe);
|
||||
const cadre = svg.getBoundingClientRect();
|
||||
const boite = bulle.getBoundingClientRect();
|
||||
const fenetre = document.documentElement;
|
||||
const x = cadre.left + point.x;
|
||||
const y = cadre.top + point.y;
|
||||
place = placerInfobulle(
|
||||
{ gauche: x, haut: y, droite: x, bas: y },
|
||||
{ largeur: boite.width, hauteur: boite.height },
|
||||
{ largeur: fenetre.clientWidth, hauteur: fenetre.clientHeight },
|
||||
);
|
||||
}
|
||||
|
||||
// À chaque cible pointée, après le rendu de sa ligne.
|
||||
$effect(() => {
|
||||
if (ligne === null) {
|
||||
place = null;
|
||||
return;
|
||||
}
|
||||
placer();
|
||||
});
|
||||
|
||||
function surTouche(evenement) {
|
||||
if (survole && effetDe('fenetre', evenement.key) === 'fermer') quitter();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={surTouche} onscrollcapture={placer} onresize={placer} />
|
||||
|
||||
{#snippet echantillon(entree)}
|
||||
<svg class="echantillon" width="28" height="12" aria-hidden="true">
|
||||
{#if entree.genre === 'signe'}
|
||||
<path
|
||||
class="signe"
|
||||
class:plein={entree.signe === 'ancre'}
|
||||
d={cheminSigne(entree.signe, 14, 6)}
|
||||
/>
|
||||
{:else if Object.hasOwn(TRAITS, entree.genre)}
|
||||
<line
|
||||
x1="1"
|
||||
y1="6"
|
||||
x2="27"
|
||||
y2="6"
|
||||
class="trait"
|
||||
stroke-dasharray={TRAITS[entree.genre].motif}
|
||||
style:stroke={`var(${TRAITS[entree.genre].jeton})`}
|
||||
/>
|
||||
{:else}
|
||||
<rect x="1" y="2" width="26" height="8" style:fill={`var(${REMPLISSAGES[entree.genre].jeton})`} />
|
||||
{/if}
|
||||
</svg>
|
||||
{/snippet}
|
||||
|
||||
<div class="graphique" bind:clientWidth={largeur}>
|
||||
<span id={idNom} hidden>{nom}</span>
|
||||
<svg
|
||||
bind:this={svg}
|
||||
data-graphique={graphique}
|
||||
role="img"
|
||||
aria-labelledby={idNom}
|
||||
aria-describedby={idSynthese}
|
||||
width={largeur}
|
||||
height={hauteur}
|
||||
>
|
||||
<text bind:this={sonde} class="sonde" x="0" y="0"></text>
|
||||
{@render children()}
|
||||
{#if zone !== null}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<rect
|
||||
x={zone.x}
|
||||
y={zone.y}
|
||||
width={zone.largeur}
|
||||
height={zone.hauteur}
|
||||
fill="none"
|
||||
pointer-events="all"
|
||||
onpointermove={pointer}
|
||||
onpointerleave={quitter}
|
||||
/>
|
||||
{/if}
|
||||
</svg>
|
||||
{#if legende.length > 0}
|
||||
<ul class="legende" aria-label={t('graphiques.legende')}>
|
||||
{#each legende as entree, rang (rang)}
|
||||
<li>
|
||||
{@render echantillon(entree)}<span>{entree.libelle ?? t(`graphiques.signe.${entree.signe}`)}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
<p id={idSynthese} class="synthese">
|
||||
{#if typeof synthese === 'function'}{@render synthese()}{:else}{synthese}{/if}
|
||||
</p>
|
||||
<div
|
||||
bind:this={bulle}
|
||||
class="infobulle"
|
||||
class:placee={place !== null}
|
||||
role="tooltip"
|
||||
hidden={ligne === null}
|
||||
style:left={place === null ? null : `${place.gauche}px`}
|
||||
style:top={place === null ? null : `${place.haut}px`}
|
||||
>
|
||||
{#if ligne !== null}
|
||||
<dl>
|
||||
{#each ligne as cellule (cellule.cle)}
|
||||
<div class="cellule">
|
||||
<dt>{cellule.libelle}</dt>
|
||||
<dd>
|
||||
{#if cellule.genre === 'pourcentage'}
|
||||
<Pourcentage texte={cellule.texte} compte={cellule.compte} />
|
||||
{:else}
|
||||
{cellule.texte ?? t('format.vide')}
|
||||
{/if}
|
||||
</dd>
|
||||
</div>
|
||||
{/each}
|
||||
</dl>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.graphique {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
svg {
|
||||
display: block;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* Le texte du dessin : le petit corps de l'interface ; la sonde le mesure
|
||||
et ne se voit pas. */
|
||||
svg :global(text) {
|
||||
font-size: var(--taille-texte-petit);
|
||||
}
|
||||
|
||||
.sonde {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.legende {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--espace-1) var(--espace-4);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
color: var(--couleur-page-texte);
|
||||
font-size: var(--taille-texte-petit);
|
||||
}
|
||||
|
||||
.legende li {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--espace-1);
|
||||
}
|
||||
|
||||
.trait {
|
||||
fill: none;
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.signe {
|
||||
fill: none;
|
||||
stroke: var(--couleur-graphique-marque);
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.signe.plein {
|
||||
fill: var(--couleur-graphique-marque);
|
||||
}
|
||||
|
||||
.synthese {
|
||||
margin: 0;
|
||||
color: var(--couleur-page-texte);
|
||||
}
|
||||
|
||||
/* L'infobulle, sur le modèle de la primitive : posée dans la fenêtre,
|
||||
invisible tant qu'elle n'est pas placée, sans capter le pointeur. */
|
||||
.infobulle {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 3;
|
||||
box-sizing: border-box;
|
||||
width: max-content;
|
||||
max-width: min(26rem, calc(100% - 16px));
|
||||
padding: var(--espace-2) var(--espace-3);
|
||||
border: 1px solid var(--couleur-carte-filet);
|
||||
border-radius: var(--rayon-2);
|
||||
background: var(--couleur-carte-fond);
|
||||
color: var(--couleur-page-texte);
|
||||
box-shadow: var(--ombre-2);
|
||||
font-size: var(--taille-texte);
|
||||
line-height: 1.4;
|
||||
pointer-events: none;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.infobulle.placee {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.infobulle[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
dl {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto;
|
||||
gap: var(--espace-1) var(--espace-3);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.cellule {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
dt {
|
||||
color: var(--couleur-page-texte-secondaire);
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
</style>
|
||||
180
src/interface/qualite/graphiques/Grille.svelte
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||
|
||||
<script>
|
||||
// Une grille de comptes (§ 12.5, § 12.11, D33, D35) : la matrice « qui a
|
||||
// rencontré qui », ou les tables par tour. Chaque case est bordée, peinte
|
||||
// du niveau de son compte sur l'échelle à quatre niveaux — 0, 1, 2, 3 et
|
||||
// plus —, le compte écrit de l'encre qui sert ce niveau (NIVEAUX_GRAPHIQUE,
|
||||
// marques.js). Une case sans compte écrit « — », sans couleur ; sous
|
||||
// diagonale, une case de même ligne et colonne est vide et neutre. Aucune
|
||||
// case n'est une cible : ni focus, ni clic ; le jumeau et la fiche portent
|
||||
// les mêmes comptes.
|
||||
//
|
||||
// lignes et colonnes : [{libelle}], libellés déjà écrits ; valeurs, les
|
||||
// comptes en ordre de lignes, null sans compte. Quand ses cases ne
|
||||
// tiennent pas dans la largeur réelle de la carte (grille, graphique.js),
|
||||
// la grille cède la place à phrase, un texte déjà traduit ou un snippet ;
|
||||
// dessinable, liable, le dit à la vue.
|
||||
//
|
||||
// Le pointage désigne la case sous le pointeur, aucune sur la diagonale ;
|
||||
// pointe, liable, en porte l'indice, ligne × colonnes + colonne ;
|
||||
// infobulle, {colonnes, lignes}, une ligne par case dans cet ordre
|
||||
// (Graphique). ecrire(v) écrit un compte, par défaut le nombre mis en
|
||||
// forme.
|
||||
//
|
||||
// Désignations : data-graphique sur le <svg>, data-case sur chaque case,
|
||||
// par « ligne:colonne » à partir de 0, data-rang sur la désignation de la
|
||||
// case pointée, par son indice à partir de 1. Le niveau d'une case à
|
||||
// partir de 1 et sa bordure portent chacun data-marque ; le niveau 0 est un
|
||||
// fond, sans marque.
|
||||
import { getContext } from 'svelte';
|
||||
import Graphique from './Graphique.svelte';
|
||||
import { ESPACE_PX, cibleCase, dispositionGrille } from './pointage.js';
|
||||
|
||||
let {
|
||||
graphique,
|
||||
nom,
|
||||
synthese,
|
||||
lignes,
|
||||
colonnes,
|
||||
valeurs,
|
||||
diagonale = false,
|
||||
phrase = null,
|
||||
ecrire = null,
|
||||
infobulle = null,
|
||||
dessinable = $bindable(true),
|
||||
pointe = $bindable(null),
|
||||
} = $props();
|
||||
|
||||
const t = getContext('t');
|
||||
const format = getContext('format');
|
||||
|
||||
let largeur = $state(0);
|
||||
let mesures = $state(null);
|
||||
|
||||
const disposition = $derived(
|
||||
mesures === null || largeur === 0
|
||||
? null
|
||||
: dispositionGrille({
|
||||
largeur,
|
||||
lignes,
|
||||
colonnes,
|
||||
valeurs,
|
||||
diagonale,
|
||||
mesurer: mesures.mesurer,
|
||||
hauteurTexte: mesures.hauteurTexte,
|
||||
ecrire: ecrire ?? ((v) => format.nombre(v)),
|
||||
}),
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
if (disposition !== null) dessinable = disposition.dessinable;
|
||||
});
|
||||
|
||||
const zone = $derived(
|
||||
disposition === null
|
||||
? null
|
||||
: { x: disposition.x0, y: disposition.y0, largeur: disposition.largeur - disposition.x0, hauteur: disposition.hauteur - disposition.y0 },
|
||||
);
|
||||
const cibler = (x, y) => cibleCase(x, y, disposition);
|
||||
const ancrer = (indice) => {
|
||||
const c = disposition.cases[indice];
|
||||
return { x: c.x + c.cote, y: c.y + c.cote };
|
||||
};
|
||||
</script>
|
||||
|
||||
<!-- La largeur se mesure ici aussi : une grille qui a cédé la place se
|
||||
redessine quand la carte s'élargit. -->
|
||||
<div bind:clientWidth={largeur}>
|
||||
{#if disposition !== null && !disposition.dessinable}
|
||||
<p class="phrase">{#if typeof phrase === 'function'}{@render phrase()}{:else}{phrase}{/if}</p>
|
||||
{:else}
|
||||
<Graphique
|
||||
{graphique}
|
||||
{nom}
|
||||
{synthese}
|
||||
{infobulle}
|
||||
hauteur={disposition?.hauteur ?? 0}
|
||||
{zone}
|
||||
{cibler}
|
||||
{ancrer}
|
||||
bind:largeur
|
||||
bind:mesures
|
||||
bind:pointe
|
||||
>
|
||||
{#if disposition !== null}
|
||||
{#each disposition.intitulesLignes as intitule, i (i)}
|
||||
<text class="intitule" x={intitule.x} y={intitule.y} text-anchor="end" dominant-baseline="central">{intitule.texte}</text>
|
||||
{/each}
|
||||
{#each disposition.intitulesColonnes as intitule, j (j)}
|
||||
<text
|
||||
class="intitule"
|
||||
x={intitule.x}
|
||||
y={intitule.y}
|
||||
text-anchor={intitule.tourne ? 'start' : 'middle'}
|
||||
dominant-baseline={intitule.tourne ? 'central' : 'auto'}
|
||||
transform={intitule.tourne ? `rotate(-90 ${intitule.x} ${intitule.y})` : undefined}>{intitule.texte}</text
|
||||
>
|
||||
{/each}
|
||||
{#each disposition.cases as c (c.index)}
|
||||
<g data-case="{c.ligne}:{c.colonne}">
|
||||
{#if c.jeton !== null}
|
||||
<rect
|
||||
data-marque={c.marque ?? undefined}
|
||||
x={c.fond.x}
|
||||
y={c.fond.y}
|
||||
width={c.fond.cote}
|
||||
height={c.fond.cote}
|
||||
style:fill={`var(${c.jeton})`}
|
||||
/>
|
||||
{/if}
|
||||
<rect class="bordure" data-marque="graphiqueBordureCase" x={c.bord.x} y={c.bord.y} width={c.bord.cote} height={c.bord.cote} />
|
||||
{#if !c.diagonale}
|
||||
<text
|
||||
class="compte"
|
||||
x={c.x + c.cote / 2}
|
||||
y={c.y + c.cote / 2}
|
||||
text-anchor="middle"
|
||||
dominant-baseline="central"
|
||||
style:fill={c.encre === null ? null : `var(${c.encre})`}>{c.texte ?? t('format.vide')}</text
|
||||
>
|
||||
{/if}
|
||||
</g>
|
||||
{/each}
|
||||
{#if pointe !== null && disposition.cases[pointe] !== undefined}
|
||||
{@const c = disposition.cases[pointe]}
|
||||
<rect class="designation" data-rang={pointe + 1} x={c.x - ESPACE_PX / 3} y={c.y - ESPACE_PX / 3} width={c.cote + (2 * ESPACE_PX) / 3} height={c.cote + (2 * ESPACE_PX) / 3} />
|
||||
{/if}
|
||||
{/if}
|
||||
</Graphique>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.phrase {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.intitule {
|
||||
fill: var(--couleur-page-texte);
|
||||
}
|
||||
|
||||
.bordure {
|
||||
fill: none;
|
||||
stroke: var(--couleur-graphique-bordure-case);
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
/* Un compte sans niveau — « — » — se lit de l'encre de la page. */
|
||||
.compte {
|
||||
fill: var(--couleur-page-texte);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.designation {
|
||||
fill: none;
|
||||
stroke: var(--couleur-graphique-axe);
|
||||
stroke-width: 2;
|
||||
}
|
||||
</style>
|
||||
179
src/interface/qualite/graphiques/Jumeau.svelte
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||
|
||||
<script>
|
||||
// Le jumeau tabulaire d'un graphique (§ 12.7, D44) : le chemin du clavier
|
||||
// et du lecteur d'écran vers chaque valeur. Une commande sous le
|
||||
// graphique, « Voir le tableau », l'ouvre à la demande et le referme ;
|
||||
// elle dit son état par aria-expanded, et désigne le tableau ouvert par
|
||||
// aria-controls. Fermé, il n'est pas dans le DOM : ses lignes ne se
|
||||
// bâtissent qu'à l'ouverture. À la fermeture, le focus reste sur la
|
||||
// commande, jamais sur body. Regarder n'écrit rien : la commande est
|
||||
// vivante en lecture et sur un plan bloqué.
|
||||
//
|
||||
// tableaux : un ou plusieurs tableaux, chacun {nom, colonnes, lignes} —
|
||||
// nom, déjà traduit, le nomme par aria-label ; colonnes, {cle, libelle,
|
||||
// genre, decimales?} (exigerColonnes, pointage.js), genre texte, nombre ou
|
||||
// pourcentage ; lignes, une par ligne, chacune un objet des valeurs sous la
|
||||
// clé de leur colonne, dans l'ordre où la vue les a reçues du détail. Un
|
||||
// nombre s'écrit par format.nombre, aux décimales de sa colonne, comme les
|
||||
// graduations des axes (celluleDe). La première colonne est l'en-tête de sa
|
||||
// ligne. Une cellule de genre pourcentage reçoit {texte, compte} et se rend
|
||||
// par Pourcentage, le compte de sa ligne dessous ; une valeur absente
|
||||
// s'écrit « — ». ouvert : liable, l'état de la commande.
|
||||
//
|
||||
// Un jumeau porte au moins les valeurs de son graphique, et peut détailler
|
||||
// au-delà — une seconde table, une ligne par tour —, jamais en deçà : la
|
||||
// vue en décide, le composant rend ce qu'il reçoit, sur le modèle de
|
||||
// TableauPropositions. Les noms y sont entiers.
|
||||
//
|
||||
// Désignations : data-commande ouvrirJumeau ou fermerJumeau sur la
|
||||
// commande, data-jumeau sur chaque tableau, par son rang, data-colonne sur
|
||||
// chaque en-tête et chaque cellule.
|
||||
import { getContext, tick } from 'svelte';
|
||||
import Pourcentage from './Pourcentage.svelte';
|
||||
import { celluleDe, exigerColonnes } from './pointage.js';
|
||||
|
||||
let { tableaux, ouvert = $bindable(false) } = $props();
|
||||
|
||||
const t = getContext('t');
|
||||
const format = getContext('format');
|
||||
const uid = $props.id();
|
||||
const idTableaux = `${uid}-tableaux`;
|
||||
|
||||
let commande = $state(null);
|
||||
|
||||
async function basculer() {
|
||||
ouvert = !ouvert;
|
||||
if (!ouvert) {
|
||||
await tick();
|
||||
commande?.focus();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="jumeau">
|
||||
<button
|
||||
bind:this={commande}
|
||||
type="button"
|
||||
class="commande"
|
||||
data-commande={ouvert ? 'fermerJumeau' : 'ouvrirJumeau'}
|
||||
aria-expanded={ouvert}
|
||||
aria-controls={ouvert ? idTableaux : undefined}
|
||||
onclick={basculer}>{t(ouvert ? 'graphiques.jumeau.fermer' : 'graphiques.jumeau.ouvrir')}</button
|
||||
>
|
||||
{#if ouvert}
|
||||
<div id={idTableaux} class="tableaux">
|
||||
{#each tableaux as tableau, rang (rang)}
|
||||
{@const colonnes = exigerColonnes(tableau.colonnes)}
|
||||
<div class="defilement">
|
||||
<table data-jumeau={rang} aria-label={tableau.nom}>
|
||||
<thead>
|
||||
<tr>
|
||||
{#each colonnes as colonne (colonne.cle)}
|
||||
<th scope="col" class:nombre={colonne.genre !== 'texte'} data-colonne={colonne.cle}>{colonne.libelle}</th>
|
||||
{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each tableau.lignes as ligne, i (i)}
|
||||
<tr>
|
||||
{#each colonnes as colonne, j (colonne.cle)}
|
||||
{@const cellule = celluleDe(colonne, ligne, format.nombre)}
|
||||
<svelte:element
|
||||
this={j === 0 ? 'th' : 'td'}
|
||||
scope={j === 0 ? 'row' : undefined}
|
||||
class:nombre={colonne.genre !== 'texte'}
|
||||
data-colonne={colonne.cle}
|
||||
>
|
||||
{#if cellule.genre === 'pourcentage'}
|
||||
<Pourcentage texte={cellule.texte} compte={cellule.compte} />
|
||||
{:else}
|
||||
{cellule.texte ?? t('format.vide')}
|
||||
{/if}
|
||||
</svelte:element>
|
||||
{/each}
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.jumeau {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--espace-2);
|
||||
}
|
||||
|
||||
/* La commande : un bouton secondaire, à filet sur le fond de la carte. */
|
||||
.commande {
|
||||
min-height: var(--cible-minimale);
|
||||
padding: var(--espace-1) var(--espace-3);
|
||||
border: 1px solid var(--couleur-carte-filet);
|
||||
border-radius: var(--rayon-2);
|
||||
background-color: var(--couleur-carte-fond);
|
||||
color: var(--couleur-page-texte);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.commande:hover {
|
||||
background-color: var(--couleur-carte-fond-sourd);
|
||||
}
|
||||
|
||||
.commande:focus-visible {
|
||||
outline: 2px solid var(--couleur-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.tableaux {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-3);
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.defilement {
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
font-size: var(--taille-texte);
|
||||
}
|
||||
|
||||
/* Un filet léger entre deux lignes, aucun filet vertical. */
|
||||
th,
|
||||
td {
|
||||
padding: var(--espace-2) var(--espace-3);
|
||||
border-bottom: 1px solid var(--couleur-ligne-filet);
|
||||
vertical-align: top;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
/* Les en-têtes de colonne : sur le fond sourd de la carte, en gras et en
|
||||
petit corps, sous un filet de carte. */
|
||||
thead th {
|
||||
background-color: var(--couleur-carte-fond-sourd);
|
||||
border-bottom-color: var(--couleur-carte-filet);
|
||||
font-size: var(--taille-texte-petit);
|
||||
font-weight: bold;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
|
||||
tbody th {
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.nombre {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
</style>
|
||||
48
src/interface/qualite/graphiques/Pourcentage.svelte
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||
|
||||
<script>
|
||||
// Un pourcentage de mélange de la page de qualité, suivi, juste dessous,
|
||||
// de son compte (D34, contrainte 44) : le pire cas de sa population pour
|
||||
// une tuile, une phrase de synthèse ou la soirée, le compte de la ligne
|
||||
// pour une ligne de jumeau ou d'infobulle. Rien ne se calcule ici
|
||||
// (contrainte 41) : texte est le pourcentage déjà écrit par la vue — un
|
||||
// entier du détail suivi du signe, par la table de la page —, null quand
|
||||
// son dénominateur est nul, qui s'écrit « — » ; compte, le compte déjà
|
||||
// écrit, que exigerCompte (pointage.js) exige non vide.
|
||||
//
|
||||
// Désignations : data-pourcentage sur la valeur, data-compte-pire sur le
|
||||
// compte, son voisin immédiat.
|
||||
import { getContext } from 'svelte';
|
||||
import { exigerCompte } from './pointage.js';
|
||||
|
||||
let { texte = null, compte } = $props();
|
||||
|
||||
const t = getContext('t');
|
||||
const ecrit = $derived(exigerCompte(compte));
|
||||
</script>
|
||||
|
||||
<span class="pourcentage"
|
||||
><span class="valeur" data-pourcentage>{texte ?? t('format.vide')}</span><span class="compte" data-compte-pire
|
||||
>{ecrit}</span
|
||||
></span
|
||||
>
|
||||
|
||||
<style>
|
||||
/* La valeur, puis le compte sur sa propre ligne, en petit corps d'encre
|
||||
secondaire ; l'un et l'autre suivent l'alignement de leur conteneur. */
|
||||
.pourcentage {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.valeur,
|
||||
.compte {
|
||||
display: block;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.compte {
|
||||
color: var(--couleur-page-texte-secondaire);
|
||||
font-size: var(--taille-texte-petit);
|
||||
}
|
||||
</style>
|
||||
743
src/interface/qualite/graphiques/graphiques.navigateur.test.js
Normal file
|
|
@ -0,0 +1,743 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Épreuves des composants de graphique de la page de qualité dans un vrai
|
||||
// moteur de rendu (§ 12.3, § 12.4, § 12.5, § 12.7, § 14.5, D30, D34, D44,
|
||||
// D51) : escalier, points de relevé, barres, grille, jumeau et pourcentage,
|
||||
// montés chacun par mount sur des données forgées, avec les contextes que
|
||||
// pose App — 't' et 'format'. Deux graphiques empilés partagent leur rang
|
||||
// pointé comme une vue le ferait par bind:pointe : un magasin lu par
|
||||
// fromStore, que chaque graphique lit et écrit. Les gestes passent par des
|
||||
// entrées que Chromium traite comme réelles (test/navigateur/entrees.js).
|
||||
// Les noms d'épreuve sont inventés.
|
||||
//
|
||||
// Le projet navigateur emploie expect, et test et describe de Vitest
|
||||
// (§ 14.8).
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, test } from 'vitest';
|
||||
import { flushSync, mount, tick, unmount } from 'svelte';
|
||||
import { fromStore, writable } from 'svelte/store';
|
||||
import '../../jetons.css';
|
||||
import { TABLES_APPLICATION } from '../../../application/libelles.js';
|
||||
import { creerFormat } from '../../../application/format.js';
|
||||
import { creerTraduction } from '../../../application/traduction.js';
|
||||
import { releverMarques } from '../../../../test/navigateur/captures.js';
|
||||
import { fixerCadre, pointer, touche } from '../../../../test/navigateur/entrees.js';
|
||||
import { attenuations, sousTheme } from '../../../../test/navigateur/rendu.js';
|
||||
import { TABLES_INTERFACE } from '../../libelles.js';
|
||||
import { LARGEUR_NOM_PX, margesCommunes } from './pointage.js';
|
||||
import Barres from './Barres.svelte';
|
||||
import Escalier from './Escalier.svelte';
|
||||
import Grille from './Grille.svelte';
|
||||
import Jumeau from './Jumeau.svelte';
|
||||
import Pourcentage from './Pourcentage.svelte';
|
||||
|
||||
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
|
||||
const CONTEXTE = new Map([
|
||||
['t', t],
|
||||
['format', creerFormat(t)],
|
||||
]);
|
||||
|
||||
const NOM_LONG = 'Maëlle-Hortense Quenouille-Vasque de Belle-Rive';
|
||||
const PRENOMS = ['Iris Ombrelle', 'Théo Pervenche', 'Ondine Lacasse', 'Basile Tourteau', NOM_LONG];
|
||||
const nomDe = (i) => PRENOMS[i % PRENOMS.length];
|
||||
|
||||
const montes = [];
|
||||
|
||||
// Le cadre des écrans de l'application (§ 19.4) : un graphique de 1 000 px
|
||||
// y tient en entier.
|
||||
beforeAll(async () => {
|
||||
await fixerCadre(1366, 700);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await fixerCadre(1366, 700);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const { composant, cible } of montes.splice(0)) {
|
||||
unmount(composant);
|
||||
cible.remove();
|
||||
}
|
||||
});
|
||||
|
||||
// Monte un composant dans un conteneur neuf de la largeur donnée, au début
|
||||
// du corps, puis laisse le graphique mesurer sa largeur et son texte.
|
||||
async function monter(Composant, props, largeur = 800) {
|
||||
const cible = document.createElement('div');
|
||||
cible.style.width = `${largeur}px`;
|
||||
document.body.prepend(cible);
|
||||
const composant = mount(Composant, { target: cible, props, context: CONTEXTE });
|
||||
flushSync();
|
||||
montes.push({ composant, cible });
|
||||
await tick();
|
||||
flushSync();
|
||||
await tick();
|
||||
return cible;
|
||||
}
|
||||
|
||||
// Un rang pointé partagé, comme bind:pointe le partage entre deux vues :
|
||||
// avec(props) rend les propriétés reçues augmentées de pointe, un accesseur
|
||||
// sur le magasin commun — une décomposition par « ... » copierait sa valeur
|
||||
// et perdrait la liaison.
|
||||
function rangPartage() {
|
||||
const rang = fromStore(writable(null));
|
||||
const accesseur = {
|
||||
get() {
|
||||
return rang.current;
|
||||
},
|
||||
set(valeur) {
|
||||
rang.current = valeur;
|
||||
},
|
||||
enumerable: true,
|
||||
};
|
||||
return { rang, avec: (props) => Object.defineProperty({ ...props }, 'pointe', accesseur) };
|
||||
}
|
||||
|
||||
// Des marges partagées par un groupe de graphiques, comme une vue les lie :
|
||||
// chaque graphique écrit les siennes (marges) et reçoit les marges communes
|
||||
// de tous (alignerSur, margesCommunes). Les accesseurs se posent sur l'objet
|
||||
// reçu même, qui garde ainsi celui de pointe.
|
||||
function margesPartagees(n) {
|
||||
const marges = Array.from({ length: n }, () => fromStore(writable(null)));
|
||||
return (props, i) =>
|
||||
Object.defineProperties(
|
||||
props,
|
||||
{
|
||||
marges: { get: () => marges[i].current, set: (v) => (marges[i].current = v), enumerable: true },
|
||||
alignerSur: { get: () => margesCommunes(marges.map((m) => m.current)), enumerable: true },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// La couleur calculée d'un jeton, lue sur un élément témoin.
|
||||
function couleurDuJeton(jeton) {
|
||||
const temoin = document.createElement('span');
|
||||
temoin.style.color = `var(${jeton})`;
|
||||
document.body.append(temoin);
|
||||
const couleur = getComputedStyle(temoin).color;
|
||||
temoin.remove();
|
||||
return couleur;
|
||||
}
|
||||
|
||||
// Le motif calculé d'un trait, ses longueurs en px ; [] pour un trait plein.
|
||||
const motifDe = (element) => (getComputedStyle(element).strokeDasharray.match(/[\d.]+/g) ?? []).map(Number);
|
||||
|
||||
// Le point (x, y) de l'élément SVG d'un graphique dans la fenêtre, pour un
|
||||
// point local au SVG : sans viewBox, le pixel du dessin est celui de l'écran.
|
||||
function versFenetre(svg, x, y) {
|
||||
const cadre = svg.getBoundingClientRect();
|
||||
return { x: cadre.left + x, y: cadre.top + y };
|
||||
}
|
||||
|
||||
// La surface de pointage d'un graphique : le rect sans remplissage qui
|
||||
// capte le pointeur.
|
||||
const surfaceDe = (svg) => svg.querySelector(':scope > rect[pointer-events="all"]');
|
||||
|
||||
const infobulleDe = (cible) => cible.querySelector('[role="tooltip"]');
|
||||
|
||||
// Le profil forgé de n personnes : rencontres croissantes, appartenances
|
||||
// rencontrées dessous, plafond réalisé en escalier, borne de diversité ;
|
||||
// marques aux rangs 2 et 4.
|
||||
function profil(n) {
|
||||
const rencontres = Array.from({ length: n }, (_, i) => 10 + Math.floor((17 * i) / Math.max(1, n - 1)));
|
||||
return {
|
||||
rencontres,
|
||||
appartenances: rencontres.map((r) => Math.floor(r / 3)),
|
||||
plafond: rencontres.map((_, i) => (i < n / 2 ? 27 : 26)),
|
||||
borne: rencontres.map(() => 6),
|
||||
marques: rencontres.map((_, i) => (i === 1 ? 'ancre' : i === 3 ? 'reserve' : null)),
|
||||
};
|
||||
}
|
||||
|
||||
const COLONNES_PROFIL = [
|
||||
{ cle: 'rang', libelle: 'Rang', genre: 'nombre' },
|
||||
{ cle: 'nom', libelle: 'Personne', genre: 'texte' },
|
||||
{ cle: 'rencontres', libelle: 'Rencontres', genre: 'nombre' },
|
||||
{ cle: 'part', libelle: 'Part', genre: 'pourcentage' },
|
||||
];
|
||||
|
||||
function infobulleProfil(p) {
|
||||
return {
|
||||
colonnes: COLONNES_PROFIL,
|
||||
lignes: p.rencontres.map((r, i) => ({
|
||||
rang: i + 1,
|
||||
nom: nomDe(i),
|
||||
rencontres: r,
|
||||
part: { texte: `${Math.floor((100 * r) / p.plafond[i])} %`, compte: `${r} sur ${p.plafond[i]}` },
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function propsProfil(n, extra = {}) {
|
||||
const p = profil(n);
|
||||
return {
|
||||
graphique: 'profil',
|
||||
nom: 'Qui rencontre le moins de monde ?',
|
||||
synthese: 'Minimum 10 rencontres, une personne.',
|
||||
series: [
|
||||
{ libelle: 'rencontres', valeurs: p.rencontres },
|
||||
{ libelle: 'appartenances rencontrées', valeurs: p.appartenances },
|
||||
],
|
||||
reperes: [
|
||||
{ genre: 'plafond', libelle: 'plafond réalisé', valeurs: p.plafond },
|
||||
{ genre: 'borne', libelle: 'borne de diversité', valeurs: p.borne },
|
||||
],
|
||||
marques: p.marques,
|
||||
infobulle: infobulleProfil(p),
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
describe('graphiques : l’escalier du profil (§ 12.3, D30)', () => {
|
||||
test('une image nommée et décrite, sans viewBox ; chaque trait peint son jeton, sans opacité ni filtre dans son ascendance', async () => {
|
||||
const cible = await monter(Escalier, propsProfil(12));
|
||||
const svg = cible.querySelector('svg[data-graphique="profil"]');
|
||||
expect(svg.getAttribute('role')).toBe('img');
|
||||
expect(svg.hasAttribute('viewBox')).toBe(false);
|
||||
expect(document.getElementById(svg.getAttribute('aria-labelledby')).textContent).toBe('Qui rencontre le moins de monde ?');
|
||||
expect(document.getElementById(svg.getAttribute('aria-describedby')).textContent).toBe('Minimum 10 rencontres, une personne.');
|
||||
const attendus = [
|
||||
['[data-serie="serie1"]', '--couleur-graphique-serie-1'],
|
||||
['[data-serie="serie2"]', '--couleur-graphique-serie-2'],
|
||||
['[data-repere="plafond"]', '--couleur-graphique-repere'],
|
||||
['[data-repere="borne"]', '--couleur-graphique-borne'],
|
||||
];
|
||||
for (const [selecteur, jeton] of attendus) {
|
||||
const trace = svg.querySelector(selecteur);
|
||||
expect(trace, selecteur).not.toBeNull();
|
||||
expect(getComputedStyle(trace).stroke, selecteur).toBe(couleurDuJeton(jeton));
|
||||
expect(attenuations(trace), selecteur).toEqual([]);
|
||||
}
|
||||
for (const marque of svg.querySelectorAll('[data-marque]')) expect(attenuations(marque)).toEqual([]);
|
||||
});
|
||||
|
||||
test('rouge : les quatre traits — plein, tirets, mixte, pointillé — ont des motifs calculés deux à deux distincts', async () => {
|
||||
const cible = await monter(Escalier, propsProfil(12));
|
||||
const motifs = ['[data-serie="serie1"]', '[data-serie="serie2"]', '[data-repere="plafond"]', '[data-repere="borne"]'].map(
|
||||
(s) => getComputedStyle(cible.querySelector(s)).strokeDasharray,
|
||||
);
|
||||
expect(new Set(motifs).size, motifs.join(' | ')).toBe(4);
|
||||
expect(motifs[0]).toBe('none');
|
||||
});
|
||||
|
||||
test('rouge : le plafond en tiret-point, la borne en pointillés plus courts que les tirets de la seconde série', async () => {
|
||||
const cible = await monter(Escalier, propsProfil(12));
|
||||
const [long, vide1, court, vide2, ...reste] = motifDe(cible.querySelector('path[data-repere="plafond"]'));
|
||||
expect([long, vide1, court, vide2].every(Number.isFinite) && reste.length === 0, 'plafond : tiret, vide, point, vide').toBe(true);
|
||||
expect(long).toBeGreaterThan(court);
|
||||
expect(court).toBeLessThanOrEqual(Math.min(vide1, vide2));
|
||||
const borne = motifDe(cible.querySelector('path[data-repere="borne"]'));
|
||||
expect(borne, 'borne : point, vide').toHaveLength(2);
|
||||
expect(borne[0]).toBeLessThanOrEqual(borne[1]);
|
||||
expect(borne[0]).toBeLessThan(motifDe(cible.querySelector('path[data-serie="serie2"]'))[0]);
|
||||
});
|
||||
|
||||
test('rouge : plus étroit que ses marges, l’escalier se rend sans lever, zone nulle', async () => {
|
||||
const cible = await monter(Escalier, propsProfil(12), 150);
|
||||
const svg = cible.querySelector('svg[data-graphique="profil"]');
|
||||
expect(svg).not.toBeNull();
|
||||
expect(svg.querySelector('[data-serie="serie1"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
test('un seul axe vertical ; chaque trace porte son libellé sur le tracé, au bout de sa ligne', async () => {
|
||||
const cible = await monter(Escalier, propsProfil(12));
|
||||
const svg = cible.querySelector('svg');
|
||||
const axes = [...svg.querySelectorAll('line[data-marque="graphiqueAxe"]')];
|
||||
expect(axes.filter((a) => a.getAttribute('x1') === a.getAttribute('x2'))).toHaveLength(1);
|
||||
const surface = surfaceDe(svg);
|
||||
const fin = Number(surface.getAttribute('x')) + Number(surface.getAttribute('width'));
|
||||
const textes = [...svg.querySelectorAll('text')];
|
||||
for (const libelle of ['rencontres', 'appartenances rencontrées', 'plafond réalisé', 'borne de diversité']) {
|
||||
const etiquette = textes.find((x) => x.textContent === libelle);
|
||||
expect(etiquette, libelle).toBeDefined();
|
||||
expect(Number(etiquette.getAttribute('x'))).toBeGreaterThan(fin);
|
||||
const boite = etiquette.getBoundingClientRect();
|
||||
expect(boite.right).toBeLessThanOrEqual(svg.getBoundingClientRect().right + 0.5);
|
||||
}
|
||||
});
|
||||
|
||||
test('un signe de forme aux seuls rangs marqués, de la marque du registre', async () => {
|
||||
const cible = await monter(Escalier, propsProfil(12));
|
||||
expect(cible.querySelectorAll('svg [data-marque="graphiqueMarque"]')).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('rouge : la surface de pointage est un rect sans remplissage, sœur des marques, jamais leur ancêtre', async () => {
|
||||
const cible = await monter(Escalier, propsProfil(12));
|
||||
const svg = cible.querySelector('svg');
|
||||
const surface = surfaceDe(svg);
|
||||
expect(surface).not.toBeNull();
|
||||
expect(surface.getAttribute('fill')).toBe('none');
|
||||
expect(surface.children).toHaveLength(0);
|
||||
for (const marque of svg.querySelectorAll('[data-marque]')) {
|
||||
expect(surface.contains(marque)).toBe(false);
|
||||
// Aucun ancêtre d'une marque, dans le dessin, ne capte le pointeur à
|
||||
// la place de la surface.
|
||||
for (let n = marque.parentElement; n !== svg; n = n.parentElement) {
|
||||
expect(getComputedStyle(n).pointerEvents, n.localName).not.toBe('all');
|
||||
}
|
||||
}
|
||||
// La surface est la dernière : rien de peint ne la couvre.
|
||||
expect(svg.lastElementChild).toBe(surface);
|
||||
});
|
||||
|
||||
test('rouge : à 260 rangs dans 1 000 px, le pointage désigne le rang le plus proche, et l’infobulle porte sa ligne', async () => {
|
||||
const cible = await monter(Escalier, propsProfil(260), 1000);
|
||||
const svg = cible.querySelector('svg');
|
||||
const surface = surfaceDe(svg);
|
||||
const x0 = Number(surface.getAttribute('x'));
|
||||
const pas = Number(surface.getAttribute('width')) / 260;
|
||||
const y = Number(surface.getAttribute('y')) + 5;
|
||||
for (let rang = 1; rang <= 260; rang += 1) {
|
||||
const p = versFenetre(svg, x0 + (rang - 0.5) * pas, y);
|
||||
await pointer('bouger', p.x, p.y);
|
||||
flushSync();
|
||||
expect(svg.querySelector('[data-rang]')?.getAttribute('data-rang'), `rang ${rang}`).toBe(String(rang));
|
||||
}
|
||||
const bulle = infobulleDe(cible);
|
||||
expect(bulle.hidden).toBe(false);
|
||||
expect(bulle.textContent).toContain(nomDe(259));
|
||||
expect(bulle.querySelector('[data-pourcentage] + [data-compte-pire]').textContent).not.toBe('');
|
||||
});
|
||||
|
||||
test('Échap ferme l’infobulle ; quitter le tracé aussi', async () => {
|
||||
const cible = await monter(Escalier, propsProfil(5));
|
||||
const svg = cible.querySelector('svg');
|
||||
const surface = surfaceDe(svg);
|
||||
const boite = surface.getBoundingClientRect();
|
||||
// Le document a le focus, comme quand l'opérateur survole puis tape :
|
||||
// une touche n'atteint que le document focalisé.
|
||||
const focalisable = document.createElement('button');
|
||||
cible.append(focalisable);
|
||||
focalisable.focus();
|
||||
await pointer('bouger', boite.left + 3, boite.top + 3);
|
||||
flushSync();
|
||||
expect(infobulleDe(cible).hidden).toBe(false);
|
||||
await touche('Escape');
|
||||
flushSync();
|
||||
expect(infobulleDe(cible).hidden).toBe(true);
|
||||
await pointer('bouger', boite.left + 4, boite.top + 3);
|
||||
flushSync();
|
||||
expect(infobulleDe(cible).hidden).toBe(false);
|
||||
await pointer('bouger', boite.right + 200, boite.bottom + 200);
|
||||
flushSync();
|
||||
expect(infobulleDe(cible).hidden).toBe(true);
|
||||
});
|
||||
|
||||
test('rouge : deux graphiques empilés — comptes, puis pourcentages — désignent le même rang pointé, l’infobulle dans le seul graphique survolé', async () => {
|
||||
const p = profil(20);
|
||||
const partage = rangPartage();
|
||||
const aligner = margesPartagees(2);
|
||||
const pourcents = p.rencontres.map((r, i) => (i === 6 ? null : Math.floor((100 * r) / p.plafond[i])));
|
||||
const haut = await monter(Escalier, aligner(partage.avec(propsProfil(20)), 0), 900);
|
||||
const bas = await monter(
|
||||
Escalier,
|
||||
aligner(partage.avec({
|
||||
graphique: 'profilPourcent',
|
||||
nom: 'Quelle part de son plafond chacun rencontre-t-il ?',
|
||||
synthese: 'minimum 10 sur 27',
|
||||
series: [{ libelle: 'part du plafond réalisé', valeurs: pourcents }],
|
||||
reperes: [{ genre: 'cent', libelle: '100 %', valeurs: p.rencontres.map(() => 100) }],
|
||||
graduer: (v) => `${v} %`,
|
||||
infobulle: infobulleProfil(p),
|
||||
}), 1),
|
||||
900,
|
||||
);
|
||||
await tick();
|
||||
flushSync();
|
||||
const svgHaut = haut.querySelector('svg');
|
||||
const surface = surfaceDe(svgHaut);
|
||||
const pas = Number(surface.getAttribute('width')) / 20;
|
||||
const point = versFenetre(svgHaut, Number(surface.getAttribute('x')) + 7.5 * pas, Number(surface.getAttribute('y')) + 4);
|
||||
await pointer('bouger', point.x, point.y);
|
||||
flushSync();
|
||||
expect(haut.querySelector('[data-rang]').getAttribute('data-rang')).toBe('8');
|
||||
expect(bas.querySelector('[data-rang]')?.getAttribute('data-rang')).toBe('8');
|
||||
expect(infobulleDe(haut).hidden).toBe(false);
|
||||
expect(infobulleDe(bas).hidden).toBe(true);
|
||||
// La règle à 100 % a le motif du plafond, la série en pourcentage le plein.
|
||||
const regle = bas.querySelector('path[data-repere="cent"]');
|
||||
expect(bas.querySelector('text[data-repere="cent"]').textContent).toBe('100 %');
|
||||
expect(getComputedStyle(regle).strokeDasharray).toBe(getComputedStyle(haut.querySelector('[data-repere="plafond"]')).strokeDasharray);
|
||||
expect(getComputedStyle(regle).strokeDasharray).not.toBe(getComputedStyle(bas.querySelector('[data-serie="serie1"]')).strokeDasharray);
|
||||
// Les deux abscisses s'alignent : même zone, rang contre rang ; le rang
|
||||
// 7, sans valeur, interrompt le tracé du second.
|
||||
const zones = [haut, bas].map((c) => ['x', 'width'].map((a) => surfaceDe(c.querySelector('svg')).getAttribute(a)));
|
||||
expect(zones[1]).toEqual(zones[0]);
|
||||
expect(bas.querySelector('[data-serie="serie1"]').getAttribute('d').match(/M/g)).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('rouge : un chemin ne relie jamais deux rangs par-dessus un rang sans valeur', async () => {
|
||||
const cible = await monter(Escalier, {
|
||||
graphique: 'trou',
|
||||
nom: 'Trou',
|
||||
synthese: 'Un rang sans valeur.',
|
||||
series: [{ libelle: 'part', valeurs: [40, null, 40, 60] }],
|
||||
});
|
||||
const d = cible.querySelector('[data-serie="serie1"]').getAttribute('d');
|
||||
expect(d.match(/M/g)).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('graphiques : les points de relevé (§ 12.14, D49)', () => {
|
||||
const ABSCISSES = [1, 10, 100, 1000, 10000, 20000];
|
||||
const multiple = (graphique, valeurs) => ({
|
||||
graphique,
|
||||
nom: `Le ${graphique} au fil de la recherche`,
|
||||
synthese: t('graphiques.synthese.pointsDeReleve'),
|
||||
forme: 'points',
|
||||
abscisses: ABSCISSES,
|
||||
series: [{ libelle: graphique, valeurs }],
|
||||
infobulle: {
|
||||
colonnes: [
|
||||
{ cle: 'mouvements', libelle: 'Mouvements', genre: 'nombre' },
|
||||
{ cle: 'valeur', libelle: 'Valeur', genre: 'nombre' },
|
||||
],
|
||||
lignes: ABSCISSES.map((mouvements, i) => ({ mouvements, valeur: valeurs[i] })),
|
||||
},
|
||||
hauteur: 140,
|
||||
});
|
||||
|
||||
test('rouge : un point aux seuls points de relevé ; le point pointé est désigné dans tous les petits multiples à la fois', async () => {
|
||||
const partage = rangPartage();
|
||||
const aligner = margesPartagees(2);
|
||||
const a = await monter(Escalier, aligner(partage.avec(multiple('collisions', [9, 7, 4, 2, 1, 1])), 0), 700);
|
||||
const b = await monter(Escalier, aligner(partage.avec(multiple('appartenances rencontrées', [3, 5, 8, 9, 10, 10])), 1), 700);
|
||||
await tick();
|
||||
flushSync();
|
||||
for (const cible of [a, b]) expect(cible.querySelectorAll('[data-point-releve]')).toHaveLength(6);
|
||||
// L'abscisse est commune : chaque point de relevé à la même abscisse.
|
||||
const abscisses = [a, b].map((c) => [...c.querySelectorAll('[data-point-releve]')].map((p) => p.getAttribute('cx')));
|
||||
expect(abscisses[1]).toEqual(abscisses[0]);
|
||||
const point = a.querySelector('[data-point-releve="100"]');
|
||||
const boite = point.getBoundingClientRect();
|
||||
await pointer('bouger', boite.left + boite.width / 2 + 2, boite.top + boite.height / 2);
|
||||
flushSync();
|
||||
for (const cible of [a, b]) {
|
||||
const designe = cible.querySelector('[data-rang]');
|
||||
expect(designe?.getAttribute('data-rang')).toBe('3');
|
||||
expect(designe.getAttribute('cx')).toBe(cible.querySelector('[data-point-releve="100"]').getAttribute('cx'));
|
||||
}
|
||||
expect(infobulleDe(a).hidden).toBe(false);
|
||||
expect(infobulleDe(b).hidden).toBe(true);
|
||||
});
|
||||
|
||||
test('rouge : l’infobulle écrit les nombres comme les axes, par format.nombre', async () => {
|
||||
const cible = await monter(Escalier, multiple('collisions', [9, 7, 4, 2, 1, 1]), 700);
|
||||
const boite = cible.querySelector('[data-point-releve="20000"]').getBoundingClientRect();
|
||||
await pointer('bouger', boite.left + boite.width / 2, boite.top + boite.height / 2);
|
||||
flushSync();
|
||||
const bulle = infobulleDe(cible);
|
||||
expect(bulle.hidden).toBe(false);
|
||||
expect(bulle.textContent).toContain('20\u{202F}000');
|
||||
expect(bulle.textContent).not.toContain('20000');
|
||||
const graduations = [...cible.querySelectorAll('svg text')].map((x) => x.textContent);
|
||||
expect(graduations).toContain('20\u{202F}000');
|
||||
});
|
||||
});
|
||||
|
||||
describe('graphiques : les barres (§ 12.4, § 12.13)', () => {
|
||||
const LIGNES = [
|
||||
{ id: 'aubier', nom: 'Coopérative Aubier', appartenance: 1, valeurs: [0, 3] },
|
||||
{ id: 'long', nom: NOM_LONG, appartenance: null, valeurs: [2, 0] },
|
||||
{ id: 'ressac', nom: 'Atelier Ressac', appartenance: 2, valeurs: [1, 1] },
|
||||
];
|
||||
const INFOBULLE = {
|
||||
colonnes: [
|
||||
{ cle: 'nom', libelle: 'Appartenance', genre: 'texte' },
|
||||
{ cle: 'cumulees', libelle: 'Collisions cumulées', genre: 'nombre' },
|
||||
],
|
||||
lignes: LIGNES.map((l) => ({ nom: l.nom, cumulees: l.valeurs[0] + l.valeurs[1] })),
|
||||
};
|
||||
const props = (extra) => ({
|
||||
graphique: 'collisions',
|
||||
nom: 'Où les appartenances se retrouvent-elles ?',
|
||||
synthese: 'Trois appartenances.',
|
||||
lignes: LIGNES,
|
||||
segments: [
|
||||
{ genre: 'prouve', libelle: 'prouvé inévitable' },
|
||||
{ genre: 'reste', libelle: 'reste' },
|
||||
],
|
||||
infobulle: INFOBULLE,
|
||||
...extra,
|
||||
});
|
||||
|
||||
test('rouge : un nom long se tronque sur le tracé, et reste entier dans l’infobulle et le jumeau', async () => {
|
||||
const cible = await monter(Barres, props());
|
||||
const ligne = cible.querySelector('[data-ligne-graphique="long"]');
|
||||
const intitule = [...ligne.querySelectorAll('text')].find((x) => x.textContent.startsWith('Maëlle'));
|
||||
expect(intitule.textContent).not.toBe(NOM_LONG);
|
||||
expect(intitule.textContent.endsWith(t('graphiques.troncature'))).toBe(true);
|
||||
expect(intitule.getComputedTextLength()).toBeLessThanOrEqual(LARGEUR_NOM_PX);
|
||||
const boite = ligne.querySelector('[data-segment]').getBoundingClientRect();
|
||||
await pointer('bouger', boite.left + 2, boite.top + boite.height / 2);
|
||||
flushSync();
|
||||
expect(infobulleDe(cible).hidden).toBe(false);
|
||||
expect(infobulleDe(cible).textContent).toContain(NOM_LONG);
|
||||
expect(cible.querySelector('[data-rang]').getAttribute('data-rang')).toBe('2');
|
||||
|
||||
const jumeau = await monter(Jumeau, { tableaux: [{ nom: 'Collisions', ...INFOBULLE }] });
|
||||
jumeau.querySelector('button').click();
|
||||
flushSync();
|
||||
expect(jumeau.querySelector('[data-jumeau]').textContent).toContain(NOM_LONG);
|
||||
});
|
||||
|
||||
test('rouge : le libellé de chaque segment se pose sur la première barre où il est non nul, et dans la légende', async () => {
|
||||
const cible = await monter(Barres, props());
|
||||
const svg = cible.querySelector('svg');
|
||||
const textes = [...svg.querySelectorAll('text')];
|
||||
for (const [libelle, id] of [['prouvé inévitable', 'long'], ['reste', 'aubier']]) {
|
||||
const etiquette = textes.find((x) => x.textContent === libelle);
|
||||
const barre = svg.querySelector(`[data-ligne-graphique="${id}"] [data-segment]`).getBoundingClientRect();
|
||||
const boite = etiquette.getBoundingClientRect();
|
||||
expect(boite.top, libelle).toBeGreaterThanOrEqual(barre.top - 1);
|
||||
expect(boite.bottom, libelle).toBeLessThanOrEqual(barre.bottom + 1);
|
||||
expect(cible.querySelector('ul').textContent).toContain(libelle);
|
||||
}
|
||||
for (const segment of svg.querySelectorAll('[data-segment]')) {
|
||||
expect(Number(segment.getAttribute('width'))).toBeGreaterThan(0);
|
||||
expect(attenuations(segment)).toEqual([]);
|
||||
}
|
||||
expect(svg.querySelectorAll('[data-segment="prouve"]')).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('rouge : chaque ligne porte ses autres chiffres en encre secondaire, après sa barre ; une ligne sans barre écrit « — »', async () => {
|
||||
const vide = t('format.vide');
|
||||
const cible = await monter(
|
||||
Barres,
|
||||
props({
|
||||
lignes: [
|
||||
{ ...LIGNES[0], secondaire: '7 personnes, 3 paires, excédent 2' },
|
||||
{ ...LIGNES[1], secondaire: '4 personnes, 2 paires, excédent 0' },
|
||||
{ id: 'sans', nom: 'Ondine Lacasse', valeurs: [0, 0], secondaire: vide },
|
||||
],
|
||||
infobulle: null,
|
||||
}),
|
||||
640,
|
||||
);
|
||||
const svg = cible.querySelector('svg');
|
||||
const droite = svg.getBoundingClientRect().right;
|
||||
const encre = couleurDuJeton('--couleur-page-texte-secondaire');
|
||||
for (const [id, texte] of [['aubier', '7 personnes, 3 paires, excédent 2'], ['long', '4 personnes, 2 paires, excédent 0'], ['sans', vide]]) {
|
||||
const ligne = svg.querySelector(`[data-ligne-graphique="${id}"]`);
|
||||
const ecrit = [...ligne.querySelectorAll('text')].find((x) => x.textContent === texte);
|
||||
expect(ecrit, id).toBeDefined();
|
||||
expect(getComputedStyle(ecrit).fill, id).toBe(encre);
|
||||
expect(attenuations(ecrit), id).toEqual([]);
|
||||
const boite = ecrit.getBoundingClientRect();
|
||||
expect(boite.right, id).toBeLessThanOrEqual(droite + 0.5);
|
||||
for (const segment of ligne.querySelectorAll('[data-segment]')) {
|
||||
expect(boite.left, id).toBeGreaterThanOrEqual(segment.getBoundingClientRect().right);
|
||||
}
|
||||
}
|
||||
// Le libellé du segment « reste », posé sur la ligne d'Aubier, précède
|
||||
// ses chiffres.
|
||||
const reste = [...svg.querySelectorAll('text')].find((x) => x.textContent === 'reste').getBoundingClientRect();
|
||||
const chiffres = [...svg.querySelectorAll('[data-ligne-graphique="aubier"] text')].find((x) => x.textContent.startsWith('7 personnes'));
|
||||
expect(chiffres.getBoundingClientRect().left).toBeGreaterThanOrEqual(reste.right);
|
||||
expect(svg.querySelectorAll('[data-ligne-graphique="sans"] [data-segment]')).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('deux colonnes juxtaposées, chacune son axe, les lignes dans le même ordre', async () => {
|
||||
const cible = await monter(Barres, {
|
||||
graphique: 'diversite',
|
||||
nom: 'Quel animateur rencontre le moins de diversité ?',
|
||||
synthese: 'Deux animateurs.',
|
||||
lignes: [
|
||||
{ id: 'iris', nom: 'Iris Ombrelle', valeurs: [2, 9] },
|
||||
{ id: 'theo', nom: 'Théo Pervenche', valeurs: [4, 1] },
|
||||
],
|
||||
colonnes: [
|
||||
{ genre: 'serie1', libelle: 'appartenances' },
|
||||
{ genre: 'serie2', libelle: 'étiquettes' },
|
||||
],
|
||||
});
|
||||
const svg = cible.querySelector('svg');
|
||||
const lignes = [...svg.querySelectorAll('[data-ligne-graphique]')].map((g) => g.getAttribute('data-ligne-graphique'));
|
||||
expect(lignes).toEqual(['iris', 'theo']);
|
||||
const [premiere, seconde] = ['serie1', 'serie2'].map((g) => svg.querySelector(`[data-ligne-graphique="theo"] [data-segment="${g}"]`));
|
||||
expect(premiere.getBoundingClientRect().right).toBeLessThan(seconde.getBoundingClientRect().left);
|
||||
expect(svg.querySelectorAll('line[data-marque="graphiqueAxe"]')).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('graphiques : la grille (§ 12.5, § 12.11, D33)', () => {
|
||||
const PERSONNES = [{ libelle: 'Iris Ombrelle' }, { libelle: 'Théo Pervenche' }, { libelle: 'Basile Tourteau' }];
|
||||
const VALEURS = [null, 1, 3, 1, null, 0, 3, 0, null];
|
||||
const props = (extra) => ({
|
||||
graphique: 'matrice',
|
||||
nom: 'Qui a rencontré qui ?',
|
||||
synthese: 'Une paire vue trois fois.',
|
||||
lignes: PERSONNES,
|
||||
colonnes: PERSONNES,
|
||||
valeurs: VALEURS,
|
||||
diagonale: true,
|
||||
infobulle: {
|
||||
colonnes: [{ cle: 'paire', libelle: 'Paire', genre: 'texte' }, { cle: 'fois', libelle: 'Fois', genre: 'nombre' }],
|
||||
lignes: VALEURS.map((fois, i) => ({ paire: `${PERSONNES[Math.floor(i / 3)].libelle} et ${PERSONNES[i % 3].libelle}`, fois })),
|
||||
},
|
||||
...extra,
|
||||
});
|
||||
|
||||
test('cases bordées, de leur niveau, le compte écrit de l’encre du niveau ; diagonale vide ; aucune case n’est une cible', async () => {
|
||||
const cible = await monter(Grille, props());
|
||||
const cases = cible.querySelectorAll('[data-case]');
|
||||
expect(cases).toHaveLength(9);
|
||||
const trois = cible.querySelector('[data-case="0:2"]');
|
||||
expect(trois.querySelector('[data-marque="graphiqueNiveau3"]')).not.toBeNull();
|
||||
expect(trois.querySelector('[data-marque="graphiqueBordureCase"]')).not.toBeNull();
|
||||
expect(trois.querySelector('text').textContent).toBe('3');
|
||||
expect(getComputedStyle(trois.querySelector('text')).fill).toBe(couleurDuJeton('--couleur-graphique-encre-claire'));
|
||||
const diagonale = cible.querySelector('[data-case="1:1"]');
|
||||
expect(diagonale.querySelector('text')?.textContent ?? '').toBe('');
|
||||
expect(diagonale.querySelector('[data-marque^="graphiqueNiveau"]')).toBeNull();
|
||||
for (const c of cases) {
|
||||
expect(c.querySelector('[tabindex], [role="button"], a, button')).toBeNull();
|
||||
expect(c.hasAttribute('tabindex')).toBe(false);
|
||||
}
|
||||
const zero = cible.querySelector('[data-case="1:2"]');
|
||||
expect(zero.querySelector('[data-marque^="graphiqueNiveau"]')).toBeNull();
|
||||
expect(zero.querySelector('text').textContent).toBe('0');
|
||||
});
|
||||
|
||||
test('le pointage désigne la case ; la diagonale n’ouvre rien', async () => {
|
||||
const cible = await monter(Grille, props());
|
||||
const boite = cible.querySelector('[data-case="1:2"] [data-marque="graphiqueBordureCase"]').getBoundingClientRect();
|
||||
await pointer('bouger', boite.left + boite.width / 2, boite.top + boite.height / 2);
|
||||
flushSync();
|
||||
expect(infobulleDe(cible).hidden).toBe(false);
|
||||
expect(infobulleDe(cible).textContent).toContain('Théo Pervenche et Basile Tourteau');
|
||||
const diag = cible.querySelector('[data-case="1:1"] [data-marque="graphiqueBordureCase"]').getBoundingClientRect();
|
||||
await pointer('bouger', diag.left + diag.width / 2, diag.top + diag.height / 2);
|
||||
flushSync();
|
||||
expect(infobulleDe(cible).hidden).toBe(true);
|
||||
});
|
||||
|
||||
test('une case sans compte écrit « — », sans couleur ; une grille trop étroite cède la place à sa phrase', async () => {
|
||||
const cible = await monter(Grille, props({ diagonale: false }));
|
||||
const vide = cible.querySelector('[data-case="0:0"]');
|
||||
expect(vide.querySelector('text').textContent).toBe(t('format.vide'));
|
||||
expect(vide.querySelector('[data-marque^="graphiqueNiveau"]')).toBeNull();
|
||||
const etroite = await monter(Grille, props({ phrase: 'La grille ne tient pas dans la carte.' }), 60);
|
||||
expect(etroite.querySelector('svg')).toBeNull();
|
||||
expect(etroite.textContent).toContain('La grille ne tient pas dans la carte.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('graphiques : le jumeau (§ 12.7, D44)', () => {
|
||||
const TABLEAU = {
|
||||
nom: 'Profil des rencontres',
|
||||
colonnes: COLONNES_PROFIL,
|
||||
lignes: [
|
||||
{ rang: 1, nom: 'Iris Ombrelle', rencontres: 24, part: { texte: '88 %', compte: '24 sur 27' } },
|
||||
{ rang: 2, nom: 'Théo Pervenche', rencontres: 0, part: { texte: null, compte: '0 sur 0' } },
|
||||
],
|
||||
};
|
||||
|
||||
test('rouge : ouvert au clavier, refermé au clavier, le focus revient à la commande, jamais sur body', async () => {
|
||||
const cible = await monter(Jumeau, { tableaux: [TABLEAU] });
|
||||
const avant = document.createElement('button');
|
||||
avant.textContent = 'avant';
|
||||
cible.prepend(avant);
|
||||
avant.focus();
|
||||
await touche('Tab');
|
||||
const commande = document.activeElement;
|
||||
expect(commande.getAttribute('data-commande')).toBe('ouvrirJumeau');
|
||||
expect(commande.getAttribute('aria-expanded')).toBe('false');
|
||||
expect(cible.querySelector('[data-jumeau]')).toBeNull();
|
||||
await touche('Enter');
|
||||
flushSync();
|
||||
await tick();
|
||||
expect(commande.getAttribute('aria-expanded')).toBe('true');
|
||||
expect(commande.getAttribute('data-commande')).toBe('fermerJumeau');
|
||||
expect(cible.querySelector('table[data-jumeau]')).not.toBeNull();
|
||||
await touche('Enter');
|
||||
flushSync();
|
||||
await tick();
|
||||
expect(cible.querySelector('[data-jumeau]')).toBeNull();
|
||||
expect(document.activeElement).toBe(commande);
|
||||
expect(document.activeElement).not.toBe(document.body);
|
||||
expect(commande.getAttribute('aria-expanded')).toBe('false');
|
||||
});
|
||||
|
||||
test('rouge : chaque cellule de pourcentage est suivie du compte de sa ligne ; « — » sans valeur', async () => {
|
||||
const cible = await monter(Jumeau, { tableaux: [TABLEAU], ouvert: true });
|
||||
const cellules = cible.querySelectorAll('td[data-colonne="part"]');
|
||||
expect(cellules).toHaveLength(2);
|
||||
const lus = [...cellules].map((c) => [c.querySelector('[data-pourcentage]').textContent, c.querySelector('[data-pourcentage] + [data-compte-pire]').textContent]);
|
||||
expect(lus).toEqual([
|
||||
['88 %', '24 sur 27'],
|
||||
[t('format.vide'), '0 sur 0'],
|
||||
]);
|
||||
expect(cible.querySelector('th[scope="row"]').textContent).toBe('1');
|
||||
});
|
||||
|
||||
test('rouge : une cellule de nombre s’écrit par format.nombre, aux décimales de sa colonne', async () => {
|
||||
const tableau = {
|
||||
nom: 'Course',
|
||||
colonnes: [
|
||||
{ cle: 'nom', libelle: 'Personne', genre: 'texte' },
|
||||
{ cle: 'mouvements', libelle: 'Mouvements', genre: 'nombre' },
|
||||
{ cle: 'taux', libelle: 'Taux de diversité', genre: 'nombre', decimales: 2 },
|
||||
],
|
||||
lignes: [{ nom: 'Basile Tourteau', mouvements: 20000, taux: 0.75 }],
|
||||
};
|
||||
const cible = await monter(Jumeau, { tableaux: [tableau], ouvert: true });
|
||||
expect(cible.querySelector('td[data-colonne="mouvements"]').textContent.trim()).toBe('20\u{202F}000');
|
||||
expect(cible.querySelector('td[data-colonne="taux"]').textContent.trim()).toBe('0,75');
|
||||
});
|
||||
});
|
||||
|
||||
describe('graphiques : le pourcentage et son compte (D34, contrainte 44)', () => {
|
||||
test('rouge : le compte se rend juste sous le pourcentage ; « — » quand le pourcentage n’a pas de valeur', async () => {
|
||||
const cible = await monter(Pourcentage, { texte: '88 %', compte: 'minimum 24 sur 27' });
|
||||
const valeur = cible.querySelector('[data-pourcentage]');
|
||||
const compte = cible.querySelector('[data-pourcentage] + [data-compte-pire]');
|
||||
expect(valeur.textContent).toBe('88 %');
|
||||
expect(compte.textContent).toBe('minimum 24 sur 27');
|
||||
expect(compte.getBoundingClientRect().top).toBeGreaterThanOrEqual(valeur.getBoundingClientRect().bottom - 0.5);
|
||||
const vide = await monter(Pourcentage, { texte: null, compte: '0 sur 0' });
|
||||
expect(vide.querySelector('[data-pourcentage]').textContent).toBe(t('format.vide'));
|
||||
expect(vide.querySelector('[data-compte-pire]').textContent).toBe('0 sur 0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('graphiques : les marques mesurées dans les deux thèmes (§ 14.5)', () => {
|
||||
test('chaque marque de graphique rendue est au registre, peinte de son jeton, à 3:1 de ce qui l’entoure', async () => {
|
||||
const racine = document.createElement('div');
|
||||
racine.style.background = 'var(--couleur-carte-fond)';
|
||||
const montages = [
|
||||
await monter(Escalier, propsProfil(12)),
|
||||
await monter(Barres, {
|
||||
graphique: 'collisions',
|
||||
nom: 'Collisions',
|
||||
synthese: 'Deux.',
|
||||
lignes: [{ id: 'a', nom: 'Atelier Ressac', appartenance: 1, valeurs: [2, 1] }],
|
||||
segments: [
|
||||
{ genre: 'prouve', libelle: 'prouvé inévitable' },
|
||||
{ genre: 'reste', libelle: 'reste' },
|
||||
],
|
||||
}),
|
||||
await monter(Grille, {
|
||||
graphique: 'grille',
|
||||
nom: 'Grille',
|
||||
synthese: 'Quatre niveaux.',
|
||||
lignes: [{ libelle: 'Table 1' }, { libelle: 'Table 2' }],
|
||||
colonnes: [{ libelle: 'Tour 1' }, { libelle: 'Tour 2' }],
|
||||
valeurs: [0, 1, 2, 3],
|
||||
}),
|
||||
];
|
||||
for (const m of montages) racine.append(m);
|
||||
document.body.prepend(racine);
|
||||
try {
|
||||
for (const theme of ['clair', 'sombre']) {
|
||||
const releve = await sousTheme(theme, () => releverMarques(racine));
|
||||
console.log(`contrastes ${theme} : ${releve.ratios.map((r) => `${r.marque} ${r.ratio.toFixed(2)}`).join(', ')}`);
|
||||
expect(releve.fautes, `${theme} :\n${releve.fautes.join('\n')}`).toEqual([]);
|
||||
expect(releve.attenuations, theme).toEqual([]);
|
||||
expect(releve.mesures, theme).toBeGreaterThan(0);
|
||||
}
|
||||
} finally {
|
||||
racine.remove();
|
||||
}
|
||||
});
|
||||
});
|
||||
815
src/interface/qualite/graphiques/pointage.js
Normal file
|
|
@ -0,0 +1,815 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Le modèle pur des composants de graphique de la page de qualité (§ 12.3,
|
||||
// § 12.4, § 12.5, § 12.7) : les traits et leurs motifs, la disposition d'un
|
||||
// escalier, de points de relevé, de barres et d'une grille, le pointage au
|
||||
// plus proche de chaque forme, la ligne de l'infobulle et les cellules du
|
||||
// jumeau. Les composants n'ont ni Math, ni tri, ni réduction (§ 12.9) : tout
|
||||
// pixel qu'ils posent sort d'ici, et toute grandeur d'ici sort de la
|
||||
// géométrie des graphiques (graphique.js). Aucune fonction ne lit le DOM :
|
||||
// la largeur d'un texte vient d'une mesure injectée, mesurer(texte) → px,
|
||||
// relevée sur la police rendue, et la hauteur d'une ligne de texte,
|
||||
// hauteurTexte, aussi.
|
||||
//
|
||||
// Les valeurs reçues sont déjà rangées par le moteur, dans l'ordre où le
|
||||
// détail les porte : rien n'est trié ni recompté ici, hors l'ordre vertical
|
||||
// des libellés posés au bout des tracés, que espacerEtiquettes écarte.
|
||||
//
|
||||
// Un indice désigne une cible dans l'ordre reçu, à partir de 0 : le rang
|
||||
// d'indice i est le rang i + 1 du profil ; une case de grille a l'indice
|
||||
// ligne × colonnes + colonne. La ligne de l'infobulle d'une cible est la
|
||||
// ligne de même indice des données que le composant reçoit.
|
||||
|
||||
import { constante } from '../../../geometrie/constantes.js';
|
||||
import {
|
||||
barres,
|
||||
cheminEscalier,
|
||||
cheminPoints,
|
||||
colonnesJuxtaposees,
|
||||
domaineDe,
|
||||
echelleLineaire,
|
||||
echelleLog10,
|
||||
graduations,
|
||||
grille,
|
||||
niveau,
|
||||
pointLePlusProche,
|
||||
rangLePlusProche,
|
||||
regleOuEscalier,
|
||||
tronquer,
|
||||
} from '../../../geometrie/graphique.js';
|
||||
import { NIVEAUX_GRAPHIQUE } from '../../marques.js';
|
||||
|
||||
/** La largeur où un nom se tronque dans un intitulé de graphique, lue au
|
||||
* registre des constantes mesurées (§ 14.1). */
|
||||
export const LARGEUR_NOM_PX = constante('LARGEUR_TRONCATURE_NOM_PX');
|
||||
|
||||
/** L'espace entre un texte et ce qu'il nomme, en px. */
|
||||
export const ESPACE_PX = 6;
|
||||
|
||||
/** Le demi-côté du signe d'un rang marqué, en px. */
|
||||
export const SIGNE_PX = 4;
|
||||
|
||||
// Le plus grand nombre de graduations d'un axe de valeurs, puis de l'axe
|
||||
// des rangs.
|
||||
const GRADUATIONS_VALEURS = 5;
|
||||
const GRADUATIONS_RANGS = 6;
|
||||
|
||||
// L'écart entre deux lignes de barres, et entre deux colonnes juxtaposées.
|
||||
const ECART_LIGNES_PX = 4;
|
||||
const ECART_COLONNES_PX = 24;
|
||||
|
||||
// Le rayon du point et le retrait de la pastille d'un intitulé de barres.
|
||||
const POINT_PASTILLE_PX = 4;
|
||||
const RETRAIT_PASTILLE_PX = 6;
|
||||
|
||||
/** L'écart entre un axe et la zone qu'il borde, en px : un tracé posé au
|
||||
* bord de la zone — une valeur nulle, le premier rang, une barre qui part
|
||||
* de zéro — ne touche pas l'axe, et chaque marque se lit sur le fond de la
|
||||
* carte (§ 14.5). */
|
||||
export const ECART_AXE_PX = 2;
|
||||
|
||||
/** L'écart entre deux segments empilés d'une même barre, pris sur le
|
||||
* second, en px : deux remplissages ne se touchent pas. */
|
||||
export const ECART_SEGMENTS_PX = 1;
|
||||
|
||||
/** Le retrait du remplissage d'une case dans sa bordure, en px : la bordure
|
||||
* se lit sur le fond de la carte, et le niveau aussi. */
|
||||
export const RETRAIT_CASE_PX = 2;
|
||||
|
||||
const jeton = (nom) => `--couleur-graphique-${nom}`;
|
||||
const trait = (marque, nom, motif) => Object.freeze({ marque, jeton: jeton(nom), motif });
|
||||
const remplissage = (marque, nom) => Object.freeze({ marque, jeton: jeton(nom) });
|
||||
|
||||
/** Les traits d'un graphique, par genre (D30, § 12.7) : { marque, jeton,
|
||||
* motif } — la désignation data-marque du registre (marques.js), le jeton
|
||||
* qui le peint, et son stroke-dasharray. Les deux séries en plein et en
|
||||
* tirets ; un plafond en trait mixte, tiret-point, et la règle à 100 %,
|
||||
* son image, du même ; une borne en pointillés. Quatre motifs, deux à deux
|
||||
* distincts. */
|
||||
export const TRAITS = Object.freeze({
|
||||
serie1: trait('graphiqueSerie1', 'serie-1', 'none'),
|
||||
serie2: trait('graphiqueSerie2', 'serie-2', '6 4'),
|
||||
plafond: trait('graphiqueRepere', 'repere', '8 3 2 3'),
|
||||
cent: trait('graphiqueRepere', 'repere', '8 3 2 3'),
|
||||
borne: trait('graphiqueBorne', 'borne', '2 3'),
|
||||
});
|
||||
|
||||
/** Les remplissages d'un segment de barre, par genre : { marque, jeton }. */
|
||||
export const REMPLISSAGES = Object.freeze({
|
||||
serie1: remplissage('graphiqueSerie1', 'serie-1'),
|
||||
serie2: remplissage('graphiqueSerie2', 'serie-2'),
|
||||
prouve: remplissage('graphiqueProuve', 'prouve'),
|
||||
reste: remplissage('graphiqueReste', 'reste'),
|
||||
});
|
||||
|
||||
/** Les signes d'un rang marqué (§ 12.3), un par marque du profil : chacun
|
||||
* sa forme, aucune couleur propre. */
|
||||
export const SIGNES = Object.freeze(['ancre', 'partiel', 'reserve', 'plafondNul']);
|
||||
|
||||
/** Les genres d'une colonne de jumeau. */
|
||||
export const GENRES_COLONNE = Object.freeze(['texte', 'nombre', 'pourcentage']);
|
||||
|
||||
const GENRES_SERIE = ['serie1', 'serie2'];
|
||||
const GENRES_REPERE = ['plafond', 'borne', 'cent'];
|
||||
|
||||
function exiger(condition, message, Erreur = RangeError) {
|
||||
if (!condition) throw new Erreur(message);
|
||||
}
|
||||
|
||||
const estTexte = (v) => typeof v === 'string';
|
||||
const estFini = (v) => typeof v === 'number' && Number.isFinite(v);
|
||||
|
||||
// La largeur du plus long des textes, 0 sans texte.
|
||||
const plusLarge = (textes, mesurer) => Math.max(0, ...textes.map((texte) => mesurer(texte)));
|
||||
|
||||
// Une trace reçue : son libellé, ses valeurs, une par rang.
|
||||
function exigerTrace(trace, quoi) {
|
||||
exiger(trace !== null && typeof trace === 'object', `${quoi} : objet attendu`, TypeError);
|
||||
exiger(estTexte(trace.libelle), `${quoi} : libellé attendu`, TypeError);
|
||||
exiger(Array.isArray(trace.valeurs), `${quoi} : liste de valeurs attendue`, TypeError);
|
||||
}
|
||||
|
||||
/**
|
||||
* Les traces d'un escalier ou de points : une ou deux séries, la première
|
||||
* en plein, la seconde en tirets, et zéro à deux repères, chacun plafond,
|
||||
* borne ou règle à 100 % (cent), de motifs distincts. Toutes ont autant de
|
||||
* valeurs, n. Lève RangeError sur un compte, un genre ou une longueur hors
|
||||
* contrat, ou deux repères du même motif — un plafond et une règle à
|
||||
* 100 % —, TypeError sur une trace qui n'a pas de libellé ou de valeurs.
|
||||
* @param {Array<{libelle: string, valeurs: Array<number|null>}>} series
|
||||
* @param {Array<{genre: string, libelle: string, valeurs: Array<number|null>}>} reperes
|
||||
* @returns {{n: number, traces: Array<{genre: string, libelle: string, valeurs: Array<number|null>}>}}
|
||||
*/
|
||||
export function exigerTraces(series, reperes = []) {
|
||||
exiger(Array.isArray(series) && series.length >= 1 && series.length <= 2, 'séries : une ou deux attendues');
|
||||
exiger(Array.isArray(reperes) && reperes.length <= 2, 'repères : deux au plus');
|
||||
series.forEach((s, i) => exigerTrace(s, `série ${i + 1}`));
|
||||
reperes.forEach((r, i) => {
|
||||
exigerTrace(r, `repère ${i + 1}`);
|
||||
exiger(GENRES_REPERE.includes(r.genre), `repère ${i + 1} : genre plafond, borne ou cent attendu, reçu ${r.genre}`);
|
||||
});
|
||||
if (reperes.length === 2) {
|
||||
exiger(TRAITS[reperes[0].genre].motif !== TRAITS[reperes[1].genre].motif, 'repères : chaque trait a son motif');
|
||||
}
|
||||
const n = series[0].valeurs.length;
|
||||
const traces = [
|
||||
...series.map((s, i) => ({ genre: GENRES_SERIE[i], libelle: s.libelle, valeurs: s.valeurs })),
|
||||
...reperes.map((r) => ({ genre: r.genre, libelle: r.libelle, valeurs: r.valeurs })),
|
||||
];
|
||||
for (const t of traces) exiger(t.valeurs.length === n, `${t.libelle} : ${n} valeurs attendues, reçu ${t.valeurs.length}`);
|
||||
return { n, traces };
|
||||
}
|
||||
|
||||
/**
|
||||
* Écarte des libellés posés à des ordonnées voulues : dans l'ordre de leurs
|
||||
* ordonnées — l'indice à égalité —, chacun à pas au moins sous le
|
||||
* précédent ; puis, du dernier au premier, chacun ramené sous max et à pas
|
||||
* au moins au-dessus du suivant ; enfin aucun au-dessus de min. Rend les
|
||||
* ordonnées dans l'ordre reçu.
|
||||
* @param {number[]} ys
|
||||
* @param {number} pas
|
||||
* @param {number} min
|
||||
* @param {number} max
|
||||
* @returns {number[]}
|
||||
*/
|
||||
export function espacerEtiquettes(ys, pas, min, max) {
|
||||
const ordre = ys.map((y, i) => ({ y, i })).sort((a, b) => a.y - b.y || a.i - b.i);
|
||||
for (let k = 1; k < ordre.length; k += 1) ordre[k].y = Math.max(ordre[k].y, ordre[k - 1].y + pas);
|
||||
for (let k = ordre.length - 1; k >= 0; k -= 1) {
|
||||
const plafond = k === ordre.length - 1 ? max : ordre[k + 1].y - pas;
|
||||
ordre[k].y = Math.max(min, Math.min(ordre[k].y, plafond));
|
||||
}
|
||||
const rendu = new Array(ys.length);
|
||||
for (const { y, i } of ordre) rendu[i] = y;
|
||||
return rendu;
|
||||
}
|
||||
|
||||
// La zone de tracé d'un graphique à axe vertical : à gauche, les
|
||||
// graduations de l'axe ; à droite, les libellés des traces et le titre de
|
||||
// l'abscisse ; en bas, les graduations de l'abscisse. Une largeur plus
|
||||
// petite que les marges laisse une zone nulle.
|
||||
function zoneDe({ largeur, hauteur, gauche, droite, hauteurTexte }) {
|
||||
const haut = hauteurTexte / 2;
|
||||
const bas = hauteurTexte + ESPACE_PX;
|
||||
return {
|
||||
x: gauche,
|
||||
y: haut,
|
||||
largeur: Math.max(0, largeur - gauche - droite),
|
||||
hauteur: Math.max(0, hauteur - haut - bas),
|
||||
};
|
||||
}
|
||||
|
||||
// Les deux axes d'une zone, à ECART_AXE_PX d'elle : le vertical à gauche,
|
||||
// l'horizontal en bas.
|
||||
function axesDe(zone) {
|
||||
const gauche = zone.x - ECART_AXE_PX;
|
||||
const bas = zone.y + zone.hauteur + ECART_AXE_PX;
|
||||
return [
|
||||
{ x1: gauche, y1: zone.y, x2: gauche, y2: bas },
|
||||
{ x1: gauche, y1: bas, x2: zone.x + zone.largeur, y2: bas },
|
||||
];
|
||||
}
|
||||
|
||||
// L'axe des valeurs de traces : son domaine, de zéro à la plus grande
|
||||
// valeur, et ses graduations entières, écrites.
|
||||
function axeDesValeurs(traces, graduer) {
|
||||
const domaine = domaineDe(...traces.map((t) => t.valeurs));
|
||||
const valeursY = graduations(domaine[0], domaine[1], GRADUATIONS_VALEURS, { entieres: true });
|
||||
return { domaine, valeursY, textesY: valeursY.map((v) => graduer(v)) };
|
||||
}
|
||||
|
||||
// Les marges qu'un graphique à axe vertical demande pour son seul texte :
|
||||
// à gauche, la plus large graduation ; à droite, le plus long libellé de
|
||||
// trace et le titre de l'abscisse.
|
||||
function margesDe(traces, textesY, titreX, mesurer) {
|
||||
return {
|
||||
gauche: plusLarge(textesY, mesurer) + ESPACE_PX,
|
||||
droite: plusLarge([...traces.map((t) => t.libelle), ...(titreX ? [titreX] : [])], mesurer) + 2 * ESPACE_PX,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Les marges qu'un escalier ou des points de relevé demandent pour leur
|
||||
* texte, {gauche, droite} en px : elles ne dépendent que des traces, des
|
||||
* graduations et du titre de l'abscisse, jamais d'un graphique voisin.
|
||||
* Deux graphiques empilés, ou des petits multiples, s'alignent en recevant
|
||||
* chacun, par l'option aligner de leur disposition, margesCommunes des
|
||||
* marges de tous (§ 12.3 : les abscisses alignées).
|
||||
*/
|
||||
export function margesVerticales({ series, reperes = [], mesurer, graduer, titreX = null }) {
|
||||
const { traces } = exigerTraces(series, reperes);
|
||||
return margesDe(traces, axeDesValeurs(traces, graduer).textesY, titreX, mesurer);
|
||||
}
|
||||
|
||||
/** Les plus grandes marges d'une liste, {gauche, droite} ; null ignoré,
|
||||
* zéro sans marge. */
|
||||
export function margesCommunes(liste) {
|
||||
const connues = liste.filter((m) => m !== null && m !== undefined);
|
||||
return {
|
||||
gauche: Math.max(0, ...connues.map((m) => m.gauche)),
|
||||
droite: Math.max(0, ...connues.map((m) => m.droite)),
|
||||
};
|
||||
}
|
||||
|
||||
// Le cadre commun d'un escalier et de points de relevé : l'axe des valeurs,
|
||||
// ses graduations entières, la zone, et les libellés des traces au bout de
|
||||
// leur tracé — à la dernière valeur non nulle, au bas de la zone sans
|
||||
// valeur —, écartés d'une ligne de texte au moins. aligner, null ou des
|
||||
// marges communes, élargit les marges du texte propre jusqu'à elles.
|
||||
function cadreVertical({ largeur, hauteur, traces, mesurer, hauteurTexte, graduer, titreX, aligner }) {
|
||||
const { domaine, valeursY, textesY } = axeDesValeurs(traces, graduer);
|
||||
const propres = margesDe(traces, textesY, titreX, mesurer);
|
||||
const { gauche, droite } = margesCommunes([propres, aligner]);
|
||||
const zone = zoneDe({ largeur, hauteur, gauche, droite, hauteurTexte });
|
||||
const ey = echelleLineaire({ domaine, plage: [zone.y + zone.hauteur, zone.y] });
|
||||
const graduationsY = valeursY.map((valeur, i) => ({ valeur, texte: textesY[i], y: ey.versPx(valeur) }));
|
||||
const bas = zone.y + zone.hauteur;
|
||||
const voulues = traces.map((t) => {
|
||||
const derniere = t.valeurs.findLast((v) => v !== null);
|
||||
return derniere === undefined ? bas : ey.versPx(derniere);
|
||||
});
|
||||
const ys = espacerEtiquettes(voulues, hauteurTexte, zone.y, bas);
|
||||
const xEtiquette = zone.x + zone.largeur + ESPACE_PX;
|
||||
const etiquettes = ys.map((y) => ({ x: xEtiquette, y }));
|
||||
const titre = titreX ? { texte: titreX, x: xEtiquette, y: bas + ESPACE_PX + hauteurTexte / 2 } : null;
|
||||
return { zone, ey, graduationsY, etiquettes, titreX: titre, axes: axesDe(zone) };
|
||||
}
|
||||
|
||||
/** Le tracé d'un signe de forme centré sur (x, y), de SIGNE_PX de
|
||||
* demi-côté : un triangle — plein pour un ancré, creux pour un placement
|
||||
* partiellement fixé —, un carré creux pour une réserve, une croix pour un
|
||||
* plafond réalisé nul. Le graphique et sa légende le tracent l'un et
|
||||
* l'autre. */
|
||||
export function cheminSigne(genre, x, y) {
|
||||
const s = SIGNE_PX;
|
||||
const n = (v) => String(Math.round(v * 100) / 100 + 0);
|
||||
const triangle = `M ${n(x)} ${n(y - s)} L ${n(x + s)} ${n(y + s)} L ${n(x - s)} ${n(y + s)} Z`;
|
||||
if (genre === 'ancre' || genre === 'partiel') return triangle;
|
||||
if (genre === 'reserve') return `M ${n(x - s)} ${n(y - s)} H ${n(x + s)} V ${n(y + s)} H ${n(x - s)} Z`;
|
||||
return `M ${n(x - s)} ${n(y - s)} L ${n(x + s)} ${n(y + s)} M ${n(x + s)} ${n(y - s)} L ${n(x - s)} ${n(y + s)}`;
|
||||
}
|
||||
|
||||
// La légende d'un graphique à traces : chaque trace sous son libellé, puis
|
||||
// chaque signe présent, dans l'ordre de SIGNES.
|
||||
function legendeDe(traces, signes = []) {
|
||||
const presents = new Set(signes.map((s) => s.genre));
|
||||
return [
|
||||
...traces.map((t) => ({ genre: t.genre, libelle: t.libelle, signe: null })),
|
||||
...SIGNES.filter((s) => presents.has(s)).map((signe) => ({ genre: 'signe', libelle: null, signe })),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* La disposition d'un escalier par rang (§ 12.3) : une ou deux séries et
|
||||
* leurs repères sur un seul axe vertical, qui part de zéro, l'abscisse
|
||||
* linéaire des rangs. marques : null, ou une entrée par rang, null ou un
|
||||
* genre de SIGNES — le signe se pose au centre du rang, à la valeur de la
|
||||
* première série, au bas de la zone quand elle est nulle. graduer(v) écrit
|
||||
* une graduation de l'axe vertical ; titreX, déjà traduit, nomme
|
||||
* l'abscisse, ou null. Les libellés des traces se posent au bout de leur
|
||||
* tracé, dans la marge droite mesurée. aligner : null, ou les marges
|
||||
* communes d'un groupe de graphiques (margesVerticales, margesCommunes).
|
||||
* Chaque graduation de l'abscisse, {valeur, texte, x}, porte son rang :
|
||||
* à zone nulle, toutes ont le même x, et la valeur reste propre à chacune.
|
||||
* @returns {{n, zone, ex, ey, axes, graduationsY, graduationsX, titreX,
|
||||
* traces: Array<{genre, libelle, valeurs, d, etiquette: {x, y}}>,
|
||||
* signes: Array<{indice, genre, x, y, d, plein}>, legende}}
|
||||
*/
|
||||
export function dispositionEscalier({
|
||||
largeur,
|
||||
hauteur,
|
||||
series,
|
||||
reperes = [],
|
||||
marques = null,
|
||||
mesurer,
|
||||
hauteurTexte,
|
||||
graduer,
|
||||
titreX = null,
|
||||
aligner = null,
|
||||
}) {
|
||||
const { n, traces } = exigerTraces(series, reperes);
|
||||
if (marques !== null) {
|
||||
exiger(Array.isArray(marques) && marques.length === n, `marques : ${n} entrées attendues`);
|
||||
for (const m of marques) exiger(m === null || SIGNES.includes(m), `marque inconnue : ${m}`);
|
||||
}
|
||||
const cadre = cadreVertical({ largeur, hauteur, traces, mesurer, hauteurTexte, graduer, titreX, aligner });
|
||||
const { zone, ey } = cadre;
|
||||
const ex = echelleLineaire({ domaine: [0, n], plage: [zone.x, zone.x + zone.largeur] });
|
||||
const bas = zone.y + zone.hauteur;
|
||||
const tracees = traces.map((t, i) => ({
|
||||
...t,
|
||||
d: t.genre.startsWith('serie') ? cheminEscalier(t.valeurs, ex, ey) : regleOuEscalier(t.valeurs, ex, ey).d,
|
||||
etiquette: cadre.etiquettes[i],
|
||||
}));
|
||||
const signes = [];
|
||||
(marques ?? []).forEach((genre, indice) => {
|
||||
if (genre === null) return;
|
||||
const v = traces[0].valeurs[indice];
|
||||
const x = ex.versPx(indice + 0.5);
|
||||
const y = v === null ? bas : ey.versPx(v);
|
||||
signes.push({ indice, genre, x, y, d: cheminSigne(genre, x, y), plein: genre === 'ancre' });
|
||||
});
|
||||
const graduationsX =
|
||||
n === 0
|
||||
? []
|
||||
: graduations(1, n, GRADUATIONS_RANGS, { entieres: true }).map((rang) => ({ valeur: rang, texte: graduer(rang), x: ex.versPx(rang - 0.5) }));
|
||||
return { ...cadre, n, ex, traces: tracees, signes, graduationsX, legende: legendeDe(tracees, signes) };
|
||||
}
|
||||
|
||||
/** L'indice du rang sous l'abscisse x d'un escalier, au plus proche, borné
|
||||
* aux rangs du tracé (§ 12.7). */
|
||||
export function cibleRang(x, disposition) {
|
||||
return rangLePlusProche(x, disposition.ex, Math.max(1, disposition.n)) - 1;
|
||||
}
|
||||
|
||||
/** Le point où s'ancre l'infobulle du rang d'indice donné : son centre, à
|
||||
* la valeur de la première série, au bas de la zone quand elle est nulle. */
|
||||
export function ancreRang(indice, disposition) {
|
||||
const v = disposition.traces[0].valeurs[indice];
|
||||
return {
|
||||
x: disposition.ex.versPx(indice + 0.5),
|
||||
y: v === null || v === undefined ? disposition.zone.y + disposition.zone.hauteur : disposition.ey.versPx(v),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* La disposition de petits multiples en points de relevé (§ 12.14, D49) :
|
||||
* des points aux seuls points de relevé, reliés par un trait mince, sur
|
||||
* l'abscisse logarithmique des mouvements évalués, graduée à ces points.
|
||||
* abscisses : les mouvements de chaque point, entiers ≥ 1 strictement
|
||||
* croissants, autant que de valeurs. Deux graduations qui se chevauchent
|
||||
* gardent la plus à droite : le dernier point, la proposition elle-même,
|
||||
* reste écrit. graduerX écrit une graduation de l'abscisse ; chacune,
|
||||
* {valeur, texte, x}, porte ses mouvements.
|
||||
* @returns {{n, zone, ex, ey, axes, graduationsY, graduationsX, titreX,
|
||||
* traces: Array<{genre, libelle, valeurs, d, etiquette, points: Array<{indice, mouvements, x, y: number|null}>}>,
|
||||
* abscissesPx: number[], legende}}
|
||||
*/
|
||||
export function dispositionPoints({
|
||||
largeur,
|
||||
hauteur,
|
||||
abscisses,
|
||||
series,
|
||||
mesurer,
|
||||
hauteurTexte,
|
||||
graduer,
|
||||
graduerX,
|
||||
titreX = null,
|
||||
aligner = null,
|
||||
}) {
|
||||
const { n, traces } = exigerTraces(series, []);
|
||||
exiger(Array.isArray(abscisses) && abscisses.length === n && n >= 1, `abscisses : ${n} mouvements attendus`);
|
||||
abscisses.forEach((m, i) => {
|
||||
exiger(Number.isInteger(m) && m >= 1, `abscisse ${i} : entier ≥ 1 attendu, reçu ${m}`);
|
||||
exiger(i === 0 || m > abscisses[i - 1], `abscisse ${i} : les mouvements croissent strictement`);
|
||||
});
|
||||
const cadre = cadreVertical({ largeur, hauteur, traces, mesurer, hauteurTexte, graduer, titreX, aligner });
|
||||
const { zone, ey } = cadre;
|
||||
const ex = echelleLog10({ domaine: [1, abscisses[n - 1]], plage: [zone.x, zone.x + zone.largeur] });
|
||||
const abscissesPx = abscisses.map((m) => ex.versPx(m));
|
||||
const tracees = traces.map((t, k) => ({
|
||||
...t,
|
||||
d: cheminPoints(abscisses.map((x, i) => ({ x, y: t.valeurs[i] })), ex, ey),
|
||||
etiquette: cadre.etiquettes[k],
|
||||
points: abscisses.map((mouvements, indice) => ({
|
||||
indice,
|
||||
mouvements,
|
||||
x: abscissesPx[indice],
|
||||
y: t.valeurs[indice] === null ? null : ey.versPx(t.valeurs[indice]),
|
||||
})),
|
||||
}));
|
||||
// Du dernier point au premier : une graduation se garde quand sa moitié
|
||||
// et celle de la dernière gardée, plus un espace, tiennent entre elles.
|
||||
const gardees = [];
|
||||
for (let i = n - 1; i >= 0; i -= 1) {
|
||||
const texte = graduerX(abscisses[i]);
|
||||
const x = abscissesPx[i];
|
||||
const suivante = gardees[0];
|
||||
if (suivante === undefined || suivante.x - x >= (mesurer(texte) + mesurer(suivante.texte)) / 2 + ESPACE_PX) {
|
||||
gardees.unshift({ valeur: abscisses[i], texte, x });
|
||||
}
|
||||
}
|
||||
return { ...cadre, n, ex, traces: tracees, abscissesPx, graduationsX: gardees, legende: legendeDe(tracees) };
|
||||
}
|
||||
|
||||
/** L'indice du point de relevé le plus proche de l'abscisse x, sur
|
||||
* l'abscisse commune des petits multiples (§ 12.7). */
|
||||
export function ciblePoint(x, disposition) {
|
||||
return pointLePlusProche(x, disposition.abscissesPx);
|
||||
}
|
||||
|
||||
/** Le point où s'ancre l'infobulle du point de relevé d'indice donné : le
|
||||
* point de la première série, au bas de la zone quand il n'a pas de
|
||||
* valeur. */
|
||||
export function ancrePoint(indice, disposition) {
|
||||
const { y } = disposition.traces[0].points[indice];
|
||||
return { x: disposition.abscissesPx[indice], y: y === null ? disposition.zone.y + disposition.zone.hauteur : y };
|
||||
}
|
||||
|
||||
/**
|
||||
* Le nom tel qu'il s'écrit dans maxPx (§ 12.6) : entier quand il tient ;
|
||||
* sinon le plus long préfixe, en points de code, suivi de la marque, qui
|
||||
* tient ; la marque seule quand aucun préfixe ne tient avec elle ; '' quand
|
||||
* elle ne tient pas. Un nom qui tient se mesure une fois ; un nom trop long,
|
||||
* une fois par préfixe. largeur est celle du texte rendu.
|
||||
* @param {string} nom
|
||||
* @param {number} maxPx
|
||||
* @param {(texte: string) => number} mesurer
|
||||
* @param {string} marque
|
||||
* @returns {{texte: string, largeur: number, tronque: boolean}}
|
||||
*/
|
||||
export function tronquerNom(nom, maxPx, mesurer, marque) {
|
||||
const entiere = mesurer(nom);
|
||||
if (entiere <= maxPx) return { texte: nom, largeur: entiere, tronque: false };
|
||||
const lettres = [...nom];
|
||||
const largeurs = lettres.map((_, k) => (k === lettres.length - 1 ? entiere : mesurer(lettres.slice(0, k + 1).join('') + marque)));
|
||||
const garde = tronquer(largeurs, maxPx);
|
||||
if (garde > 0) {
|
||||
const texte = lettres.slice(0, garde).join('') + marque;
|
||||
return { texte, largeur: largeurs[garde - 1], tronque: true };
|
||||
}
|
||||
const seule = mesurer(marque);
|
||||
return seule <= maxPx ? { texte: marque, largeur: seule, tronque: true } : { texte: '', largeur: 0, tronque: true };
|
||||
}
|
||||
|
||||
// Les segments ou les colonnes de barres : un ou deux, chacun un libellé et
|
||||
// un genre de remplissage connu, distinct de celui de l'autre — le genre
|
||||
// est la clé de son libellé dans Barres, et deux genres égaux auraient le
|
||||
// même remplissage.
|
||||
function exigerSegments(liste, quoi) {
|
||||
exiger(Array.isArray(liste) && liste.length >= 1 && liste.length <= 2, `${quoi} : un ou deux attendus`);
|
||||
for (const s of liste) {
|
||||
exiger(s !== null && typeof s === 'object' && estTexte(s.libelle), `${quoi} : libellé attendu`, TypeError);
|
||||
exiger(Object.hasOwn(REMPLISSAGES, s.genre), `${quoi} : genre inconnu ${s.genre}`);
|
||||
}
|
||||
exiger(liste.length === 1 || liste[0].genre !== liste[1].genre, `${quoi} : deux genres distincts attendus, reçu ${liste[0].genre} deux fois`);
|
||||
}
|
||||
|
||||
// L'identifiant d'une ligne de barres, clé de sa ligne dans Barres : un
|
||||
// texte ou un nombre, qu'aucune autre ligne ne porte.
|
||||
function exigerIdentifiants(lignes) {
|
||||
const vus = new Set();
|
||||
for (const [i, l] of lignes.entries()) {
|
||||
exiger(estTexte(l.id) || estFini(l.id), `ligne ${i + 1} : identifiant, un texte ou un nombre`, TypeError);
|
||||
exiger(!vus.has(l.id), `ligne ${i + 1} : identifiant ${l.id} déjà porté`);
|
||||
vus.add(l.id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* La disposition de barres horizontales (§ 12.4, § 12.12, § 12.13) : une
|
||||
* ligne par entrée de lignes, {id, nom, appartenance?, valeurs}, dans
|
||||
* l'ordre reçu, chacune intitulée de son nom tronqué à LARGEUR_NOM_PX —
|
||||
* précédé d'une pastille quand appartenance est donnée, un rang de teinte
|
||||
* ou null pour la neutre —, le nom entier gardé pour l'infobulle et le
|
||||
* jumeau. Deux formes, exclusives : segments, un ou deux, empilés depuis
|
||||
* zéro sur un axe commun, valeurs[k] celle du segment k, le libellé de
|
||||
* chaque segment posé sur la première ligne où il est non nul ; ou
|
||||
* colonnes, deux, juxtaposées, chacune son axe, valeurs[k] celle de la
|
||||
* colonne k. Un segment nul ne se trace pas. marque : la marque de
|
||||
* troncature.
|
||||
*
|
||||
* secondaire, facultatif, un texte déjà écrit ou null : les autres chiffres
|
||||
* de la ligne (§ 12.4), ou « — » d'une ligne sans barre (B15). Il se pose
|
||||
* au milieu de la ligne, après la fin de sa barre — de la seconde colonne
|
||||
* quand elles sont juxtaposées —, ou après le dernier libellé de segment
|
||||
* posé sur elle ; la marge droite réserve la largeur mesurée du plus long.
|
||||
* Lève TypeError sur un secondaire qui n'est pas un texte ou sur un
|
||||
* identifiant qui n'est ni un texte ni un nombre, RangeError sur deux
|
||||
* lignes du même identifiant ou deux segments du même genre.
|
||||
* @returns {{lignes: Array<{id, nom, appartenance, indice, y, hauteur, fin,
|
||||
* intitule: {texte, largeur, tronque, x, y}, pastille: {x, y, largeur, hauteur, rayon, point: {x, y}}|null,
|
||||
* secondaire: {texte, x, y}|null,
|
||||
* segments: Array<{genre, colonne, x, y, largeur, hauteur, marque, jeton}>}>,
|
||||
* ex, colonnes, axes, graduations, etiquettesSegments, legende, hauteurTotale, haut}}
|
||||
*/
|
||||
export function dispositionBarres({ largeur, lignes, segments = null, colonnes = null, mesurer, hauteurTexte, graduer, marque }) {
|
||||
exiger((segments === null) !== (colonnes === null), 'barres : segments ou colonnes, l’un des deux');
|
||||
const juxtaposees = colonnes !== null;
|
||||
const parts = juxtaposees ? colonnes : segments;
|
||||
exigerSegments(parts, juxtaposees ? 'colonnes' : 'segments');
|
||||
exiger(!juxtaposees || colonnes.length === 2, 'colonnes : deux attendues');
|
||||
exiger(Array.isArray(lignes), 'lignes : liste attendue', TypeError);
|
||||
exigerIdentifiants(lignes);
|
||||
for (const l of lignes) {
|
||||
exiger(Array.isArray(l.valeurs) && l.valeurs.length === parts.length, `ligne ${l.id} : ${parts.length} valeurs attendues`);
|
||||
exiger(estTexte(l.nom), `ligne ${l.id} : nom attendu`, TypeError);
|
||||
exiger(l.secondaire === undefined || l.secondaire === null || estTexte(l.secondaire), `ligne ${l.id} : secondaire, un texte`, TypeError);
|
||||
}
|
||||
|
||||
const hauteurLigne = hauteurTexte + ESPACE_PX;
|
||||
const intitules = lignes.map((l) => tronquerNom(l.nom, LARGEUR_NOM_PX, mesurer, marque));
|
||||
const avecPastille = lignes.some((l) => l.appartenance !== undefined);
|
||||
const retraitNom = avecPastille ? 2 * RETRAIT_PASTILLE_PX + 2 * POINT_PASTILLE_PX : 0;
|
||||
const largeurNoms = Math.max(0, ...intitules.map((i) => i.largeur)) + retraitNom + (avecPastille ? RETRAIT_PASTILLE_PX : 0);
|
||||
const x0 = largeurNoms + ESPACE_PX;
|
||||
// En tête : le titre de chaque colonne juxtaposée, puis les graduations.
|
||||
const haut = (juxtaposees ? 2 : 1) * (hauteurTexte + ESPACE_PX);
|
||||
// À droite : les libellés des segments, au pire tous sur une même ligne,
|
||||
// puis le plus long texte secondaire.
|
||||
const etiquettes = juxtaposees ? [] : parts.map((s) => s.libelle);
|
||||
const secondaires = lignes.filter((l) => estTexte(l.secondaire)).map((l) => l.secondaire);
|
||||
const droite =
|
||||
etiquettes.reduce((somme, e) => somme + mesurer(e) + ESPACE_PX, ESPACE_PX) +
|
||||
(secondaires.length === 0 ? 0 : plusLarge(secondaires, mesurer) + ESPACE_PX);
|
||||
const trace = Math.max(0, largeur - x0 - droite);
|
||||
|
||||
// Les axes : un seul, commun, pour des segments empilés ; un par colonne.
|
||||
const plages = juxtaposees ? colonnesJuxtaposees(trace, 2, ECART_COLONNES_PX) : [{ x: 0, largeur: trace }];
|
||||
const echelles = plages.map((plage, k) => {
|
||||
const valeurs = juxtaposees ? lignes.map((l) => l.valeurs[k]) : lignes.map((l) => l.valeurs.reduce((a, v) => a + v, 0));
|
||||
const domaine = domaineDe(valeurs);
|
||||
return echelleLineaire({ domaine, plage: [x0 + plage.x, x0 + plage.x + plage.largeur] });
|
||||
});
|
||||
const geometries = juxtaposees
|
||||
? echelles.map((ex, k) => barres(lignes.map((l) => [l.valeurs[k]]), ex, hauteurLigne, ECART_LIGNES_PX))
|
||||
: [barres(lignes.map((l) => l.valeurs), echelles[0], hauteurLigne, ECART_LIGNES_PX)];
|
||||
|
||||
const disposees = lignes.map((l, indice) => {
|
||||
const y = haut + geometries[0].lignes[indice].y;
|
||||
const traces = juxtaposees
|
||||
? geometries.map((g, k) => ({ ...g.lignes[indice].segments[0], genre: parts[k].genre, colonne: k }))
|
||||
: geometries[0].lignes[indice].segments.map((s, k) => ({ ...s, genre: parts[k].genre, colonne: k }));
|
||||
// Empilé, un segment qui suit un segment non nul s'en écarte.
|
||||
const tracees = traces
|
||||
.filter((s) => l.valeurs[s.colonne] > 0)
|
||||
.map((s) => {
|
||||
const ecart = !juxtaposees && s.colonne > 0 && l.valeurs[s.colonne - 1] > 0 ? ECART_SEGMENTS_PX : 0;
|
||||
return { ...s, x: s.x + ecart, largeur: Math.max(0, s.largeur - ecart), y, hauteur: hauteurLigne, ...REMPLISSAGES[s.genre] };
|
||||
});
|
||||
const intitule = intitules[indice];
|
||||
const centre = y + hauteurLigne / 2;
|
||||
const pastille =
|
||||
l.appartenance === undefined
|
||||
? null
|
||||
: {
|
||||
x: 0,
|
||||
y,
|
||||
largeur: intitule.largeur + retraitNom + RETRAIT_PASTILLE_PX,
|
||||
hauteur: hauteurLigne,
|
||||
rayon: hauteurLigne / 2,
|
||||
point: { x: RETRAIT_PASTILLE_PX + POINT_PASTILLE_PX, y: centre },
|
||||
};
|
||||
const derniere = traces.at(-1);
|
||||
return {
|
||||
id: l.id,
|
||||
nom: l.nom,
|
||||
appartenance: l.appartenance ?? null,
|
||||
indice,
|
||||
y,
|
||||
hauteur: hauteurLigne,
|
||||
fin: derniere.x + derniere.largeur,
|
||||
intitule: { ...intitule, x: retraitNom, y: centre },
|
||||
pastille,
|
||||
segments: tracees,
|
||||
secondaire: null,
|
||||
};
|
||||
});
|
||||
|
||||
// Le libellé d'un segment, à droite de la barre de la première ligne où
|
||||
// il est non nul ; deux libellés sur la même ligne, l'un après l'autre.
|
||||
const etiquettesSegments = [];
|
||||
if (!juxtaposees) {
|
||||
parts.forEach((s, k) => {
|
||||
const indice = lignes.findIndex((l) => l.valeurs[k] > 0);
|
||||
if (indice === -1) return;
|
||||
const ligne = disposees[indice];
|
||||
const voisine = etiquettesSegments.find((e) => e.indice === indice);
|
||||
const x = voisine === undefined ? ligne.fin + ESPACE_PX : voisine.x + mesurer(voisine.libelle) + ESPACE_PX;
|
||||
etiquettesSegments.push({ genre: s.genre, libelle: s.libelle, indice, x, y: ligne.y + ligne.hauteur / 2 });
|
||||
});
|
||||
}
|
||||
|
||||
// Le texte secondaire de chaque ligne, après sa barre et les libellés de
|
||||
// segment posés sur elle.
|
||||
for (const ligne of disposees) {
|
||||
const texte = lignes[ligne.indice].secondaire;
|
||||
if (!estTexte(texte)) continue;
|
||||
const derniere = etiquettesSegments.findLast((e) => e.indice === ligne.indice);
|
||||
const x = derniere === undefined ? ligne.fin + ESPACE_PX : derniere.x + mesurer(derniere.libelle) + ESPACE_PX;
|
||||
ligne.secondaire = { texte, x, y: ligne.y + ligne.hauteur / 2 };
|
||||
}
|
||||
|
||||
const corps = geometries[0].hauteurTotale;
|
||||
const colonnesDisposees = echelles.map((ex, k) => ({
|
||||
ex,
|
||||
x: ex.plage[0],
|
||||
largeur: ex.plage[1] - ex.plage[0],
|
||||
titre: juxtaposees ? { texte: parts[k].libelle, x: ex.plage[0], y: hauteurTexte / 2 } : null,
|
||||
graduations: graduations(0, ex.domaine[1], GRADUATIONS_VALEURS, { entieres: true }).map((valeur) => ({
|
||||
valeur,
|
||||
texte: graduer(valeur),
|
||||
x: ex.versPx(valeur),
|
||||
y: haut - ESPACE_PX - hauteurTexte / 2,
|
||||
})),
|
||||
axe: { x1: ex.plage[0] - ECART_AXE_PX, y1: haut - ESPACE_PX / 2, x2: ex.plage[0] - ECART_AXE_PX, y2: haut + corps },
|
||||
}));
|
||||
return {
|
||||
lignes: disposees,
|
||||
ex: echelles[0],
|
||||
colonnes: juxtaposees ? colonnesDisposees : [],
|
||||
axes: colonnesDisposees,
|
||||
etiquettesSegments,
|
||||
legende: juxtaposees ? [] : parts.map((s) => ({ genre: s.genre, libelle: s.libelle, signe: null })),
|
||||
haut,
|
||||
hauteurTotale: haut + corps,
|
||||
};
|
||||
}
|
||||
|
||||
/** L'indice de la ligne de barres sous l'ordonnée y, bornée aux lignes ;
|
||||
* null sans ligne (§ 12.7). */
|
||||
export function cibleLigne(y, disposition) {
|
||||
const n = disposition.lignes.length;
|
||||
if (n === 0) return null;
|
||||
const pas = disposition.lignes[0].hauteur + ECART_LIGNES_PX;
|
||||
const indice = Math.floor((y - disposition.haut + ECART_LIGNES_PX / 2) / pas);
|
||||
return Math.min(n - 1, Math.max(0, indice));
|
||||
}
|
||||
|
||||
/**
|
||||
* La disposition d'une grille (§ 12.5, § 12.11, D33, D35) : lignes ×
|
||||
* colonnes de cases carrées, chacune {libelle}, valeurs les comptes en ordre
|
||||
* de lignes, null pour une case sans compte. Chaque case porte le niveau de
|
||||
* son compte (niveau), le jeton de ce niveau et l'encre qui le sert
|
||||
* (NIVEAUX_GRAPHIQUE) ; marque, la désignation de la marque d'un niveau au
|
||||
* moins 1 — le niveau 0 est un fond, sans marque —, null sinon. Une case
|
||||
* sans compte n'a ni niveau ni jeton, et texte null : le composant écrit
|
||||
* « — ». Sous diagonale, une case de même ligne et colonne est vide et
|
||||
* neutre. Une case porte sa bordure, bord, un trait d'un pixel au bord de
|
||||
* la case, et son remplissage, fond, en retrait de RETRAIT_CASE_PX dans
|
||||
* elle. dessinable faux quand les cases ne tiennent pas dans la largeur
|
||||
* (grille, graphique.js) : la vue cède la place. Les intitulés de colonne
|
||||
* se tournent d'un quart de tour quand l'un d'eux dépasse la case. ecrire(v)
|
||||
* écrit un compte.
|
||||
*/
|
||||
export function dispositionGrille({ largeur, lignes, colonnes, valeurs, diagonale = false, mesurer, hauteurTexte, ecrire }) {
|
||||
exiger(Array.isArray(lignes) && Array.isArray(colonnes), 'grille : lignes et colonnes attendues', TypeError);
|
||||
const nl = lignes.length;
|
||||
const nc = colonnes.length;
|
||||
exiger(Array.isArray(valeurs) && valeurs.length === nl * nc, `grille : ${nl * nc} valeurs attendues`);
|
||||
const largeurIntitules = plusLarge(lignes.map((l) => l.libelle), mesurer) + ESPACE_PX;
|
||||
const g = grille(nl, nc, Math.max(0, largeur - largeurIntitules), hauteurTexte);
|
||||
const tournes = colonnes.some((c) => mesurer(c.libelle) > g.cote);
|
||||
const enTete = (tournes ? plusLarge(colonnes.map((c) => c.libelle), mesurer) : hauteurTexte) + ESPACE_PX;
|
||||
const x0 = largeurIntitules;
|
||||
const y0 = enTete;
|
||||
const cases = valeurs.map((v, index) => {
|
||||
const ligne = Math.floor(index / nc);
|
||||
const colonne = index % nc;
|
||||
const surDiagonale = diagonale && ligne === colonne;
|
||||
const n = surDiagonale || v === null ? null : niveau(v);
|
||||
const entree = n === null ? null : NIVEAUX_GRAPHIQUE[n];
|
||||
const x = x0 + colonne * g.cote;
|
||||
const y = y0 + ligne * g.cote;
|
||||
return {
|
||||
index,
|
||||
ligne,
|
||||
colonne,
|
||||
x,
|
||||
y,
|
||||
cote: g.cote,
|
||||
bord: { x: x + 0.5, y: y + 0.5, cote: Math.max(0, g.cote - 1) },
|
||||
fond: { x: x + RETRAIT_CASE_PX, y: y + RETRAIT_CASE_PX, cote: Math.max(0, g.cote - 2 * RETRAIT_CASE_PX) },
|
||||
diagonale: surDiagonale,
|
||||
niveau: n,
|
||||
jeton: entree?.jeton ?? null,
|
||||
encre: entree?.encre ?? null,
|
||||
marque: n !== null && n > 0 ? `graphiqueNiveau${n}` : null,
|
||||
texte: surDiagonale ? '' : v === null ? null : ecrire(v),
|
||||
};
|
||||
});
|
||||
return {
|
||||
dessinable: g.dessinable,
|
||||
cote: g.cote,
|
||||
x0,
|
||||
y0,
|
||||
largeur: x0 + g.largeur,
|
||||
hauteur: y0 + g.hauteur,
|
||||
nl,
|
||||
nc,
|
||||
intitulesLignes: lignes.map((l, i) => ({ texte: l.libelle, x: x0 - ESPACE_PX, y: y0 + (i + 0.5) * g.cote })),
|
||||
intitulesColonnes: colonnes.map((c, j) => ({ texte: c.libelle, x: x0 + (j + 0.5) * g.cote, y: y0 - ESPACE_PX, tourne: tournes })),
|
||||
cases,
|
||||
};
|
||||
}
|
||||
|
||||
/** L'indice de la case sous le point (x, y) ; null hors de la grille et sur
|
||||
* la diagonale, qui ne porte rien (§ 12.7). */
|
||||
export function cibleCase(x, y, disposition) {
|
||||
const { x0, y0, cote, nl, nc } = disposition;
|
||||
if (cote === 0) return null;
|
||||
const colonne = Math.floor((x - x0) / cote);
|
||||
const ligne = Math.floor((y - y0) / cote);
|
||||
if (colonne < 0 || colonne >= nc || ligne < 0 || ligne >= nl) return null;
|
||||
const index = ligne * nc + colonne;
|
||||
return disposition.cases[index].diagonale ? null : index;
|
||||
}
|
||||
|
||||
/**
|
||||
* Les colonnes d'un jumeau ou d'une infobulle : une liste non vide de
|
||||
* {cle, libelle, genre, decimales?}, libelle déjà traduit, genre de
|
||||
* GENRES_COLONNE, chaque clé une fois ; decimales, sur une colonne de
|
||||
* nombres seule, un entier de 0 à 20, 0 par défaut. Rend la liste reçue.
|
||||
* Lève TypeError sur une colonne sans clé ni libellé, RangeError sur une
|
||||
* liste vide, un genre inconnu, une clé répétée ou des décimales hors
|
||||
* contrat.
|
||||
*/
|
||||
export function exigerColonnes(colonnes) {
|
||||
exiger(Array.isArray(colonnes) && colonnes.length > 0, 'colonnes : liste non vide attendue');
|
||||
const vues = new Set();
|
||||
for (const c of colonnes) {
|
||||
exiger(c !== null && typeof c === 'object' && estTexte(c.cle) && estTexte(c.libelle), 'colonne : clé et libellé attendus', TypeError);
|
||||
exiger(GENRES_COLONNE.includes(c.genre), `colonne ${c.cle} : genre inconnu ${c.genre}`);
|
||||
exiger(
|
||||
c.decimales === undefined || (c.genre === 'nombre' && Number.isInteger(c.decimales) && c.decimales >= 0 && c.decimales <= 20),
|
||||
`colonne ${c.cle} : décimales, un entier de 0 à 20 sur une colonne de nombres`,
|
||||
);
|
||||
exiger(!vues.has(c.cle), `colonne ${c.cle} : clé répétée`);
|
||||
vues.add(c.cle);
|
||||
}
|
||||
return colonnes;
|
||||
}
|
||||
|
||||
/** Le compte du pire, ou d'une ligne, qui suit un pourcentage (D34) : une
|
||||
* chaîne non vide, rendue telle quelle ; TypeError sinon. */
|
||||
export function exigerCompte(compte) {
|
||||
exiger(estTexte(compte) && compte !== '', 'pourcentage : le compte qui le suit est attendu', TypeError);
|
||||
return compte;
|
||||
}
|
||||
|
||||
/**
|
||||
* La cellule d'une ligne sous une colonne : {genre, texte, compte}, texte
|
||||
* null quand la valeur manque. Texte : la valeur, un texte. Nombre : un
|
||||
* nombre fini, écrit par ecrire(valeur, {decimales}) aux décimales de la
|
||||
* colonne — l'écriture des nombres de la page, format.nombre, celle des
|
||||
* axes —, ou un texte déjà écrit, rendu tel quel ; compte null. Une cellule
|
||||
* de pourcentage reçoit {texte, compte} de la vue — le pourcentage déjà
|
||||
* écrit, ou null pour « — », et le compte de la ligne, qu'elle exige. Lève
|
||||
* TypeError sur une valeur d'une autre forme, ou sans ecrire.
|
||||
* @param {{cle: string, genre: string, decimales?: number}} colonne
|
||||
* @param {Object} ligne
|
||||
* @param {(valeur: number, options: {decimales: number}) => string} ecrire
|
||||
*/
|
||||
export function celluleDe(colonne, ligne, ecrire) {
|
||||
exiger(typeof ecrire === 'function', 'cellule : l’écriture des nombres est attendue', TypeError);
|
||||
const valeur = ligne[colonne.cle];
|
||||
if (colonne.genre === 'pourcentage') {
|
||||
exiger(valeur !== null && typeof valeur === 'object', `${colonne.cle} : {texte, compte} attendu`, TypeError);
|
||||
exiger(valeur.texte === null || estTexte(valeur.texte), `${colonne.cle} : texte attendu`, TypeError);
|
||||
return { genre: 'pourcentage', texte: valeur.texte, compte: exigerCompte(valeur.compte) };
|
||||
}
|
||||
if (valeur === null || valeur === undefined) return { genre: colonne.genre, texte: null, compte: null };
|
||||
if (colonne.genre === 'nombre' && estFini(valeur)) {
|
||||
return { genre: 'nombre', texte: ecrire(valeur, { decimales: colonne.decimales ?? 0 }), compte: null };
|
||||
}
|
||||
exiger(estTexte(valeur), `${colonne.cle} : ${colonne.genre === 'nombre' ? 'nombre fini ou texte' : 'texte'} attendu`, TypeError);
|
||||
return { genre: colonne.genre, texte: valeur, compte: null };
|
||||
}
|
||||
|
||||
/** La ligne d'une infobulle : chaque cellule de la ligne, dans l'ordre des
|
||||
* colonnes, sous leur clé et leur libellé (§ 12.7 : l'infobulle porte la
|
||||
* ligne du jumeau) ; ecrire, celle de celluleDe. */
|
||||
export function ligneInfobulle(colonnes, ligne, ecrire) {
|
||||
return exigerColonnes(colonnes).map((c) => ({ cle: c.cle, libelle: c.libelle, ...celluleDe(c, ligne, ecrire) }));
|
||||
}
|
||||
575
src/interface/qualite/graphiques/pointage.test.js
Normal file
|
|
@ -0,0 +1,575 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Le modèle pur des graphiques de la page de qualité (§ 12.3, § 12.4,
|
||||
// § 12.5, § 12.7) : les traits et leurs motifs, la disposition d'un
|
||||
// escalier, de points de relevé, de barres et d'une grille, le pointage au
|
||||
// plus proche de chaque forme, la ligne de l'infobulle et les cellules du
|
||||
// jumeau, la troncature d'un nom. La mesure du texte est injectée : ici, une
|
||||
// largeur fixe par caractère. Les noms d'épreuve sont inventés.
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, test } from '../../../../test/lanceur.js';
|
||||
import { cheminEscalier, cheminPoints } from '../../../geometrie/graphique.js';
|
||||
import { constante } from '../../../geometrie/constantes.js';
|
||||
import { MARQUES_GRAPHIQUE, NIVEAUX_GRAPHIQUE } from '../../marques.js';
|
||||
import {
|
||||
ECART_AXE_PX,
|
||||
ECART_SEGMENTS_PX,
|
||||
ESPACE_PX,
|
||||
LARGEUR_NOM_PX,
|
||||
RETRAIT_CASE_PX,
|
||||
REMPLISSAGES,
|
||||
SIGNES,
|
||||
TRAITS,
|
||||
ancrePoint,
|
||||
ancreRang,
|
||||
celluleDe,
|
||||
ciblePoint,
|
||||
cibleCase,
|
||||
cibleLigne,
|
||||
cibleRang,
|
||||
dispositionBarres,
|
||||
dispositionEscalier,
|
||||
dispositionGrille,
|
||||
dispositionPoints,
|
||||
espacerEtiquettes,
|
||||
exigerColonnes,
|
||||
exigerCompte,
|
||||
exigerTraces,
|
||||
ligneInfobulle,
|
||||
margesCommunes,
|
||||
margesVerticales,
|
||||
tronquerNom,
|
||||
} from './pointage.js';
|
||||
|
||||
// Sept pixels par caractère, comptés en points de code ; douze de haut.
|
||||
const mesurer = (texte) => 7 * [...texte].length;
|
||||
const HAUTEUR_TEXTE = 12;
|
||||
const graduer = (v) => String(v);
|
||||
const MARQUE = '\u{2026}';
|
||||
|
||||
const escalier = (options) =>
|
||||
dispositionEscalier({ largeur: 1000, hauteur: 240, mesurer, hauteurTexte: HAUTEUR_TEXTE, graduer, ...options });
|
||||
|
||||
const serie = (valeurs, libelle = 'rencontres') => ({ libelle, valeurs });
|
||||
|
||||
describe('pointage : les traits (§ 12.7, D30)', () => {
|
||||
test('quatre motifs deux à deux distincts : plein, tirets, mixte, pointillé ; la règle à 100 % prend celui du plafond', () => {
|
||||
const motifs = ['serie1', 'serie2', 'plafond', 'borne'].map((genre) => TRAITS[genre].motif);
|
||||
assert.equal(new Set(motifs).size, 4, motifs.join(' | '));
|
||||
assert.equal(TRAITS.serie1.motif, 'none');
|
||||
assert.equal(TRAITS.cent.motif, TRAITS.plafond.motif);
|
||||
assert.equal(TRAITS.cent.marque, TRAITS.plafond.marque);
|
||||
});
|
||||
|
||||
test('rouge : le plafond et la règle à 100 % en tiret-point, la borne en pointillés plus courts que les tirets de la seconde série', () => {
|
||||
const motif = (genre) => TRAITS[genre].motif.split(/[\s,]+/).map(Number);
|
||||
for (const genre of ['plafond', 'cent']) {
|
||||
const [long, vide1, court, vide2] = motif(genre);
|
||||
assert.equal(motif(genre).length, 4, `${genre} : tiret, vide, point, vide`);
|
||||
assert.ok(long > court, `${genre} : un tiret long puis un court`);
|
||||
assert.ok(court <= vide1 && court <= vide2, `${genre} : le point n’est pas plus long que ses vides`);
|
||||
}
|
||||
const [point, vide] = motif('borne');
|
||||
assert.equal(motif('borne').length, 2, 'borne : point, vide');
|
||||
assert.ok(point <= vide, 'borne : le point n’est pas plus long que son vide');
|
||||
assert.ok(point < motif('serie2')[0], 'borne : plus court que le tiret de la seconde série');
|
||||
});
|
||||
|
||||
test('chaque trait et chaque remplissage nomme une marque de graphique et en peint le jeton', () => {
|
||||
const registre = new Map(MARQUES_GRAPHIQUE.map(({ nom, jeton }) => [nom, jeton]));
|
||||
for (const [genre, { marque, jeton }] of [...Object.entries(TRAITS), ...Object.entries(REMPLISSAGES)]) {
|
||||
assert.equal(registre.get(marque), jeton, genre);
|
||||
}
|
||||
assert.deepEqual(Object.keys(REMPLISSAGES), ['serie1', 'serie2', 'prouve', 'reste']);
|
||||
});
|
||||
|
||||
test('les signes des rangs marqués : ancré, partiel, en réserve, plafond nul', () => {
|
||||
assert.deepEqual(SIGNES, ['ancre', 'partiel', 'reserve', 'plafondNul']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pointage : exigerTraces', () => {
|
||||
test('une ou deux séries, plein puis tirets ; zéro à deux repères', () => {
|
||||
const { n, traces } = exigerTraces([serie([1, 2]), serie([0, 1], 'appartenances')], [{ genre: 'borne', libelle: 'b', valeurs: [1, 1] }]);
|
||||
assert.equal(n, 2);
|
||||
assert.deepEqual(traces.map((t) => t.genre), ['serie1', 'serie2', 'borne']);
|
||||
assert.throws(() => exigerTraces([], []), RangeError);
|
||||
assert.throws(() => exigerTraces([serie([1]), serie([1]), serie([1])], []), RangeError);
|
||||
const reperes = ['plafond', 'borne', 'borne'].map((genre) => ({ genre, libelle: genre, valeurs: [1] }));
|
||||
assert.throws(() => exigerTraces([serie([1])], reperes), RangeError);
|
||||
});
|
||||
|
||||
test('rouge : deux repères du même motif — un plafond et la règle à 100 % — sont refusés', () => {
|
||||
const reperes = ['plafond', 'cent'].map((genre) => ({ genre, libelle: genre, valeurs: [1] }));
|
||||
assert.throws(() => exigerTraces([serie([1])], reperes), /motif/);
|
||||
});
|
||||
|
||||
test('un genre de repère inconnu, une série de longueur différente, un libellé absent sont refusés', () => {
|
||||
assert.throws(() => exigerTraces([serie([1])], [{ genre: 'serie2', libelle: 'x', valeurs: [1] }]), RangeError);
|
||||
assert.throws(() => exigerTraces([serie([1, 2]), serie([1])], []), RangeError);
|
||||
assert.throws(() => exigerTraces([{ valeurs: [1] }], []), TypeError);
|
||||
assert.throws(() => exigerTraces([{ libelle: 'x', valeurs: 'abc' }], []), TypeError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pointage : la disposition d’un escalier (§ 12.3)', () => {
|
||||
test('un seul axe vertical, partant de zéro ; les graduations entières sous la plus grande valeur', () => {
|
||||
const d = escalier({ series: [serie([3, 5, 8])], reperes: [{ genre: 'plafond', libelle: 'plafond réalisé', valeurs: [9, 9, 9] }] });
|
||||
assert.equal(d.axes.length, 2);
|
||||
const verticaux = d.axes.filter((a) => a.x1 === a.x2);
|
||||
assert.equal(verticaux.length, 1);
|
||||
assert.equal(verticaux[0].x1, d.zone.x - ECART_AXE_PX, 'l’axe ne touche pas un tracé posé au bord de la zone');
|
||||
assert.equal(d.graduationsY[0].texte, '0');
|
||||
assert.ok(d.graduationsY.every(({ valeur }) => Number.isInteger(valeur) && valeur <= 9));
|
||||
assert.equal(d.ey.domaine[1], 9);
|
||||
});
|
||||
|
||||
test('chaque trace suit la géométrie : l’escalier de la série, la règle d’un repère constant', () => {
|
||||
const d = escalier({ series: [serie([3, null, 8])], reperes: [{ genre: 'plafond', libelle: 'plafond réalisé', valeurs: [9, 9, 9] }] });
|
||||
assert.equal(d.traces[0].d, cheminEscalier([3, null, 8], d.ex, d.ey));
|
||||
assert.equal(d.traces[0].d.match(/M/g).length, 2, 'le chemin reprend après le rang null, sans le franchir');
|
||||
assert.match(d.traces[1].d, /^M [\d.]+ [\d.]+ H [\d.]+$/);
|
||||
});
|
||||
|
||||
test('rouge : un chemin qui relierait deux rangs par-dessus un rang null', () => {
|
||||
const d = escalier({ series: [serie([4, null, 4])] });
|
||||
const morceaux = d.traces[0].d.split(/(?=[MHV])/).map((m) => m.trim());
|
||||
// Le rang 2 occupe [ex(1), ex(2)] : aucun trait n'y passe.
|
||||
const debut = d.ex.versPx(1);
|
||||
const fin = d.ex.versPx(2);
|
||||
let x = null;
|
||||
for (const morceau of morceaux) {
|
||||
const [commande, ...nombres] = morceau.split(' ');
|
||||
if (commande === 'M') x = Number(nombres[0]);
|
||||
if (commande === 'H') {
|
||||
const vers = Number(nombres[0]);
|
||||
// Les nombres du chemin sont arrondis au centième de pixel.
|
||||
assert.ok(!(fin - x > 0.01 && vers - debut > 0.01), `trait de ${x} à ${vers} sur le rang null`);
|
||||
x = vers;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('les libellés se posent au bout de leur tracé, sans se chevaucher', () => {
|
||||
const d = escalier({
|
||||
series: [serie([3, 5, 8]), serie([2, 4, 7], 'appartenances')],
|
||||
reperes: [
|
||||
{ genre: 'plafond', libelle: 'plafond réalisé', valeurs: [8, 8, 8] },
|
||||
{ genre: 'borne', libelle: 'borne de diversité', valeurs: [7, 7, 7] },
|
||||
],
|
||||
});
|
||||
const fin = d.zone.x + d.zone.largeur;
|
||||
for (const trace of d.traces) assert.ok(trace.etiquette.x > fin, trace.libelle);
|
||||
const ys = d.traces.map((t) => t.etiquette.y).sort((a, b) => a - b);
|
||||
for (let i = 1; i < ys.length; i += 1) assert.ok(ys[i] - ys[i - 1] >= HAUTEUR_TEXTE, ys.join(' '));
|
||||
// La place des libellés est mesurée : la marge droite tient le plus long.
|
||||
assert.ok(1000 - fin >= mesurer('borne de diversité'));
|
||||
});
|
||||
|
||||
test('un signe de forme aux seuls rangs marqués, à la valeur de la première série', () => {
|
||||
const d = escalier({ series: [serie([3, 5, 8, 9])], marques: [null, 'ancre', null, 'plafondNul'] });
|
||||
assert.deepEqual(d.signes.map((s) => [s.indice, s.genre]), [[1, 'ancre'], [3, 'plafondNul']]);
|
||||
assert.equal(d.signes[0].x, d.ex.versPx(1.5));
|
||||
assert.equal(d.signes[0].y, d.ey.versPx(5));
|
||||
assert.equal(d.signes[0].plein, true);
|
||||
assert.equal(d.signes[1].plein, false);
|
||||
assert.throws(() => escalier({ series: [serie([3])], marques: ['rouge'] }), RangeError);
|
||||
assert.throws(() => escalier({ series: [serie([3])], marques: [null, null] }), RangeError);
|
||||
});
|
||||
|
||||
test('la légende nomme les séries, les repères et les signes présents, dans cet ordre', () => {
|
||||
const d = escalier({
|
||||
series: [serie([3, 5])],
|
||||
reperes: [{ genre: 'borne', libelle: 'borne de diversité', valeurs: [4, 4] }],
|
||||
marques: ['reserve', null],
|
||||
});
|
||||
assert.deepEqual(d.legende, [
|
||||
{ genre: 'serie1', libelle: 'rencontres', signe: null },
|
||||
{ genre: 'borne', libelle: 'borne de diversité', signe: null },
|
||||
{ genre: 'signe', libelle: null, signe: 'reserve' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('pointage au plus proche à 260 rangs : le centre de chaque rang le désigne, hors du tracé le rang du bord', () => {
|
||||
const valeurs = Array.from({ length: 260 }, (_, i) => i % 27);
|
||||
const d = escalier({ series: [serie(valeurs)] });
|
||||
for (let r = 1; r <= 260; r += 1) assert.equal(cibleRang(d.ex.versPx(r - 0.5), d), r - 1, `rang ${r}`);
|
||||
assert.equal(cibleRang(d.zone.x - 50, d), 0);
|
||||
assert.equal(cibleRang(5000, d), 259);
|
||||
assert.deepEqual(ancreRang(3, d), { x: d.ex.versPx(3.5), y: d.ey.versPx(3) });
|
||||
});
|
||||
|
||||
test('ancreRang d’un rang null se pose au bas du tracé', () => {
|
||||
const d = escalier({ series: [serie([null, 2])] });
|
||||
assert.equal(ancreRang(0, d).y, d.zone.y + d.zone.hauteur);
|
||||
});
|
||||
|
||||
test('deux graphiques empilés alignent leur zone sur les marges communes, que chacun demande pour son seul texte', () => {
|
||||
const comptes = { series: [serie([3, 25])], reperes: [{ genre: 'plafond', libelle: 'plafond réalisé', valeurs: [27, 27] }] };
|
||||
const parts = { series: [serie([11, 92], 'part')], reperes: [{ genre: 'cent', libelle: '100', valeurs: [100, 100] }], graduer: (v) => `${v} pc` };
|
||||
const marges = [
|
||||
margesVerticales({ ...comptes, mesurer, graduer }),
|
||||
margesVerticales({ ...parts, mesurer }),
|
||||
];
|
||||
assert.notDeepEqual(marges[0], marges[1]);
|
||||
const aligner = margesCommunes(marges);
|
||||
assert.deepEqual(aligner, { gauche: Math.max(marges[0].gauche, marges[1].gauche), droite: Math.max(marges[0].droite, marges[1].droite) });
|
||||
const haut = escalier({ ...comptes, aligner });
|
||||
const bas = escalier({ ...parts, aligner });
|
||||
assert.deepEqual([haut.zone.x, haut.zone.largeur], [bas.zone.x, bas.zone.largeur]);
|
||||
assert.equal(haut.ex.versPx(1.5), bas.ex.versPx(1.5));
|
||||
assert.notEqual(escalier(comptes).zone.x, escalier(parts).zone.x, 'sans alignement, chacun sa marge');
|
||||
assert.deepEqual(margesCommunes([null, undefined]), { gauche: 0, droite: 0 });
|
||||
});
|
||||
|
||||
test('une largeur plus petite que les marges laisse une zone nulle, sans lever', () => {
|
||||
const d = escalier({ largeur: 10, series: [serie([1, 2])] });
|
||||
assert.equal(d.zone.largeur, 0);
|
||||
});
|
||||
|
||||
test('rouge : à zone nulle, chaque graduation de l’abscisse garde sa valeur propre, son rang', () => {
|
||||
const d = escalier({ largeur: 10, series: [serie(Array.from({ length: 12 }, (_, i) => i))] });
|
||||
const valeurs = d.graduationsX.map((g) => g.valeur);
|
||||
assert.ok(valeurs.length > 1);
|
||||
assert.equal(new Set(d.graduationsX.map((g) => g.x)).size, 1, 'toutes au même pixel');
|
||||
assert.equal(new Set(valeurs).size, valeurs.length, valeurs.join(' | '));
|
||||
for (const g of d.graduationsX) assert.equal(g.texte, graduer(g.valeur));
|
||||
});
|
||||
});
|
||||
|
||||
describe('pointage : les points de relevé (§ 12.14, D49)', () => {
|
||||
const points = (options) =>
|
||||
dispositionPoints({
|
||||
largeur: 600,
|
||||
hauteur: 120,
|
||||
mesurer,
|
||||
hauteurTexte: HAUTEUR_TEXTE,
|
||||
graduer,
|
||||
graduerX: graduer,
|
||||
abscisses: [1, 10, 100, 1000, 10000, 20000],
|
||||
series: [serie([5, 4, null, 2, 1, 1], 'collisions')],
|
||||
...options,
|
||||
});
|
||||
|
||||
test('des points aux seuls points de relevé, reliés par cheminPoints, sur l’échelle logarithmique', () => {
|
||||
const d = points();
|
||||
const trace = d.traces[0];
|
||||
assert.equal(trace.d, cheminPoints([1, 10, 100, 1000, 10000, 20000].map((x, i) => ({ x, y: [5, 4, null, 2, 1, 1][i] })), d.ex, d.ey));
|
||||
assert.deepEqual(trace.points.map((p) => p.y === null), [false, false, true, false, false, false]);
|
||||
assert.equal(trace.points[1].x, d.ex.versPx(10));
|
||||
assert.equal(d.axes.filter((a) => a.x1 === a.x2).length, 1);
|
||||
});
|
||||
|
||||
test('l’abscisse se gradue aux points de relevé ; deux graduations qui se chevauchent gardent la plus à droite', () => {
|
||||
const d = points();
|
||||
const textes = d.graduationsX.map((g) => g.texte);
|
||||
assert.equal(textes.at(-1), '20000');
|
||||
assert.ok(!textes.includes('10000'), textes.join(' '));
|
||||
assert.ok(textes.includes('1'));
|
||||
for (let i = 1; i < d.graduationsX.length; i += 1) {
|
||||
assert.ok(d.graduationsX[i].x - d.graduationsX[i - 1].x >= mesurer(d.graduationsX[i].texte));
|
||||
}
|
||||
});
|
||||
|
||||
test('chaque graduation de l’abscisse porte ses mouvements, sa valeur propre', () => {
|
||||
const d = points();
|
||||
for (const g of d.graduationsX) assert.equal(g.texte, graduer(g.valeur));
|
||||
assert.equal(d.graduationsX.at(-1).valeur, 20000);
|
||||
});
|
||||
|
||||
test('ciblePoint désigne le point de relevé le plus proche sur l’abscisse commune', () => {
|
||||
const d = points();
|
||||
assert.equal(ciblePoint(d.ex.versPx(10) + 3, d), 1);
|
||||
assert.equal(ciblePoint(0, d), 0);
|
||||
assert.equal(ciblePoint(10_000, d), 5);
|
||||
assert.deepEqual(ancrePoint(1, d), { x: d.ex.versPx(10), y: d.ey.versPx(4) });
|
||||
assert.deepEqual(ancrePoint(2, d), { x: d.ex.versPx(100), y: d.zone.y + d.zone.hauteur });
|
||||
});
|
||||
|
||||
test('des abscisses qui ne croissent pas, ou sous 1, sont refusées', () => {
|
||||
assert.throws(() => points({ abscisses: [1, 1, 100, 1000, 10000, 20000] }), RangeError);
|
||||
assert.throws(() => points({ abscisses: [0, 10, 100, 1000, 10000, 20000] }), RangeError);
|
||||
assert.throws(() => points({ abscisses: [1, 10] }), RangeError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pointage : les barres (§ 12.4)', () => {
|
||||
const NOM_LONG = 'Maëlle-Hortense Quenouille-Vasque de Belle-Rive';
|
||||
const lignes = [
|
||||
{ id: 'a', nom: 'Coopérative Aubier', appartenance: 1, valeurs: [0, 3] },
|
||||
{ id: 'b', nom: NOM_LONG, appartenance: null, valeurs: [2, 0] },
|
||||
{ id: 'c', nom: 'Atelier Ressac', appartenance: 2, valeurs: [1, 1] },
|
||||
];
|
||||
const segments = [
|
||||
{ genre: 'prouve', libelle: 'prouvé inévitable' },
|
||||
{ genre: 'reste', libelle: 'reste' },
|
||||
];
|
||||
const disposer = (options) =>
|
||||
dispositionBarres({ largeur: 900, mesurer, hauteurTexte: HAUTEUR_TEXTE, graduer, marque: MARQUE, lignes, segments, ...options });
|
||||
|
||||
test('le nom long se tronque sous la largeur du registre ; le nom entier reste à la ligne', () => {
|
||||
assert.equal(LARGEUR_NOM_PX, constante('LARGEUR_TRONCATURE_NOM_PX'));
|
||||
const d = disposer();
|
||||
const ligne = d.lignes[1];
|
||||
assert.equal(ligne.nom, NOM_LONG);
|
||||
assert.ok(ligne.intitule.texte.endsWith(MARQUE));
|
||||
assert.ok(mesurer(ligne.intitule.texte) <= LARGEUR_NOM_PX);
|
||||
assert.equal(d.lignes[0].intitule.texte, 'Coopérative Aubier');
|
||||
});
|
||||
|
||||
test('les segments empilés partent de zéro, sur un axe commun ; un segment nul ne se trace pas', () => {
|
||||
const d = disposer();
|
||||
assert.deepEqual(d.lignes.map((l) => l.segments.map((s) => s.genre)), [['reste'], ['prouve'], ['prouve', 'reste']]);
|
||||
const zero = d.ex.versPx(0);
|
||||
assert.equal(d.lignes[1].segments[0].x, zero);
|
||||
assert.equal(d.lignes[0].segments[0].x, zero, 'le reste d’une ligne sans prouvé part de zéro');
|
||||
assert.equal(d.lignes[2].segments[1].x, d.ex.versPx(1) + ECART_SEGMENTS_PX, 'le second segment s’écarte du premier');
|
||||
assert.equal(d.lignes[0].segments[0].largeur, d.ex.versPx(3) - d.ex.versPx(0), 'sans premier segment, aucun écart');
|
||||
assert.equal(d.ex.domaine[1], 3);
|
||||
});
|
||||
|
||||
test('rouge : le libellé de chaque segment se pose sur la première barre où il est non nul', () => {
|
||||
const d = disposer();
|
||||
assert.deepEqual(d.etiquettesSegments.map((e) => [e.genre, e.indice]), [['prouve', 1], ['reste', 0]]);
|
||||
for (const e of d.etiquettesSegments) {
|
||||
const ligne = d.lignes[e.indice];
|
||||
assert.equal(e.y, ligne.y + ligne.hauteur / 2);
|
||||
assert.ok(e.x > ligne.fin);
|
||||
}
|
||||
});
|
||||
|
||||
test('un segment nul partout n’a pas de libellé sur le tracé ; la légende le garde', () => {
|
||||
const d = disposer({ lignes: lignes.map((l) => ({ ...l, valeurs: [0, l.valeurs[1]] })) });
|
||||
assert.deepEqual(d.etiquettesSegments.map((e) => e.genre), ['reste']);
|
||||
assert.deepEqual(d.legende.map((e) => e.genre), ['prouve', 'reste']);
|
||||
});
|
||||
|
||||
test('deux colonnes juxtaposées, chacune son axe, les lignes dans le même ordre', () => {
|
||||
const d = dispositionBarres({
|
||||
largeur: 900,
|
||||
mesurer,
|
||||
hauteurTexte: HAUTEUR_TEXTE,
|
||||
graduer,
|
||||
marque: MARQUE,
|
||||
lignes: [
|
||||
{ id: 'f1', nom: 'Iris Ombrelle', valeurs: [2, 9] },
|
||||
{ id: 'f2', nom: 'Théo Pervenche', valeurs: [4, 1] },
|
||||
],
|
||||
colonnes: [
|
||||
{ genre: 'serie1', libelle: 'appartenances' },
|
||||
{ genre: 'serie2', libelle: 'étiquettes' },
|
||||
],
|
||||
});
|
||||
assert.equal(d.colonnes.length, 2);
|
||||
assert.deepEqual(d.colonnes.map((c) => c.ex.domaine[1]), [4, 9]);
|
||||
assert.ok(d.colonnes[0].x + d.colonnes[0].largeur < d.colonnes[1].x);
|
||||
assert.deepEqual(d.lignes.map((l) => l.id), ['f1', 'f2']);
|
||||
assert.equal(d.lignes[1].segments[1].x, d.colonnes[1].ex.versPx(0));
|
||||
assert.deepEqual(d.etiquettesSegments, []);
|
||||
});
|
||||
|
||||
test('rouge : chaque ligne porte son texte secondaire après sa barre et ses libellés, dans la marge droite réservée', () => {
|
||||
const avec = [
|
||||
{ ...lignes[0], secondaire: '7 personnes, 3 paires, excédent 2' },
|
||||
{ ...lignes[1], secondaire: '4 personnes, 2 paires, excédent 0' },
|
||||
{ id: 'vide', nom: 'Ondine Lacasse', valeurs: [0, 0], secondaire: '\u{2014}' },
|
||||
{ ...lignes[2] },
|
||||
];
|
||||
for (const largeur of [900, 700]) {
|
||||
const d = disposer({ largeur, lignes: avec });
|
||||
const [a, b, vide, c] = d.lignes;
|
||||
assert.equal(c.secondaire, null, 'sans texte secondaire, rien');
|
||||
for (const ligne of [a, b, vide]) {
|
||||
assert.equal(ligne.secondaire.y, ligne.y + ligne.hauteur / 2);
|
||||
assert.ok(ligne.secondaire.x >= ligne.fin + ESPACE_PX - 1e-9, ligne.id);
|
||||
assert.ok(ligne.secondaire.x + mesurer(ligne.secondaire.texte) <= largeur + 1e-9, `${ligne.id} tient dans ${largeur}`);
|
||||
for (const e of d.etiquettesSegments.filter((x) => x.indice === ligne.indice)) {
|
||||
assert.ok(ligne.secondaire.x >= e.x + mesurer(e.libelle) + ESPACE_PX - 1e-9, `${ligne.id} : après le libellé ${e.genre}`);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(vide.segments, [], 'une ligne sans valeur n’a aucune barre');
|
||||
assert.equal(vide.secondaire.texte, '\u{2014}');
|
||||
assert.equal(vide.secondaire.x, d.ex.versPx(0) + ESPACE_PX);
|
||||
}
|
||||
});
|
||||
|
||||
test('le texte secondaire des colonnes juxtaposées suit la barre de la seconde ; un secondaire qui n’est pas un texte est refusé', () => {
|
||||
const d = dispositionBarres({
|
||||
largeur: 600,
|
||||
mesurer,
|
||||
hauteurTexte: HAUTEUR_TEXTE,
|
||||
graduer,
|
||||
marque: MARQUE,
|
||||
lignes: [{ id: 'f1', nom: 'Iris Ombrelle', valeurs: [2, 9], secondaire: '12 tours animés' }],
|
||||
colonnes: [
|
||||
{ genre: 'serie1', libelle: 'appartenances' },
|
||||
{ genre: 'serie2', libelle: 'étiquettes' },
|
||||
],
|
||||
});
|
||||
const [ligne] = d.lignes;
|
||||
assert.equal(ligne.secondaire.x, ligne.segments[1].x + ligne.segments[1].largeur + ESPACE_PX);
|
||||
assert.ok(ligne.secondaire.x + mesurer('12 tours animés') <= 600 + 1e-9);
|
||||
assert.throws(() => disposer({ lignes: [{ ...lignes[0], secondaire: 7 }] }), TypeError);
|
||||
assert.equal(disposer({ lignes: [{ ...lignes[0], secondaire: null }] }).lignes[0].secondaire, null);
|
||||
});
|
||||
|
||||
test('cibleLigne : la ligne sous le pointeur, bornée aux lignes ; aucune sans ligne', () => {
|
||||
const d = disposer();
|
||||
for (const [i, ligne] of d.lignes.entries()) assert.equal(cibleLigne(ligne.y + 1, d), i);
|
||||
assert.equal(cibleLigne(-100, d), 0);
|
||||
assert.equal(cibleLigne(10_000, d), 2);
|
||||
assert.equal(cibleLigne(10, disposer({ lignes: [] })), null);
|
||||
});
|
||||
|
||||
test('segments et colonnes ensemble, ou une ligne qui n’a pas autant de valeurs, sont refusés', () => {
|
||||
assert.throws(() => disposer({ colonnes: [{ genre: 'serie1', libelle: 'x' }] }), RangeError);
|
||||
assert.throws(() => disposer({ lignes: [{ id: 'z', nom: 'Ondine Lacasse', valeurs: [1] }] }), RangeError);
|
||||
assert.throws(() => disposer({ segments: [{ genre: 'repere', libelle: 'x' }, segments[1]] }), RangeError);
|
||||
});
|
||||
|
||||
test('deux segments du même genre, ou une ligne sans identifiant propre, sont refusés : Barres les prend pour clés', () => {
|
||||
assert.throws(() => disposer({ segments: [{ genre: 'reste', libelle: 'a' }, { genre: 'reste', libelle: 'b' }] }), RangeError);
|
||||
assert.throws(() => disposer({ colonnes: [{ genre: 'serie1', libelle: 'a' }, { genre: 'serie1', libelle: 'b' }], segments: null }), RangeError);
|
||||
const sansId = [{ nom: 'Iris Ombrelle', valeurs: [1, 0] }, { nom: 'Théo Pervenche', valeurs: [0, 1] }];
|
||||
assert.throws(() => disposer({ lignes: sansId }), TypeError);
|
||||
assert.throws(() => disposer({ lignes: [{ ...lignes[0], id: { cle: 'a' } }] }), TypeError);
|
||||
assert.throws(() => disposer({ lignes: [lignes[0], { ...lignes[1], id: 'a' }] }), RangeError);
|
||||
assert.deepEqual(disposer({ lignes: [{ ...lignes[0], id: 1 }, { ...lignes[1], id: 2 }] }).lignes.map(({ id }) => id), [1, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pointage : la grille (§ 12.5, § 12.11, D33)', () => {
|
||||
const lignes = [{ libelle: 'Iris Ombrelle' }, { libelle: 'Théo Pervenche' }, { libelle: 'Basile Tourteau' }];
|
||||
const valeurs = [null, 1, 3, 1, null, 0, 3, 0, null];
|
||||
const disposer = (options) =>
|
||||
dispositionGrille({
|
||||
largeur: 600,
|
||||
mesurer,
|
||||
hauteurTexte: HAUTEUR_TEXTE,
|
||||
ecrire: graduer,
|
||||
lignes,
|
||||
colonnes: lignes,
|
||||
valeurs,
|
||||
diagonale: true,
|
||||
...options,
|
||||
});
|
||||
|
||||
test('chaque case : son niveau, son jeton, l’encre de son niveau et son compte écrit ; la diagonale vide et neutre', () => {
|
||||
const d = disposer();
|
||||
assert.equal(d.dessinable, true);
|
||||
const c = d.cases;
|
||||
assert.equal(c.length, 9);
|
||||
assert.deepEqual([c[0].diagonale, c[0].texte, c[0].niveau], [true, '', null]);
|
||||
assert.deepEqual([c[2].niveau, c[2].texte, c[2].marque], [3, '3', 'graphiqueNiveau3']);
|
||||
assert.equal(c[2].jeton, NIVEAUX_GRAPHIQUE[3].jeton);
|
||||
assert.equal(c[2].encre, NIVEAUX_GRAPHIQUE[3].encre);
|
||||
assert.deepEqual([c[5].niveau, c[5].marque], [0, null], 'le niveau 0 est un fond, sans marque');
|
||||
// La bordure et le remplissage ne se touchent pas.
|
||||
assert.ok(c[2].fond.x > c[2].bord.x + 0.5);
|
||||
assert.equal(c[2].fond.x + c[2].fond.cote, c[2].x + c[2].cote - RETRAIT_CASE_PX);
|
||||
});
|
||||
|
||||
test('une case sans compte écrit « — » sans couleur ; elle n’est pas sur la diagonale', () => {
|
||||
const d = disposer({ diagonale: false, valeurs: [null, 1, 2, 0] , lignes: lignes.slice(0, 2), colonnes: lignes.slice(0, 2) });
|
||||
assert.deepEqual([d.cases[0].texte, d.cases[0].niveau, d.cases[0].jeton, d.cases[0].diagonale], [null, null, null, false]);
|
||||
});
|
||||
|
||||
test('cède la place quand ses cases ne tiennent pas dans la largeur', () => {
|
||||
assert.equal(disposer({ largeur: 120 }).dessinable, false);
|
||||
});
|
||||
|
||||
test('cibleCase : la case sous le pointeur, aucune sur la diagonale ni hors de la grille', () => {
|
||||
const d = disposer();
|
||||
const centre = (i) => [d.cases[i].x + d.cases[i].cote / 2, d.cases[i].y + d.cases[i].cote / 2];
|
||||
assert.equal(cibleCase(...centre(5), d), 5);
|
||||
assert.equal(cibleCase(...centre(4), d), null);
|
||||
assert.equal(cibleCase(d.x0 - 1, centre(5)[1], d), null);
|
||||
assert.equal(cibleCase(centre(5)[0], d.y0 + d.cote * 3 + 1, d), null);
|
||||
});
|
||||
|
||||
test('autant de valeurs que de cases, sinon refus', () => {
|
||||
assert.throws(() => disposer({ valeurs: [1, 2] }), RangeError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pointage : la ligne de l’infobulle et les cellules du jumeau (§ 12.7, D34, D44)', () => {
|
||||
const colonnes = [
|
||||
{ cle: 'nom', libelle: 'Personne', genre: 'texte' },
|
||||
{ cle: 'rencontres', libelle: 'Rencontres', genre: 'nombre' },
|
||||
{ cle: 'part', libelle: 'Part', genre: 'pourcentage' },
|
||||
];
|
||||
|
||||
// L'écriture des nombres de la page, injectée comme format.nombre : elle
|
||||
// rend ici ses arguments, pour qu'une épreuve voie ce qu'elle a reçu.
|
||||
const ecrire = (v, { decimales = 0 } = {}) => `n(${v};${decimales})`;
|
||||
|
||||
test('chaque cellule dans l’ordre des colonnes ; une cellule de pourcentage porte son texte et le compte de sa ligne', () => {
|
||||
const ligne = { nom: 'Iris Ombrelle', rencontres: 24, part: { texte: '88 \u{25}', compte: '24 sur 27' } };
|
||||
assert.deepEqual(ligneInfobulle(colonnes, ligne, ecrire), [
|
||||
{ cle: 'nom', libelle: 'Personne', genre: 'texte', texte: 'Iris Ombrelle', compte: null },
|
||||
{ cle: 'rencontres', libelle: 'Rencontres', genre: 'nombre', texte: 'n(24;0)', compte: null },
|
||||
{ cle: 'part', libelle: 'Part', genre: 'pourcentage', texte: '88 \u{25}', compte: '24 sur 27' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('rouge : un nombre s’écrit par l’écriture de la page, aux décimales de sa colonne, jamais par String', () => {
|
||||
const taux = { cle: 'taux', libelle: 'Taux', genre: 'nombre', decimales: 2 };
|
||||
assert.equal(celluleDe(colonnes[1], { rencontres: 20000 }, ecrire).texte, 'n(20000;0)');
|
||||
assert.equal(celluleDe(taux, { taux: 0.75 }, ecrire).texte, 'n(0.75;2)');
|
||||
assert.equal(celluleDe(colonnes[1], { rencontres: 'inconnues' }, ecrire).texte, 'inconnues', 'un texte déjà écrit reste');
|
||||
assert.throws(() => celluleDe(colonnes[1], { rencontres: 3 }), TypeError, 'sans écriture, refus');
|
||||
assert.throws(() => celluleDe(colonnes[0], { nom: 3 }, ecrire), TypeError, 'un nombre sous une colonne de texte');
|
||||
assert.throws(() => exigerColonnes([{ ...taux, decimales: 1.5 }]), RangeError);
|
||||
assert.throws(() => exigerColonnes([{ ...colonnes[0], decimales: 2 }]), RangeError, 'des décimales hors d’une colonne de nombres');
|
||||
});
|
||||
|
||||
test('un pourcentage sans valeur garde son compte ; une valeur absente se rend null', () => {
|
||||
assert.deepEqual(celluleDe(colonnes[2], { part: { texte: null, compte: '0 sur 0' } }, ecrire), {
|
||||
genre: 'pourcentage',
|
||||
texte: null,
|
||||
compte: '0 sur 0',
|
||||
});
|
||||
assert.deepEqual(celluleDe(colonnes[1], {}, ecrire), { genre: 'nombre', texte: null, compte: null });
|
||||
});
|
||||
|
||||
test('rouge : une cellule de pourcentage sans son compte est refusée', () => {
|
||||
assert.throws(() => celluleDe(colonnes[2], { part: { texte: '88 \u{25}' } }, ecrire), /compte/);
|
||||
assert.throws(() => celluleDe(colonnes[2], { part: { texte: '88 \u{25}', compte: '' } }, ecrire), /compte/);
|
||||
assert.throws(() => celluleDe(colonnes[2], { part: '88 \u{25}' }, ecrire), TypeError);
|
||||
});
|
||||
|
||||
test('exigerCompte : une chaîne non vide, sinon TypeError', () => {
|
||||
assert.equal(exigerCompte('minimum 24 sur 27'), 'minimum 24 sur 27');
|
||||
for (const faux of ['', null, undefined, 3]) assert.throws(() => exigerCompte(faux), TypeError);
|
||||
});
|
||||
|
||||
test('exigerColonnes : un genre connu, une clé unique, un libellé', () => {
|
||||
assert.equal(exigerColonnes(colonnes), colonnes);
|
||||
assert.throws(() => exigerColonnes([]), RangeError);
|
||||
assert.throws(() => exigerColonnes([{ cle: 'x', libelle: 'X', genre: 'date' }]), RangeError);
|
||||
assert.throws(() => exigerColonnes([colonnes[0], colonnes[0]]), RangeError);
|
||||
assert.throws(() => exigerColonnes([{ cle: 'x', genre: 'texte' }]), TypeError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pointage : la troncature d’un nom et l’espacement des libellés', () => {
|
||||
test('tronquerNom : entier quand il tient, sinon le plus long préfixe suivi de la marque', () => {
|
||||
assert.deepEqual(tronquerNom('Iris Ombrelle', 200, mesurer, MARQUE), { texte: 'Iris Ombrelle', largeur: 91, tronque: false });
|
||||
const r = tronquerNom('Iris Ombrelle', 50, mesurer, MARQUE);
|
||||
assert.deepEqual(r, { texte: `Iris O${MARQUE}`, largeur: 49, tronque: true });
|
||||
assert.deepEqual(tronquerNom('Iris', 5, mesurer, MARQUE), { texte: '', largeur: 0, tronque: true });
|
||||
assert.deepEqual(tronquerNom('Iris', 7, mesurer, MARQUE), { texte: MARQUE, largeur: 7, tronque: true });
|
||||
});
|
||||
|
||||
test('tronquerNom ne coupe pas une lettre hors du plan de base', () => {
|
||||
const r = tronquerNom('\u{1D4D8}\u{1D4D8}\u{1D4D8}', 14, mesurer, MARQUE);
|
||||
assert.equal(r.texte, `\u{1D4D8}${MARQUE}`);
|
||||
});
|
||||
|
||||
test('espacerEtiquettes : un pas au moins entre deux libellés, l’ordre gardé, dans les bornes', () => {
|
||||
assert.deepEqual(espacerEtiquettes([50, 52, 51], 10, 0, 200), [50, 70, 60]);
|
||||
assert.deepEqual(espacerEtiquettes([195, 196], 10, 0, 200), [190, 200]);
|
||||
assert.deepEqual(espacerEtiquettes([], 10, 0, 200), []);
|
||||
});
|
||||
});
|
||||
121
src/interface/qualite/source.js
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// La source de la page de qualité (§ 12, B9, D25), pure : SectionQualite.svelte
|
||||
// la câble.
|
||||
//
|
||||
// La page mesure un placement entier, tous ses tours : la proposition
|
||||
// demandée (ui.proposition) quand elle est dans la liste, sinon le retenu,
|
||||
// sinon aucun. Le tour que le plan affiche n'y entre pas : un
|
||||
// placement auquel manque un tour de l'événement est en dérive, à chaque tour
|
||||
// du plan, et la page en donne les raisons. Une proposition fautive demandée
|
||||
// reste la source, même quand le plan, qui ne la lit pas, montre le retenu ou
|
||||
// les réservations à sa place : « Qualité » sur sa ligne en donne les fautes.
|
||||
//
|
||||
// La ligne de source reprend le libellé que la barre de vue du plan donne à
|
||||
// ce placement (sourceMontree, plan/vue_plan.js), sa commande de retour
|
||||
// comprise, configuration normalisée ou non ; une proposition fautive, que la
|
||||
// barre ne nomme jamais, a le sien, qui dit que le plan ne la montre pas. La
|
||||
// ligne d'évaluation est celle de la table d'évaluation de la charge
|
||||
// (evaluation, propositions/modele.js), la même que le tableau des
|
||||
// propositions lit : rien ne se mesure ici. Sans source mesurable, quatre cas
|
||||
// se partagent la page sans se recouvrir, éprouvés dans l'ordre de
|
||||
// CAS_QUALITE : la configuration qui ne se normalise pas l'emporte ; puis ni
|
||||
// proposition ni retenu ; puis des propositions sans source, avec la première
|
||||
// comparable du classement (premiereDuClassement) ; puis une source en dérive
|
||||
// ou fautive, avec ses raisons.
|
||||
import { formeNommeeDe } from '../../application/commandes.js';
|
||||
import { diagnostiquerCharge } from '../../application/diagnostic.js';
|
||||
import { premiereDuClassement } from '../../application/evaluation.js';
|
||||
import { sourceMontree } from '../plan/vue_plan.js';
|
||||
import { evaluation } from '../propositions/modele.js';
|
||||
|
||||
/** Les cas de la page, dans l'ordre où elle les éprouve : configuration,
|
||||
* la configuration ne se normalise pas ; aucunPlacement, ni proposition
|
||||
* ni retenu ; aucuneMontree, des propositions et aucune montrée ; derive
|
||||
* et fautif, le statut de la source ; mesurable, une source comparable. */
|
||||
export const CAS_QUALITE = Object.freeze(['configuration', 'aucunPlacement', 'aucuneMontree', 'derive', 'fautif', 'mesurable']);
|
||||
|
||||
// Une raison de dérive ou une faute : son code, ses détails, et la personne
|
||||
// qu'elle nomme, null quand elle n'en nomme aucune — la forme des raisons du
|
||||
// tableau des propositions.
|
||||
const raisonDe = (raison) => ({ cle: raison.code, details: raison, participant: raison.participant ?? null });
|
||||
|
||||
// Le libellé de la ligne de source et la clé de sa commande de retour pour
|
||||
// le placement id ('retenu', un identifiant de proposition, ou null) : ceux
|
||||
// que la barre de vue du plan donne à ce placement, l'état des places
|
||||
// qu'elle lit étant reconstitué sans tour ; une proposition fautive a le
|
||||
// sien. Fautif se lit aux fautes du placement (formeNommeeDe), comme la
|
||||
// barre le lit, et non à la table d'évaluation, nulle quand la
|
||||
// configuration ne se normalise pas.
|
||||
function ligneDeSource(charge, demandee, id) {
|
||||
const retenu = charge.retenu !== null;
|
||||
const cible = id === 'retenu' ? { retenu: true } : { proposition: id };
|
||||
const fautif = id !== null && formeNommeeDe(charge, cible).fautes.length > 0;
|
||||
let etat;
|
||||
if (id === 'retenu') {
|
||||
etat = fautif ? { source: 'reservations', refus: { code: 'RETENU_FAUTIF' } } : { source: 'retenu' };
|
||||
} else if (id !== null) {
|
||||
etat = { source: 'proposition', proposition: id, cible: retenu ? null : 'reservations' };
|
||||
} else {
|
||||
etat = { source: 'reservations', refus: null };
|
||||
}
|
||||
const montree = sourceMontree(etat, demandee, retenu);
|
||||
if (id === null || id === 'retenu' || !fautif) return montree;
|
||||
return { libelle: { cle: 'qualite.source.propositionFautive', details: { proposition: id } }, revenir: montree.revenir };
|
||||
}
|
||||
|
||||
// Le refus de normaliser la configuration : celui de la table d'évaluation,
|
||||
// ou, sans placement à évaluer, celui du diagnostic ; null quand elle se
|
||||
// normalise.
|
||||
function refusDeConfiguration(charge, evaluee) {
|
||||
if (evaluee.refus !== null) return evaluee.refus;
|
||||
if (evaluee.table !== null) return null;
|
||||
const { erreur } = diagnostiquerCharge(charge);
|
||||
return erreur === null ? null : { code: erreur.code, details: erreur.details };
|
||||
}
|
||||
|
||||
/**
|
||||
* La source de la page de qualité et le cas qu'elle y fait. Ne modifie pas
|
||||
* la charge.
|
||||
*
|
||||
* @param {Object} charge la charge de l'état observable de la séance
|
||||
* @param {{demandee?: number|null}} [demande]
|
||||
* demandee : la proposition demandée au plan (ui.proposition)
|
||||
* @returns {{cas: string, libelle: {cle: string, details: Object},
|
||||
* revenir: string|null, id: number|'retenu'|null, ligne: Object|null,
|
||||
* premiere: number|null, raisons: Object[], refus: Object|null}}
|
||||
* cas : l'un de CAS_QUALITE ; libelle et revenir : la ligne de source et
|
||||
* la clé de sa commande de retour ; id : le placement que la page mesure,
|
||||
* l'identifiant de la proposition ou 'retenu', null sans placement ;
|
||||
* ligne : sa ligne d'évaluation quand il se mesure, null sinon ;
|
||||
* premiere : l'identifiant de la première proposition comparable du
|
||||
* classement, dans le cas aucuneMontree, null sinon ; raisons : celles de
|
||||
* la dérive ou les fautes, chacune {cle, details, participant} ; refus :
|
||||
* {code, details} du refus de normaliser
|
||||
*/
|
||||
export function sourceQualite(charge, { demandee = null } = {}) {
|
||||
const evaluee = evaluation(charge);
|
||||
const { table } = evaluee;
|
||||
// Le placement que la page mesure : la proposition demandée qu'a la liste,
|
||||
// sinon le retenu ; sa ligne d'évaluation, null quand la configuration ne
|
||||
// se normalise pas.
|
||||
let id = null;
|
||||
if (demandee !== null && charge.propositions.some((proposition) => proposition.id === demandee)) id = demandee;
|
||||
else if (charge.retenu !== null) id = 'retenu';
|
||||
let ligne = null;
|
||||
if (table !== null && id === 'retenu') ligne = table.retenu;
|
||||
else if (table !== null && id !== null) ligne = table.propositions.find((candidate) => candidate.id === id) ?? null;
|
||||
|
||||
const { libelle, revenir } = ligneDeSource(charge, demandee, id);
|
||||
const resultat = (cas, champs = {}) => ({ cas, libelle, revenir, id: null, ligne: null, premiere: null, raisons: [], refus: null, ...champs });
|
||||
|
||||
const refus = refusDeConfiguration(charge, evaluee);
|
||||
if (refus !== null) return resultat('configuration', { refus });
|
||||
if (ligne === null) {
|
||||
if (charge.propositions.length === 0) return resultat('aucunPlacement');
|
||||
return resultat('aucuneMontree', { premiere: premiereDuClassement(table)?.id ?? null });
|
||||
}
|
||||
if (ligne.statut !== 'comparable') return resultat(ligne.statut, { id, raisons: ligne.raisons.map(raisonDe) });
|
||||
return resultat('mesurable', { id, ligne });
|
||||
}
|
||||
320
src/interface/qualite/source.test.js
Normal file
|
|
@ -0,0 +1,320 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Épreuves de la source de la page de qualité (§ 12, B9, D25) : la page
|
||||
// mesure un placement entier — la proposition demandée, sinon le retenu —, et
|
||||
// quatre cas sans recouvrement se partagent l'absence d'une source mesurable
|
||||
// — ni proposition ni retenu, des propositions dont aucune n'est montrée, une
|
||||
// source en dérive ou fautive, une configuration qui ne se normalise pas, qui
|
||||
// l'emporte. L'état des places du plan (etatPlaces), sollicité à part, dit ce
|
||||
// que la barre de vue nomme ; la ligne d'évaluation est celle d'evaluer. Les
|
||||
// noms d'épreuve sont inventés.
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, test } from '../../../test/lanceur.js';
|
||||
import { appliquerCommande } from '../../application/commandes.js';
|
||||
import { diagnostiquerCharge } from '../../application/diagnostic.js';
|
||||
import { evaluer, premiereDuClassement } from '../../application/evaluation.js';
|
||||
import { etatPlaces } from '../../application/places.js';
|
||||
import { rechercher } from '../../moteur/recherche.js';
|
||||
import { configurationDepuisCharge, creerCharge } from '../../stockage/document.js';
|
||||
import { VERSION } from '../../version.genere.js';
|
||||
import { CAS_QUALITE, sourceQualite } from './source.js';
|
||||
|
||||
const V1 = VERSION.affichee;
|
||||
|
||||
// Six personnes inventées, deux appartenances, deux tables de trois places,
|
||||
// deux tours, sans proposition ni retenu.
|
||||
function chargeSix() {
|
||||
const charge = creerCharge({ id: 'evt-qualite', nom: 'Atelier Ressac', siegesParDefaut: 3, tours: 2 });
|
||||
const noms = [
|
||||
['Ombrelle', 'Iris', 'Coopérative Aubier'],
|
||||
['Pervenche', 'Théo', 'Coopérative Aubier'],
|
||||
['Lacasse', 'Ondine', 'Atelier Ressac'],
|
||||
['Tourteau', 'Basile', 'Atelier Ressac'],
|
||||
['Quenouille', 'Maëlle', null],
|
||||
['Varech', 'Sidonie', null],
|
||||
];
|
||||
charge.participants = noms.map(([nom, prenom, appartenance], rang) => ({
|
||||
id: rang + 1,
|
||||
nom,
|
||||
prenom,
|
||||
appartenance,
|
||||
courriel: null,
|
||||
titrePressenti: null,
|
||||
notes: null,
|
||||
exclu: false,
|
||||
}));
|
||||
charge.tables = [1, 2].map((id) => ({ id, numero: id, sieges: null, forme: 'ronde', position: { x: 300 * id, y: 0 } }));
|
||||
charge.prochainsIds = { participant: 7, table: 3, proposition: 1 };
|
||||
return charge;
|
||||
}
|
||||
|
||||
// La charge, une génération de plus, enregistrée par la commande.
|
||||
function generee(charge, reglages = { graine: 3, arret: 40, nombre: 3 }) {
|
||||
const configuration = configurationDepuisCharge(charge);
|
||||
const propositions = rechercher(configuration, reglages);
|
||||
return appliquerCommande('enregistrerGeneration', charge, { propositions, configuration, produitVersion: V1 }).charge;
|
||||
}
|
||||
|
||||
const executer = (charge, nom, args) => appliquerCommande(nom, charge, args).charge;
|
||||
|
||||
// Une copie de la charge, modifiée en place par change.
|
||||
function avec(charge, change) {
|
||||
const copie = structuredClone(charge);
|
||||
change(copie);
|
||||
return copie;
|
||||
}
|
||||
|
||||
// Le même placement assis deux fois au premier tour : un placement fautif.
|
||||
function rendreFautif(placement) {
|
||||
const [premiere, seconde] = placement.placement[0].sieges;
|
||||
seconde[0] = premiere[0];
|
||||
}
|
||||
|
||||
// Iris réservée à la table 1 pour toute la soirée, et à la table 2 au
|
||||
// premier tour : la configuration ne se normalise pas.
|
||||
function enConflit(charge) {
|
||||
charge.reservations.push(
|
||||
{ participant: 1, table: 1, siege: null, portee: 'tous', tour: null },
|
||||
{ participant: 1, table: 2, siege: null, portee: 'tour', tour: 1 },
|
||||
);
|
||||
}
|
||||
|
||||
// La source que le plan montre à ce tour, lue dans l'état des places, et le
|
||||
// libellé de sa barre de vue.
|
||||
const sourceDuPlan = (charge, demandee, tour = 1) => {
|
||||
const { source, proposition, refus } = etatPlaces(charge, { tour, proposition: demandee });
|
||||
return { source, proposition, refus: refus?.code ?? null };
|
||||
};
|
||||
|
||||
// Les clés et les détails des raisons d'une ligne d'évaluation, comme le
|
||||
// tableau des propositions les écrit.
|
||||
const raisonsDe = (ligne) => ligne.raisons.map((raison) => ({ cle: raison.code, details: raison, participant: raison.participant ?? null }));
|
||||
|
||||
describe('la source de la page de qualité (§ 12, B9, D25)', () => {
|
||||
test('les cas, dans l’ordre où la page les éprouve', () => {
|
||||
assert.deepEqual(CAS_QUALITE, ['configuration', 'aucunPlacement', 'aucuneMontree', 'derive', 'fautif', 'mesurable']);
|
||||
assert.ok(Object.isFrozen(CAS_QUALITE));
|
||||
});
|
||||
|
||||
test('ni proposition ni retenu : aucun placement, la barre dit les réservations, rien à mesurer', () => {
|
||||
const charge = chargeSix();
|
||||
const source = sourceQualite(charge);
|
||||
assert.deepEqual(sourceDuPlan(charge, null), { source: 'reservations', proposition: null, refus: null });
|
||||
assert.equal(source.cas, 'aucunPlacement');
|
||||
assert.deepEqual(source.libelle, { cle: 'plan.source.reservations', details: {} });
|
||||
assert.equal(source.revenir, null);
|
||||
assert.equal(source.id, null);
|
||||
assert.equal(source.ligne, null);
|
||||
assert.equal(source.premiere, null);
|
||||
assert.deepEqual(source.raisons, []);
|
||||
assert.equal(source.refus, null);
|
||||
});
|
||||
|
||||
test('des propositions, aucune montrée : la commande montre la première comparable du classement, celle de premiereDuClassement', () => {
|
||||
const charge = generee(chargeSix());
|
||||
const premiere = premiereDuClassement(evaluer(charge));
|
||||
assert.notEqual(premiere, null);
|
||||
const source = sourceQualite(charge);
|
||||
assert.equal(source.cas, 'aucuneMontree');
|
||||
assert.equal(source.premiere, premiere.id);
|
||||
assert.deepEqual(source.libelle, { cle: 'plan.source.reservations', details: {} });
|
||||
assert.equal(source.id, null);
|
||||
assert.equal(source.ligne, null);
|
||||
});
|
||||
|
||||
test('des propositions dont la première par identifiant est fautive : la commande vise une proposition comparable, jamais elle', () => {
|
||||
const charge = avec(generee(chargeSix()), (c) => rendreFautif(c.propositions[0]));
|
||||
const [fautive] = charge.propositions;
|
||||
const source = sourceQualite(charge);
|
||||
assert.equal(source.cas, 'aucuneMontree');
|
||||
assert.notEqual(source.premiere, fautive.id);
|
||||
const ligne = evaluer(charge).propositions.find(({ id }) => id === source.premiere);
|
||||
assert.equal(ligne.statut, 'comparable');
|
||||
});
|
||||
|
||||
test('des propositions toutes en dérive : aucune montrée, et aucune commande, aucune ne se comparant', () => {
|
||||
const charge = avec(generee(chargeSix()), (c) => (c.participants[5].exclu = true));
|
||||
assert.equal(premiereDuClassement(evaluer(charge)), null);
|
||||
const source = sourceQualite(charge);
|
||||
assert.equal(source.cas, 'aucuneMontree');
|
||||
assert.equal(source.premiere, null);
|
||||
});
|
||||
|
||||
test('une proposition montrée qui se compare : la page la mesure, sous sa ligne d’évaluation, et la barre la nomme', () => {
|
||||
const charge = generee(chargeSix());
|
||||
const table = evaluer(charge);
|
||||
for (const ligne of table.propositions) {
|
||||
const source = sourceQualite(charge, { demandee: ligne.id });
|
||||
assert.deepEqual(sourceDuPlan(charge, ligne.id), { source: 'proposition', proposition: ligne.id, refus: null });
|
||||
assert.equal(source.cas, 'mesurable', String(ligne.id));
|
||||
assert.equal(source.id, ligne.id);
|
||||
assert.deepEqual(source.ligne, ligne);
|
||||
assert.deepEqual(source.libelle, { cle: 'plan.source.propositionReservations', details: { proposition: ligne.id } });
|
||||
assert.equal(source.revenir, 'plan.source.revenirReservations');
|
||||
}
|
||||
});
|
||||
|
||||
test('le retenu, sans proposition demandée : la page mesure le retenu ; une proposition demandée passe devant lui, en vue seule', () => {
|
||||
const generees = generee(chargeSix());
|
||||
const [, deuxieme] = generees.propositions;
|
||||
const charge = executer(generees, 'retenirProposition', { id: deuxieme.id });
|
||||
const table = evaluer(charge);
|
||||
const retenu = sourceQualite(charge);
|
||||
assert.deepEqual(sourceDuPlan(charge, null), { source: 'retenu', proposition: null, refus: null });
|
||||
assert.equal(retenu.cas, 'mesurable');
|
||||
assert.equal(retenu.id, 'retenu');
|
||||
assert.deepEqual(retenu.ligne, table.retenu);
|
||||
assert.deepEqual(retenu.libelle, { cle: 'plan.source.retenu', details: {} });
|
||||
assert.equal(retenu.revenir, null);
|
||||
const autre = charge.propositions.find(({ id }) => id !== deuxieme.id);
|
||||
const montree = sourceQualite(charge, { demandee: autre.id });
|
||||
assert.equal(montree.cas, 'mesurable');
|
||||
assert.equal(montree.id, autre.id);
|
||||
assert.deepEqual(montree.libelle, { cle: 'plan.source.propositionVueSeule', details: { proposition: autre.id } });
|
||||
assert.equal(montree.revenir, 'plan.source.revenirRetenu');
|
||||
});
|
||||
|
||||
test('une source qui disparaît : la page suit le plan, qui revient au retenu, ou aux réservations ; la commande de retour reste offerte', () => {
|
||||
const generees = generee(chargeSix());
|
||||
const disparue = generees.propositions[0].id;
|
||||
const sansElle = avec(generees, (c) => c.propositions.shift());
|
||||
assert.deepEqual(sourceDuPlan(sansElle, disparue), { source: 'reservations', proposition: null, refus: null });
|
||||
const aucune = sourceQualite(sansElle, { demandee: disparue });
|
||||
assert.equal(aucune.cas, 'aucuneMontree');
|
||||
assert.equal(aucune.revenir, 'plan.source.revenirReservations');
|
||||
// Une génération qui remplace la liste sous un retenu : le retenu revient.
|
||||
const retenue = executer(generees, 'retenirProposition', { id: generees.propositions[1].id });
|
||||
const remplacee = avec(retenue, (c) => c.propositions.splice(0, 1));
|
||||
const suivie = sourceQualite(remplacee, { demandee: disparue });
|
||||
assert.equal(suivie.cas, 'mesurable');
|
||||
assert.equal(suivie.id, 'retenu');
|
||||
assert.deepEqual(suivie.libelle, { cle: 'plan.source.retenu', details: {} });
|
||||
assert.equal(suivie.revenir, 'plan.source.revenirRetenu');
|
||||
});
|
||||
|
||||
test('une proposition montrée en dérive : ses raisons, celles de la ligne d’évaluation ; rien à mesurer', () => {
|
||||
const generees = generee(chargeSix());
|
||||
const id = generees.propositions[0].id;
|
||||
const charge = avec(generees, (c) => (c.participants[5].exclu = true));
|
||||
const ligne = evaluer(charge).propositions.find((candidate) => candidate.id === id);
|
||||
assert.equal(ligne.statut, 'derive');
|
||||
assert.deepEqual(sourceDuPlan(charge, id), { source: 'proposition', proposition: id, refus: null });
|
||||
const source = sourceQualite(charge, { demandee: id });
|
||||
assert.equal(source.cas, 'derive');
|
||||
assert.equal(source.id, id);
|
||||
assert.equal(source.ligne, null);
|
||||
assert.ok(source.raisons.length > 0);
|
||||
assert.deepEqual(source.raisons, raisonsDe(ligne));
|
||||
});
|
||||
|
||||
test('un retenu fautif : la barre dit que le retenu ne se lit pas, la page donne ses fautes', () => {
|
||||
const generees = generee(chargeSix());
|
||||
const charge = avec(executer(generees, 'retenirProposition', { id: generees.propositions[0].id }), (c) => rendreFautif(c.retenu));
|
||||
const ligne = evaluer(charge).retenu;
|
||||
assert.equal(ligne.statut, 'fautif');
|
||||
assert.deepEqual(sourceDuPlan(charge, null), { source: 'reservations', proposition: null, refus: 'RETENU_FAUTIF' });
|
||||
const source = sourceQualite(charge);
|
||||
assert.equal(source.cas, 'fautif');
|
||||
assert.equal(source.id, 'retenu');
|
||||
assert.deepEqual(source.libelle, { cle: 'plan.source.retenuFautif', details: {} });
|
||||
assert.ok(source.raisons.length > 0);
|
||||
assert.deepEqual(source.raisons, raisonsDe(ligne));
|
||||
});
|
||||
|
||||
test('une proposition fautive demandée : la page la mesure quand même, donne ses fautes, et sa ligne de source dit que le plan ne la montre pas', () => {
|
||||
const generees = generee(chargeSix());
|
||||
const id = generees.propositions[0].id;
|
||||
const sansRetenu = avec(generees, (c) => rendreFautif(c.propositions[0]));
|
||||
const ligne = evaluer(sansRetenu).propositions.find((candidate) => candidate.id === id);
|
||||
assert.equal(ligne.statut, 'fautif');
|
||||
assert.deepEqual(sourceDuPlan(sansRetenu, id), { source: 'reservations', proposition: null, refus: null });
|
||||
const seule = sourceQualite(sansRetenu, { demandee: id });
|
||||
assert.equal(seule.cas, 'fautif');
|
||||
assert.equal(seule.id, id);
|
||||
assert.equal(seule.ligne, null);
|
||||
assert.ok(seule.raisons.length > 0);
|
||||
assert.deepEqual(seule.raisons, raisonsDe(ligne));
|
||||
assert.deepEqual(seule.libelle, { cle: 'qualite.source.propositionFautive', details: { proposition: id } });
|
||||
assert.equal(seule.revenir, 'plan.source.revenirReservations');
|
||||
// Sous un retenu qui se lit, la proposition fautive demandée reste la
|
||||
// source de la page : le retenu ne passe pas devant elle.
|
||||
const retenue = avec(executer(generees, 'retenirProposition', { id: generees.propositions[1].id }), (c) => rendreFautif(c.propositions[0]));
|
||||
assert.deepEqual(sourceDuPlan(retenue, id), { source: 'retenu', proposition: null, refus: null });
|
||||
const source = sourceQualite(retenue, { demandee: id });
|
||||
assert.equal(source.cas, 'fautif');
|
||||
assert.equal(source.id, id);
|
||||
assert.deepEqual(source.raisons, raisonsDe(evaluer(retenue).propositions.find((candidate) => candidate.id === id)));
|
||||
assert.deepEqual(source.libelle, { cle: 'qualite.source.propositionFautive', details: { proposition: id } });
|
||||
assert.equal(source.revenir, 'plan.source.revenirRetenu');
|
||||
});
|
||||
|
||||
test('une configuration qui ne se normalise pas l’emporte : sans placement, avec des propositions, avec un retenu, le refus de la génération', () => {
|
||||
const generees = generee(chargeSix());
|
||||
const retenue = executer(generees, 'retenirProposition', { id: generees.propositions[0].id });
|
||||
const cas = [chargeSix(), generees, retenue].map((charge) => avec(charge, enConflit));
|
||||
for (const charge of cas) {
|
||||
const { erreur } = diagnostiquerCharge(charge);
|
||||
assert.notEqual(erreur, null);
|
||||
const source = sourceQualite(charge, { demandee: charge.propositions[0]?.id ?? null });
|
||||
assert.equal(source.cas, 'configuration');
|
||||
assert.equal(source.refus.code, erreur.code);
|
||||
assert.deepEqual(source.refus.details, erreur.details);
|
||||
assert.equal(source.ligne, null);
|
||||
assert.equal(source.premiere, null);
|
||||
}
|
||||
// Sans table d'évaluation, un placement fautif se reconnaît à ses
|
||||
// fautes : la ligne de source garde le libellé que la barre lui donne.
|
||||
const retenuFautif = avec(retenue, (c) => {
|
||||
rendreFautif(c.retenu);
|
||||
enConflit(c);
|
||||
});
|
||||
assert.deepEqual(sourceDuPlan(retenuFautif, null), { source: 'reservations', proposition: null, refus: 'RETENU_FAUTIF' });
|
||||
const surRetenu = sourceQualite(retenuFautif);
|
||||
assert.equal(surRetenu.cas, 'configuration');
|
||||
assert.deepEqual(surRetenu.libelle, { cle: 'plan.source.retenuFautif', details: {} });
|
||||
const id = generees.propositions[0].id;
|
||||
const propositionFautive = avec(generees, (c) => {
|
||||
rendreFautif(c.propositions[0]);
|
||||
enConflit(c);
|
||||
});
|
||||
const surProposition = sourceQualite(propositionFautive, { demandee: id });
|
||||
assert.equal(surProposition.cas, 'configuration');
|
||||
assert.deepEqual(surProposition.libelle, { cle: 'qualite.source.propositionFautive', details: { proposition: id } });
|
||||
});
|
||||
|
||||
test('le placement entier : un tour ajouté à l’événement met la proposition montrée et le retenu en dérive, au tour que le plan montre comme à tout autre', () => {
|
||||
const generees = generee(chargeSix());
|
||||
const id = generees.propositions[0].id;
|
||||
// L'événement passe à trois tours : la proposition n'en porte que deux,
|
||||
// et le plan revient aux réservations au troisième ; la page, elle,
|
||||
// mesure la proposition entière, et la dit en dérive.
|
||||
const courte = executer(generees, 'changerTours', { tours: 3 });
|
||||
assert.deepEqual(sourceDuPlan(courte, id, 1), { source: 'proposition', proposition: id, refus: null });
|
||||
assert.deepEqual(sourceDuPlan(courte, id, 3), { source: 'reservations', proposition: null, refus: null });
|
||||
const ligne = evaluer(courte).propositions.find((candidate) => candidate.id === id);
|
||||
assert.equal(ligne.statut, 'derive');
|
||||
const source = sourceQualite(courte, { demandee: id });
|
||||
assert.deepEqual([source.cas, source.id], ['derive', id]);
|
||||
assert.deepEqual(source.raisons, raisonsDe(ligne));
|
||||
assert.deepEqual(source.libelle, { cle: 'plan.source.propositionReservations', details: { proposition: id } });
|
||||
// Le retenu, sans proposition demandée : en dérive lui aussi, ses
|
||||
// raisons, que le plan le montre au premier tour et non au dernier.
|
||||
const retenu = executer(executer(generees, 'retenirProposition', { id }), 'changerTours', { tours: 3 });
|
||||
assert.equal(evaluer(retenu).retenu.statut, 'derive');
|
||||
assert.deepEqual(sourceDuPlan(retenu, null, 3), { source: 'reservations', proposition: null, refus: null });
|
||||
assert.deepEqual(sourceDuPlan(retenu, null, 1), { source: 'retenu', proposition: null, refus: null });
|
||||
const deRetenu = sourceQualite(retenu);
|
||||
assert.deepEqual([deRetenu.cas, deRetenu.id], ['derive', 'retenu']);
|
||||
assert.deepEqual(deRetenu.raisons, raisonsDe(evaluer(retenu).retenu));
|
||||
assert.deepEqual(deRetenu.libelle, { cle: 'plan.source.retenu', details: {} });
|
||||
});
|
||||
|
||||
test('la charge n’est pas modifiée, et deux lectures de la même charge rendent la même source', () => {
|
||||
const charge = generee(chargeSix());
|
||||
const avant = structuredClone(charge);
|
||||
const id = charge.propositions[1].id;
|
||||
assert.deepEqual(sourceQualite(charge, { demandee: id }), sourceQualite(charge, { demandee: id }));
|
||||
assert.deepEqual(charge, avant);
|
||||
});
|
||||
});
|
||||
14
src/main.js
|
|
@ -11,8 +11,9 @@
|
|||
// dépendances d'App — l'horloge du système, l'exécuteur de la génération sur
|
||||
// le travailleur, les identifiants tirés par crypto.randomUUID, les
|
||||
// démonstrations livrées nommées par le catalogue, le chargement de la
|
||||
// police embarquée, l'imprimante — et monte App dans le corps du document. Le thème de départ est laissé à App, qui lit la
|
||||
// préférence du système.
|
||||
// police embarquée, l'imprimante, le chargement de la section de la
|
||||
// qualité — et monte App dans le corps du document. Le thème de départ est
|
||||
// laissé à App, qui lit la préférence du système.
|
||||
//
|
||||
// Il est le seul module qui importe le travailleur de génération, par
|
||||
// « ?worker&inline » : Vite l'inclut dans la page, que la coquille charge
|
||||
|
|
@ -33,6 +34,13 @@
|
|||
// garderait sa largeur. Une face qui ne se charge pas ne retient pas
|
||||
// l'application : l'erreur va au collecteur (reportError), et App se monte
|
||||
// sur les replis du système.
|
||||
//
|
||||
// La section de la qualité forme son propre morceau dans la construction,
|
||||
// que la page ne télécharge qu'à la première ouverture de la section :
|
||||
// chargerSectionQualite l'importe par un import différé et rend son
|
||||
// composant. L'interface ne charge aucun module elle-même ; App reçoit
|
||||
// cette fonction, et le montage des épreuves lui en passe une qui rend le
|
||||
// composant importé d'emblée.
|
||||
import { mount } from 'svelte';
|
||||
import { creerExecuteurTravailleur } from './application/executeur_travailleur.js';
|
||||
import { creerHorlogeSysteme } from './application/horloge.js';
|
||||
|
|
@ -55,6 +63,7 @@ const pressePapiers = sousElectron
|
|||
? { copier: async (texte) => void (await pont.copier(texte)) }
|
||||
: { copier: (texte) => navigator.clipboard.writeText(texte) };
|
||||
const imprimante = sousElectron ? creerImprimanteElectron(pont) : creerImprimanteWeb(window);
|
||||
const chargerSectionQualite = async () => (await import('./interface/qualite/SectionQualite.svelte')).default;
|
||||
|
||||
try {
|
||||
await attendrePolices(document);
|
||||
|
|
@ -80,5 +89,6 @@ mount(App, {
|
|||
versionWindows: VERSION.windows,
|
||||
polices: chargerPolices,
|
||||
imprimante,
|
||||
sectionQualite: chargerSectionQualite,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -62,10 +62,12 @@
|
|||
* @property {number} ancragesImposantRetours personnes dont les réservations
|
||||
* imposent un retour
|
||||
* @property {{plancher: number, mobilesAuMoins: number}|null} ecartItineraire
|
||||
* @property {{horsObjectif: number, jamaisServies: number, horsObjectifMax: number}} objectifsInevitables
|
||||
* @property {{horsObjectif: number, jamaisServies: number, horsObjectifMax: number,
|
||||
* parTour: number[]}} objectifsInevitables
|
||||
* planchers immédiats des trois valeurs des modes des objectifs
|
||||
* (D16, A10) ; 0 quand rien n'est prouvé, chacun calculé sous tout
|
||||
* mode
|
||||
* mode, critère coché ou non ; parTour, le plancher de chaque tour,
|
||||
* max(0, P_r − L_r), R cases, dont horsObjectif est la somme
|
||||
*/
|
||||
import {
|
||||
LIBRE,
|
||||
|
|
@ -384,18 +386,20 @@ function ecartItineraire(configuration, instance, aPriori) {
|
|||
// Planchers immédiats des objectifs (D16, A10). Au tour r, une personne
|
||||
// servie qui n'est pas fixée occupe un siège libre d'une table dont
|
||||
// l'objectif est en vigueur : P_r compte les couples possibles non fixés,
|
||||
// L_r ces sièges libres, et les tours hors objectif sont au moins
|
||||
// Σ_r max(0, P_r − L_r). Un siège libre ne sert qu'une personne par tour :
|
||||
// parmi C′, les concernées qu'aucune réservation ne sert, au moins
|
||||
// #C′ − Σ_r min(L_r, P′_r) ne le sont jamais, P′_r comptant celles de C′ dont
|
||||
// le tour r est possible — tous non fixés, un couple fixé possible étant
|
||||
// servi. Le plus grand nombre de tours hors objectif d'une personne ne
|
||||
// descend pas sous la moyenne : le premier plancher divisé par le nombre de
|
||||
// personnes qui ont un tour possible non fixé, arrondi au-dessus. Les fixés
|
||||
// ne comptent ni parmi les couples ni parmi les sièges libres. O(T · R + N · R)
|
||||
// une fois les couples possibles connus.
|
||||
// L_r ces sièges libres, et les tours hors objectif du tour sont au moins
|
||||
// max(0, P_r − L_r), rendu par tour dans parTour ; leur somme sur les tours
|
||||
// est le plancher des tours hors objectif. Un siège libre ne sert qu'une
|
||||
// personne par tour : parmi C′, les concernées qu'aucune réservation ne
|
||||
// sert, au moins #C′ − Σ_r min(L_r, P′_r) ne le sont jamais, P′_r comptant
|
||||
// celles de C′ dont le tour r est possible — tous non fixés, un couple fixé
|
||||
// possible étant servi. Le plus grand nombre de tours hors objectif d'une
|
||||
// personne ne descend pas sous la moyenne : le premier plancher divisé par
|
||||
// le nombre de personnes qui ont un tour possible non fixé, arrondi
|
||||
// au-dessus. Les fixés ne comptent ni parmi les couples ni parmi les sièges
|
||||
// libres. O(T · R + N · R) une fois les couples possibles connus. Rien ne
|
||||
// lit l'état des critères : les planchers se chiffrent critère coché ou non.
|
||||
function objectifsInevitables({ N, T, R, fixe, possible, objectifDe, siegesLibres }) {
|
||||
let horsObjectif = 0;
|
||||
const parTour = new Array(R).fill(0);
|
||||
let servisAuPlus = 0;
|
||||
const libresAuTour = new Int32Array(R);
|
||||
for (let t = 0; t < T; t += 1) {
|
||||
|
|
@ -429,13 +433,15 @@ function objectifsInevitables({ N, T, R, fixe, possible, objectifDe, siegesLibre
|
|||
couples += 1;
|
||||
if (servieDOffice[p] === 0) parmiC += 1;
|
||||
}
|
||||
horsObjectif += Math.max(0, couples - libresAuTour[r]);
|
||||
parTour[r] = Math.max(0, couples - libresAuTour[r]);
|
||||
servisAuPlus += Math.min(libresAuTour[r], parmiC);
|
||||
}
|
||||
const horsObjectif = parTour.reduce((somme, plancher) => somme + plancher, 0);
|
||||
return {
|
||||
horsObjectif,
|
||||
jamaisServies: Math.max(0, concerneesLibres - servisAuPlus),
|
||||
horsObjectifMax: horsObjectif === 0 ? 0 : Math.ceil(horsObjectif / avecTourLibre),
|
||||
parTour,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -477,7 +483,9 @@ function etendue(valeurs) {
|
|||
* - objectifsInevitables : planchers immédiats des tours hors objectif, des
|
||||
* personnes jamais servies et du plus de tours hors objectif d'une
|
||||
* personne, nuls sans objectif ; l'interface écrit ceux que le mode
|
||||
* compare. animateursMemeAppartenance garde son sens : les ancrés.
|
||||
* compare. parTour porte le plancher de chaque tour, que le détail de la
|
||||
* page de qualité lit sans le recompter (qualite.js).
|
||||
* animateursMemeAppartenance garde son sens : les ancrés.
|
||||
*
|
||||
* @param {import('./types.js').Configuration} configuration
|
||||
* @param {{planchersCollisions?: typeof planchersCollisions}} [calcul]
|
||||
|
|
|
|||
|
|
@ -1091,7 +1091,7 @@ describe('diagnostic : population vide, forme, refus', () => {
|
|||
retoursImposes: 0,
|
||||
ancragesImposantRetours: 0,
|
||||
ecartItineraire: null,
|
||||
objectifsInevitables: { horsObjectif: 0, jamaisServies: 0, horsObjectifMax: 0 },
|
||||
objectifsInevitables: { horsObjectif: 0, jamaisServies: 0, horsObjectifMax: 0, parTour: [0, 0] },
|
||||
};
|
||||
// Les mêmes chiffres, séparation active ou non : sans présent, aucune
|
||||
// appartenance n'est portée par chacun, et aucune table n'est forcée.
|
||||
|
|
@ -1454,13 +1454,24 @@ describe('diagnostic : les objectifs hors d’atteinte (§ 5.6, A10, D16)', () =
|
|||
return configuration;
|
||||
}
|
||||
|
||||
test('dix, trente, quarante personnes « finance » pour huit places, quatre tours : les planchers de A10', () => {
|
||||
// Trente : P_r = 30, L_r = 8, 22 × 4 = 88 ; 30 − 4 × 8 < 0 ; ⌈88 / 30⌉ = 3.
|
||||
// Dix : 2 × 4 = 8 ; aucun ; ⌈8 / 10⌉ = 1. Quarante : 32 × 4 = 128 ;
|
||||
// 40 − 32 = 8 ; ⌈128 / 40⌉ = 4.
|
||||
assert.deepEqual(diagnostiquer(finance(30)).objectifsInevitables, { horsObjectif: 88, jamaisServies: 0, horsObjectifMax: 3 });
|
||||
assert.deepEqual(diagnostiquer(finance(10)).objectifsInevitables, { horsObjectif: 8, jamaisServies: 0, horsObjectifMax: 1 });
|
||||
assert.deepEqual(diagnostiquer(finance(40)).objectifsInevitables, { horsObjectif: 128, jamaisServies: 8, horsObjectifMax: 4 });
|
||||
test('dix, trente, quarante personnes « finance » pour huit places, quatre tours : les planchers de A10, et celui de chaque tour', () => {
|
||||
// Trente : P_r = 30, L_r = 8, 22 par tour, 22 × 4 = 88 ; 30 − 4 × 8 < 0 ;
|
||||
// ⌈88 / 30⌉ = 3. Dix : 2 par tour, 2 × 4 = 8 ; aucun ; ⌈8 / 10⌉ = 1.
|
||||
// Quarante : 32 par tour, 32 × 4 = 128 ; 40 − 32 = 8 ; ⌈128 / 40⌉ = 4.
|
||||
assert.deepEqual(diagnostiquer(finance(30)).objectifsInevitables, { horsObjectif: 88, jamaisServies: 0, horsObjectifMax: 3, parTour: [22, 22, 22, 22] });
|
||||
assert.deepEqual(diagnostiquer(finance(10)).objectifsInevitables, { horsObjectif: 8, jamaisServies: 0, horsObjectifMax: 1, parTour: [2, 2, 2, 2] });
|
||||
assert.deepEqual(diagnostiquer(finance(40)).objectifsInevitables, { horsObjectif: 128, jamaisServies: 8, horsObjectifMax: 4, parTour: [32, 32, 32, 32] });
|
||||
});
|
||||
|
||||
test('le plancher de chaque tour se calcule critère décoché, et sa somme est le plancher des tours hors objectif', () => {
|
||||
// Le critère des objectifs décoché sort de la recherche et du
|
||||
// classement ; le diagnostic chiffre les mêmes planchers.
|
||||
const decoche = finance(30);
|
||||
decoche.contraintes = { ...decoche.contraintes, respecterObjectifs: false };
|
||||
const { parTour, horsObjectif } = diagnostiquer(decoche).objectifsInevitables;
|
||||
assert.deepEqual(parTour, [22, 22, 22, 22]);
|
||||
assert.equal(parTour.reduce((somme, plancher) => somme + plancher, 0), horsObjectif);
|
||||
assert.ok(Array.isArray(parTour));
|
||||
});
|
||||
|
||||
test('les fixés ne comptent ni dans les couples ni dans les sièges libres, et qui une réservation sert n’est jamais servi d’office', () => {
|
||||
|
|
@ -1468,22 +1479,23 @@ describe('diagnostic : les objectifs hors d’atteinte (§ 5.6, A10, D16)', () =
|
|||
// étiquette ancré à la même table : L_r = 6, P_r = 10, 4 × 4 = 16 ; C′
|
||||
// compte les dix libres, 10 − 24 < 0 ; ⌈16 / 10⌉ = 2.
|
||||
const deuxAncres = finance(10, { autres: [['finance'], undefined], reservations: [ancrer(11, 1), ancrer(12, 1)] });
|
||||
assert.deepEqual(diagnostiquer(deuxAncres).objectifsInevitables, { horsObjectif: 16, jamaisServies: 0, horsObjectifMax: 2 });
|
||||
assert.deepEqual(diagnostiquer(deuxAncres).objectifsInevitables, { horsObjectif: 16, jamaisServies: 0, horsObjectifMax: 2, parTour: [4, 4, 4, 4] });
|
||||
// Quarante dont 1, ancré à la table 1, servi d'office : L_r = 7, P_r = 39,
|
||||
// 32 × 4 = 128 ; C′ = 39, 39 − 28 = 11 ; ⌈128 / 39⌉ = 4.
|
||||
const unAncre = finance(40, { reservations: [ancrer(1, 1)] });
|
||||
assert.deepEqual(diagnostiquer(unAncre).objectifsInevitables, { horsObjectif: 128, jamaisServies: 11, horsObjectifMax: 4 });
|
||||
assert.deepEqual(diagnostiquer(unAncre).objectifsInevitables, { horsObjectif: 128, jamaisServies: 11, horsObjectifMax: 4, parTour: [32, 32, 32, 32] });
|
||||
// Quarante dont 1, réservé au seul tour 1 à la table 1 : servi d'office,
|
||||
// hors de C′, il compte encore dans P_r aux trois autres tours.
|
||||
// 32 + 3 × 32 = 128 ; C′ = 39, 39 − (7 + 3 × 8) = 8 ; ⌈128 / 40⌉ = 4.
|
||||
// Au tour 1, 39 − 7 = 32, comme 40 − 8 aux autres.
|
||||
const unTour = finance(40, { reservations: [fixer(1, 1, 1)] });
|
||||
assert.deepEqual(diagnostiquer(unTour).objectifsInevitables, { horsObjectif: 128, jamaisServies: 8, horsObjectifMax: 4 });
|
||||
assert.deepEqual(diagnostiquer(unTour).objectifsInevitables, { horsObjectif: 128, jamaisServies: 8, horsObjectifMax: 4, parTour: [32, 32, 32, 32] });
|
||||
// Dix dont 1 à 6 ancrés à la table 1, servis d'office : L_r = 2, P_r = 4,
|
||||
// 2 × 4 = 8 ; C′ = 4, 4 − 4 × 2 < 0. Le diviseur compte les quatre qui
|
||||
// ont un tour possible non fixé, non les six que leur réservation sert :
|
||||
// ⌈8 / 4⌉ = 2.
|
||||
const sixAncres = finance(10, { reservations: Array.from({ length: 6 }, (_, i) => ancrer(i + 1, 1)) });
|
||||
assert.deepEqual(diagnostiquer(sixAncres).objectifsInevitables, { horsObjectif: 8, jamaisServies: 0, horsObjectifMax: 2 });
|
||||
assert.deepEqual(diagnostiquer(sixAncres).objectifsInevitables, { horsObjectif: 8, jamaisServies: 0, horsObjectifMax: 2, parTour: [2, 2, 2, 2] });
|
||||
// Deux tours. 1 à 6, « finance », réservés au tour 2 à la table 1,
|
||||
// « finance », de huit places ; 7 à 16, « rh », libres ; la table 2,
|
||||
// « rh » au seul tour 2, de trois places ; la table 3, sans objectif.
|
||||
|
|
@ -1497,18 +1509,20 @@ describe('diagnostic : les objectifs hors d’atteinte (§ 5.6, A10, D16)', () =
|
|||
{ table: 1, tour: null, regle: 'ou', etiquettes: ['finance'] },
|
||||
{ table: 2, tour: 2, regle: 'ou', etiquettes: ['rh'] },
|
||||
];
|
||||
assert.deepEqual(diagnostiquer(parTour).objectifsInevitables, { horsObjectif: 5, jamaisServies: 5, horsObjectifMax: 1 });
|
||||
// Par tour : max(0, 6 − 8) = 0, puis 10 − 5 = 5.
|
||||
assert.deepEqual(diagnostiquer(parTour).objectifsInevitables, { horsObjectif: 5, jamaisServies: 5, horsObjectifMax: 1, parTour: [0, 5] });
|
||||
});
|
||||
|
||||
test('sans objectif, ou sans personne qu’un objectif serve, les trois planchers sont nuls', () => {
|
||||
const sans = finance(10);
|
||||
delete sans.objectifs;
|
||||
const nul = { horsObjectif: 0, jamaisServies: 0, horsObjectifMax: 0 };
|
||||
const nul = { horsObjectif: 0, jamaisServies: 0, horsObjectifMax: 0, parTour: [0, 0, 0, 0] };
|
||||
assert.deepEqual(diagnostiquer(sans).objectifsInevitables, nul);
|
||||
const personne = finance(10);
|
||||
personne.objectifs = [{ table: 1, tour: null, regle: 'ou', etiquettes: ['bénévole'] }];
|
||||
assert.deepEqual(diagnostiquer(personne).objectifsInevitables, nul);
|
||||
assert.deepEqual(diagnostiquer(construire('petite')).objectifsInevitables, nul);
|
||||
const petite = construire('petite');
|
||||
assert.deepEqual(diagnostiquer(petite).objectifsInevitables, { ...nul, parTour: new Array(petite.tours).fill(0) });
|
||||
});
|
||||
|
||||
test('un objectif ou une animation d’un tour au-delà de R′ se retire de la sonde des tours, sans lever', () => {
|
||||
|
|
|
|||
|
|
@ -18,9 +18,10 @@
|
|||
// Une seule lecture du plan décide qu'une paire est réunie, collègue ou revue,
|
||||
// qu'une personne revient, qu'elle est servie par l'objectif de sa table et
|
||||
// quels animateurs elle rencontre : parcourirTours. mesurer en tire chacun de
|
||||
// ses chiffres, et conflitsParTable (conflits.js) le détail par table, si
|
||||
// ses chiffres, conflitsParTable (conflits.js) le détail par table, si
|
||||
// bien que la somme des violations par table égale le compte de la mesure
|
||||
// (§ 13.2).
|
||||
// (§ 13.2), et le détail de la page de qualité (qualite.js) ses comptes par
|
||||
// tour.
|
||||
//
|
||||
// Un tour servi et une rencontre d'animateur ne se comptent qu'à une table
|
||||
// que la personne peut occuper à ce tour : sa table fixée, ou, libre, une
|
||||
|
|
@ -139,17 +140,27 @@ const rangPaire = (N, a, b) => (a * (2 * N - a - 1)) / 2 + (b - a - 1);
|
|||
* animateurs distincts, autres que p, qui animent à sa table, à un tour où
|
||||
* il l'occupe et peut l'occuper, et y sont assis.
|
||||
*
|
||||
* Par tour, au même passage que servis (§ 5.4, B4) : servisParTour, S_r,
|
||||
* les tours servis du tour r, si bien que leur somme sur les tours est celle
|
||||
* de servis ; possiblesParTour, Π_r, les couples (p, r) possibles du tour,
|
||||
* instance.possible, fixés et personnes en réserve compris, si bien que leur
|
||||
* somme est celle de P(p) ; assisSansCoupleParTour, σ_r, les personnes
|
||||
* assises à une table au tour r dont le couple (p, r) n'est pas possible,
|
||||
* concernées ou non. Un servi a son couple possible : S_r ≤ Π_r, et σ_r + Π_r
|
||||
* ne dépasse pas les présents.
|
||||
*
|
||||
* Ne modifie ni l'instance ni le plan ; lève ce que lève exigerPlanIndexe.
|
||||
*
|
||||
* @param {import('./types.js').Instance} instance
|
||||
* @param {ArrayLike<number>} tableDe
|
||||
* @returns {{parTour: Array<Array<{collegues: number[][], revues: number[][], revenus: number[]}>>,
|
||||
* parPaire: Uint16Array|Uint32Array, reunies: number[], toursAssis: number[], retours: number[],
|
||||
* servis: number[], rencontresAnimateurs: number[]}}
|
||||
* servis: number[], rencontresAnimateurs: number[], servisParTour: number[],
|
||||
* possiblesParTour: number[], assisSansCoupleParTour: number[]}}
|
||||
*/
|
||||
export function parcourirTours(instance, tableDe) {
|
||||
exigerPlanIndexe(instance, tableDe);
|
||||
const { N, T, R, fixe, groupe, siegesLibres, O, objectifDe, correspond } = instance;
|
||||
const { N, T, R, fixe, groupe, siegesLibres, O, objectifDe, correspond, possible } = instance;
|
||||
const { A, rangAnimateur, animateurs, debutAnimes, animes } = instance;
|
||||
|
||||
// Personne par personne, ses tours dans l'ordre : visites[t] compte ses
|
||||
|
|
@ -162,11 +173,16 @@ export function parcourirTours(instance, tableDe) {
|
|||
// servi quand l'objectif en vigueur lui correspond, et chaque animateur qui
|
||||
// anime là et y est assis, autre qu'elle, rencontré. rencontre[f] vaut p
|
||||
// quand p a déjà rencontré l'animateur de rang f : la marque, datée par
|
||||
// personne, n'a pas à se remettre à zéro.
|
||||
// personne, n'a pas à se remettre à zéro. Le couple (p, r) possible compte
|
||||
// dans Π_r avant que la réserve ne l'écarte ; assis, il entre dans σ_r
|
||||
// quand il ne l'est pas, et un tour servi entre aussi dans S_r.
|
||||
const toursAssis = new Array(N).fill(0);
|
||||
const retours = new Array(N).fill(0);
|
||||
const servis = new Array(N).fill(0);
|
||||
const rencontresAnimateurs = new Array(N).fill(0);
|
||||
const servisParTour = new Array(R).fill(0);
|
||||
const possiblesParTour = new Array(R).fill(0);
|
||||
const assisSansCoupleParTour = new Array(R).fill(0);
|
||||
const revenu = new Uint8Array(N * R);
|
||||
const visites = new Int32Array(T);
|
||||
const reserveeAvant = new Uint8Array(T);
|
||||
|
|
@ -174,14 +190,20 @@ export function parcourirTours(instance, tableDe) {
|
|||
for (let p = 0; p < N; p += 1) {
|
||||
const soi = rangAnimateur[p];
|
||||
for (let r = 0; r < R; r += 1) {
|
||||
const couple = possible[p * R + r];
|
||||
possiblesParTour[r] += couple;
|
||||
const t = tableDe[p * R + r];
|
||||
if (t === RESERVE) continue;
|
||||
toursAssis[p] += 1;
|
||||
if (couple === 0) assisSansCoupleParTour[r] += 1;
|
||||
const imposee = fixe[p * R + r];
|
||||
const ligne = t * R + r;
|
||||
if (imposee === LIBRE ? siegesLibres[ligne] > 0 : imposee === t) {
|
||||
const o = objectifDe[ligne];
|
||||
if (o !== SANS_OBJECTIF && correspond[p * O + o] === 1) servis[p] += 1;
|
||||
if (o !== SANS_OBJECTIF && correspond[p * O + o] === 1) {
|
||||
servis[p] += 1;
|
||||
servisParTour[r] += 1;
|
||||
}
|
||||
for (let k = debutAnimes[ligne], fin = debutAnimes[ligne + 1]; k < fin; k += 1) {
|
||||
const f = animes[k];
|
||||
if (f === soi || rencontre[f] === p || tableDe[animateurs[f] * R + r] !== t) continue;
|
||||
|
|
@ -247,7 +269,18 @@ export function parcourirTours(instance, tableDe) {
|
|||
}
|
||||
parTour.push(tables);
|
||||
}
|
||||
return { parTour, parPaire, reunies, toursAssis, retours, servis, rencontresAnimateurs };
|
||||
return {
|
||||
parTour,
|
||||
parPaire,
|
||||
reunies,
|
||||
toursAssis,
|
||||
retours,
|
||||
servis,
|
||||
rencontresAnimateurs,
|
||||
servisParTour,
|
||||
possiblesParTour,
|
||||
assisSansCoupleParTour,
|
||||
};
|
||||
}
|
||||
|
||||
// Lecture des paires réunies (reunies de parcourirTours), leurs comptes dans
|
||||
|
|
|
|||
|
|
@ -733,6 +733,11 @@ describe('parcourirTours', () => {
|
|||
retours: [2, 1, 1, 1],
|
||||
servis: [0, 0, 0, 0],
|
||||
rencontresAnimateurs: [0, 0, 0, 0],
|
||||
// Sans objectif, aucun couple n'est possible : chaque assis du tour est
|
||||
// sans table possible, et 4, en réserve au tour 3, n'y compte pas.
|
||||
servisParTour: [0, 0, 0],
|
||||
possiblesParTour: [0, 0, 0],
|
||||
assisSansCoupleParTour: [4, 4, 3],
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -770,6 +775,9 @@ describe('parcourirTours', () => {
|
|||
retours: [],
|
||||
servis: [],
|
||||
rencontresAnimateurs: [],
|
||||
servisParTour: [0, 0],
|
||||
possiblesParTour: [0, 0],
|
||||
assisSansCoupleParTour: [0, 0],
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -1185,6 +1193,100 @@ function servisLus(configuration, tours) {
|
|||
});
|
||||
}
|
||||
|
||||
// Lecture directe des trois comptes par tour (§ 5.4, B4) sur un plan écrit
|
||||
// par identifiants, sans rien du module éprouvé. Le couple (p, r) est
|
||||
// possible quand la table fixée de p à ce tour lui correspond ou, p libre,
|
||||
// quand une table qui garde un siège libre à ce tour en porte un qui lui
|
||||
// correspond ; p est servi assis à une table qu'il peut occuper — sa table
|
||||
// fixée, ou, libre, une table qui garde un siège libre — et dont l'objectif
|
||||
// lui correspond. Par tour : Π_r, les couples possibles, réserve comprise ;
|
||||
// S_r, les servis ; σ_r, les assis dont le couple n'est pas possible. Une
|
||||
// personne absente des listes d'un tour y est en réserve.
|
||||
function parTourLus(configuration, tours) {
|
||||
const { participants, tables, reservations, objectifs = [] } = configuration;
|
||||
const couvre = (reservation, tour) => reservation.portee === 'tous' || reservation.tour === tour;
|
||||
const fixee = (id, tour) => reservations.find((res) => res.participant === id && couvre(res, tour))?.table;
|
||||
const libre = (table, tour) => reservations.filter((res) => res.table === table.id && couvre(res, tour)).length < table.capacite;
|
||||
const enVigueur = (table, tour) =>
|
||||
objectifs.find((o) => o.table === table && o.tour === tour) ?? objectifs.find((o) => o.table === table && o.tour === null);
|
||||
const correspond = (personne, table, tour) => {
|
||||
const o = enVigueur(table, tour);
|
||||
if (o === undefined) return false;
|
||||
const siennes = personne.etiquettes ?? [];
|
||||
return o.regle === 'ou' ? o.etiquettes.some((e) => siennes.includes(e)) : o.etiquettes.every((e) => siennes.includes(e));
|
||||
};
|
||||
const occupable = (personne, table, tour) => {
|
||||
const t = fixee(personne.id, tour);
|
||||
return t === undefined ? libre(table, tour) : t === table.id;
|
||||
};
|
||||
const possible = (personne, tour) => {
|
||||
const t = fixee(personne.id, tour);
|
||||
if (t !== undefined) return correspond(personne, t, tour);
|
||||
return tables.some((table) => libre(table, tour) && correspond(personne, table.id, tour));
|
||||
};
|
||||
const presents = participants.filter((personne) => !personne.exclu);
|
||||
const lus = { servisParTour: [], possiblesParTour: [], assisSansCoupleParTour: [] };
|
||||
tours.forEach((listes, r) => {
|
||||
const tour = r + 1;
|
||||
let servis = 0;
|
||||
let possibles = 0;
|
||||
let sansCouple = 0;
|
||||
for (const personne of presents) {
|
||||
const ouvert = possible(personne, tour);
|
||||
if (ouvert) possibles += 1;
|
||||
const i = listes.findIndex((occupants) => occupants.includes(personne.id));
|
||||
if (i === -1) continue;
|
||||
if (!ouvert) sansCouple += 1;
|
||||
const table = tables[i];
|
||||
if (occupable(personne, table, tour) && correspond(personne, table.id, tour)) servis += 1;
|
||||
}
|
||||
lus.servisParTour.push(servis);
|
||||
lus.possiblesParTour.push(possibles);
|
||||
lus.assisSansCoupleParTour.push(sansCouple);
|
||||
});
|
||||
return lus;
|
||||
}
|
||||
|
||||
// Les trois comptes par tour de parcourirTours, sur un plan écrit par
|
||||
// identifiants, réserve comprise.
|
||||
function parTourMesures(configuration, tours) {
|
||||
const instance = normaliser(configuration);
|
||||
const tables = configuration.tables.map(({ id }) => id);
|
||||
const assis = (r) => new Set(tours[r].flat());
|
||||
const reserves = tours.map((_, r) => configuration.participants.map(({ id }) => id).filter((id) => !assis(r).has(id)));
|
||||
const parcours = parcourirTours(instance, indexerPlan(instance, { tables, tours, reserves }));
|
||||
return {
|
||||
servisParTour: parcours.servisParTour,
|
||||
possiblesParTour: parcours.possiblesParTour,
|
||||
assisSansCoupleParTour: parcours.assisSansCoupleParTour,
|
||||
servis: parcours.servis,
|
||||
};
|
||||
}
|
||||
|
||||
describe('parcourirTours : les servis, les possibles et les assis sans table possible de chaque tour (§ 5.4, B4, D36)', () => {
|
||||
test('dix « finance » pour une table de huit : 10 possibles et 8 servis par tour, les six sans étiquette assis sans table possible', () => {
|
||||
const tours = tenus([9, 10], [8, 10], [7, 10], [6, 10]);
|
||||
const mesures = parTourMesures(FINANCE, tours);
|
||||
assert.deepEqual(mesures.servisParTour, [8, 8, 8, 8]);
|
||||
assert.deepEqual(mesures.possiblesParTour, [10, 10, 10, 10]);
|
||||
assert.deepEqual(mesures.assisSansCoupleParTour, [6, 6, 6, 6]);
|
||||
const { servisParTour, possiblesParTour, assisSansCoupleParTour } = mesures;
|
||||
assert.deepEqual({ servisParTour, possiblesParTour, assisSansCoupleParTour }, parTourLus(FINANCE, tours));
|
||||
});
|
||||
|
||||
test('une personne en réserve garde son couple possible, n’est ni servie ni assise sans table possible', () => {
|
||||
// Au tour 1, 1, « finance », et 11, sans étiquette, sont en réserve :
|
||||
// Π_1 garde 1 ; σ_1 perd 11 ; la table 1 sert huit « finance ».
|
||||
const tours = tenus([9, 10], [8, 10], [7, 10], [6, 10]);
|
||||
tours[0] = [suite(2, 9), [10, ...suite(12, 16)]];
|
||||
const { servisParTour, possiblesParTour, assisSansCoupleParTour } = parTourMesures(FINANCE, tours);
|
||||
assert.deepEqual(servisParTour, [8, 8, 8, 8]);
|
||||
assert.deepEqual(possiblesParTour, [10, 10, 10, 10]);
|
||||
assert.deepEqual(assisSansCoupleParTour, [5, 6, 6, 6]);
|
||||
assert.deepEqual({ servisParTour, possiblesParTour, assisSansCoupleParTour }, parTourLus(FINANCE, tours));
|
||||
});
|
||||
});
|
||||
|
||||
describe('mesurer : un objectif et des sièges libres qui changent d’un tour à l’autre (§ 4.5, A9, A21)', () => {
|
||||
// Tables 1, 2, 3 de trois places, trois tours. La table 1 porte « finance »
|
||||
// pour la soirée, « bénévole » au tour 2 ; la table 2, « bénévole » pour la
|
||||
|
|
@ -1241,4 +1343,23 @@ describe('mesurer : un objectif et des sièges libres qui changent d’un tour
|
|||
assert.deepEqual(s, [1, 2, 1, 2, 0, 0, 2, 1, 1]);
|
||||
assert.deepEqual(m, [0, 0, 1, 0, 2, 1, 0, 0, 0]);
|
||||
});
|
||||
|
||||
test('S_r, Π_r et σ_r égalent une lecture directe, et S_r somme s(p) : l’objectif du tour remplace celui de la soirée, la table gelée n’offre rien', () => {
|
||||
// Comptés à la main. Tour 1 : la table 2, gelée, n'offre rien ; 8 y est
|
||||
// fixé sous « bénévole » ; les « finance » libres ont les tables 1 et 3 :
|
||||
// Π_1 = 6, tous servis ; 3, 5 et 6 sans table possible. Tour 2 : 7, fixé
|
||||
// à la table 2 sous « bénévole », n'a pas de couple ; 3, 4 et 8 en ont
|
||||
// un ; 4 et 3 sont servis ; six assis sans table possible. Tour 3 : les
|
||||
// cinq « finance », 2 aussi sous « toutes » ; 7 et 2 servis.
|
||||
const mesures = parTourMesures(VARIABLE, TOURS_V);
|
||||
assert.deepEqual(mesures.servisParTour, [6, 2, 2]);
|
||||
assert.deepEqual(mesures.possiblesParTour, [6, 3, 5]);
|
||||
assert.deepEqual(mesures.assisSansCoupleParTour, [3, 6, 4]);
|
||||
const { servisParTour, possiblesParTour, assisSansCoupleParTour } = mesures;
|
||||
assert.deepEqual({ servisParTour, possiblesParTour, assisSansCoupleParTour }, parTourLus(VARIABLE, TOURS_V));
|
||||
const somme = (valeurs) => valeurs.reduce((total, valeur) => total + valeur, 0);
|
||||
assert.equal(somme(servisParTour), somme(mesures.servis));
|
||||
const instance = normaliser(VARIABLE);
|
||||
assert.equal(somme(possiblesParTour), somme(instance.possibles));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
612
src/moteur/qualite.js
Normal file
|
|
@ -0,0 +1,612 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Le détail d'un placement pour la page de qualité (§ 12, D27) : le profil
|
||||
// trié des rencontres et sa répartition, les bornes des tuiles, les barres
|
||||
// des collisions jointes à leurs planchers, le mélange des appartenances à
|
||||
// chaque table, les objectifs tour par tour, le profil des animateurs. Ces
|
||||
// mesures décrivent un placement à la fois ; aucune n'entre dans la
|
||||
// recherche, le classement ni le tableau des propositions, et rien de rendu
|
||||
// n'est un tableau typé.
|
||||
//
|
||||
// Le détail ne recompte rien (§ 13.2) : les comptes par paire, par table et
|
||||
// par tour sortent de parcourirTours (indicateurs.js), l'occupation de
|
||||
// occupations (plafond.js), les planchers du diagnostic (diagnostic.js), les
|
||||
// valeurs par personne des mesures et des plafonds que l'appelant passe.
|
||||
// Chaque pourcentage sort de pourcentage, la seule division d'un
|
||||
// pourcentage (D52), et se rend à côté de ses deux comptes.
|
||||
//
|
||||
// Une personne est désignée par son index canonique, son rang dans
|
||||
// instance.ids, et une table par son index dans l'instance : l'index suit
|
||||
// l'identifiant, si bien qu'un ordre par index croissant est l'ordre par
|
||||
// identifiant croissant. La traduction en identifiants revient à
|
||||
// l'application.
|
||||
import { STATUT, decrire } from './configuration.js';
|
||||
import { diversiteAnimateurs } from './diversite.js';
|
||||
import { parcourirTours } from './indicateurs.js';
|
||||
import { manques, profilManque } from './manque.js';
|
||||
import { matriceRencontres, pairesRepetees } from './paires.js';
|
||||
import { borneDiversite, occupations } from './plafond.js';
|
||||
|
||||
/**
|
||||
* @typedef {Object} LigneProfil
|
||||
* @property {number} p index canonique
|
||||
* @property {number} rencontres
|
||||
* @property {number} plafondRealise
|
||||
* @property {number|null} pourcent rencontres sur plafond réalisé ;
|
||||
* null à plafond nul
|
||||
* @property {number|null} plafondAPriori null quand il est inconnu
|
||||
* @property {number} appartenances A(p)
|
||||
* @property {number} affilies |F(p)|
|
||||
* @property {number} borneDiversite A_max(p)
|
||||
* @property {number} toursNonAssis tours en réserve
|
||||
* @property {'ancre'|'partiel'|'reserve'|'plafondNul'|null} marque
|
||||
*
|
||||
* @typedef {Object} TroisComptes
|
||||
* @property {number} sieges c_t − δ_{t,r}
|
||||
* @property {number} assis o_{t,r} − δ_{t,r}
|
||||
* @property {number} doublons α_{t,r} − δ_{t,r}
|
||||
*
|
||||
* @typedef {Object} CaseMelange
|
||||
* @property {number} occupants o_{t,r}
|
||||
* @property {number} affilies α_{t,r}
|
||||
* @property {number} appartenances δ_{t,r}
|
||||
* @property {TroisComptes} comptes
|
||||
* @property {{sieges: number, assis: number, doublons: number}} pourcents
|
||||
* δ sur c, sur o, sur α
|
||||
*/
|
||||
|
||||
// Les trois positions du sélecteur du mélange des tables (D35), dans
|
||||
// l'ordre où les objets du détail les portent.
|
||||
const POSITIONS = Object.freeze(['sieges', 'assis', 'doublons']);
|
||||
|
||||
// Le nom du statut d'une personne (§ 4.2), celui de l'interface.
|
||||
const NOM_DU_STATUT = Object.freeze(['mobile', 'partiel', 'ancre']);
|
||||
|
||||
// Vrai pour un entier positif ou nul.
|
||||
const estCompte = (valeur) => Number.isInteger(valeur) && valeur >= 0;
|
||||
|
||||
// Lève RangeError quand la liste n'a pas une case par personne de
|
||||
// l'instance : elle décrit une autre population.
|
||||
function exigerParPersonne(liste, N, nom) {
|
||||
if (liste.length !== N) throw new RangeError(`${nom} : ${liste.length} cases, ${N} attendues`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Le pourcentage de n sur d (§ 5.4, D52) : la partie entière de 100 · n / d,
|
||||
* portée à 1 quand 0 < n < d — jamais 0 % tant que le compte n'est pas nul ;
|
||||
* la partie entière ne rend jamais 100 sous d, si bien qu'aucun manque ne
|
||||
* s'écrit 100 %. 0 à numérateur nul, 100 au numérateur égal au dénominateur,
|
||||
* null quand d vaut 0 : la page écrit « — ». Au-delà du dénominateur, qu'un
|
||||
* calcul faux seul produit et que la garde refuse (§ 12.3), le quotient
|
||||
* entier, au-dessus de 100, que rien ne ramène dans les bornes.
|
||||
*
|
||||
* La division se fait sur des entiers : 100 · n moins son reste, divisé par
|
||||
* d, est exact.
|
||||
*
|
||||
* @param {number} n entier positif ou nul
|
||||
* @param {number} d entier positif ou nul
|
||||
* @returns {number|null}
|
||||
*/
|
||||
export function pourcentage(n, d) {
|
||||
if (!estCompte(n) || !estCompte(d)) {
|
||||
throw new RangeError(`pourcentage de ${decrire(n)} sur ${decrire(d)} : deux entiers positifs ou nuls attendus`);
|
||||
}
|
||||
if (d === 0) return null;
|
||||
const centuple = 100 * n;
|
||||
const quotient = (centuple - (centuple % d)) / d;
|
||||
return n > 0 && quotient === 0 ? 1 : quotient;
|
||||
}
|
||||
|
||||
// La marque d'un rang du profil (§ 12.3, B12) : la première qui s'applique,
|
||||
// ancré, partiellement fixé, en réserve à un tour au moins, plafond réalisé
|
||||
// nul ; null sinon.
|
||||
function marqueDe(statut, toursNonAssis, plafondRealise) {
|
||||
if (statut === STATUT.ANCRE) return 'ancre';
|
||||
if (statut === STATUT.PARTIELLEMENT_FIXE) return 'partiel';
|
||||
if (toursNonAssis > 0) return 'reserve';
|
||||
if (plafondRealise === 0) return 'plafondNul';
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Le profil trié des rencontres (§ 12.3, D28) : une ligne par personne,
|
||||
* rencontres croissantes, puis plafond réalisé décroissant — à rencontres
|
||||
* égales, le plus grand manque d'abord —, puis index croissant. Le
|
||||
* pourcentage de la ligne se lit dans cet ordre (D51) et n'en décide rien.
|
||||
*
|
||||
* Lève RangeError quand une liste n'a pas une case par personne.
|
||||
*
|
||||
* @param {import('./types.js').Instance} instance N, R et statut sont lus
|
||||
* @param {import('./indicateurs.js').Mesures} mesures
|
||||
* @param {ArrayLike<number>} plafondsRealises
|
||||
* @param {ArrayLike<number>|null} plafondsAPriori null quand il est inconnu
|
||||
* @param {ArrayLike<number>} aMax borneDiversite(instance, tableDe).aMax
|
||||
* @returns {LigneProfil[]}
|
||||
*/
|
||||
export function profilRencontres(instance, mesures, plafondsRealises, plafondsAPriori, aMax) {
|
||||
const { N, R, statut } = instance;
|
||||
exigerParPersonne(plafondsRealises, N, 'plafondsRealises');
|
||||
if (plafondsAPriori !== null) exigerParPersonne(plafondsAPriori, N, 'plafondsAPriori');
|
||||
exigerParPersonne(aMax, N, 'aMax');
|
||||
const { rencontres, appartenancesVues, affilies, toursAssis } = mesures;
|
||||
const lignes = [];
|
||||
for (let p = 0; p < N; p += 1) {
|
||||
const toursNonAssis = R - toursAssis[p];
|
||||
lignes.push({
|
||||
p,
|
||||
rencontres: rencontres[p],
|
||||
plafondRealise: plafondsRealises[p],
|
||||
pourcent: pourcentage(rencontres[p], plafondsRealises[p]),
|
||||
plafondAPriori: plafondsAPriori === null ? null : plafondsAPriori[p],
|
||||
appartenances: appartenancesVues[p],
|
||||
affilies: affilies[p],
|
||||
borneDiversite: aMax[p],
|
||||
toursNonAssis,
|
||||
marque: marqueDe(statut[p], toursNonAssis, plafondsRealises[p]),
|
||||
});
|
||||
}
|
||||
return lignes.sort((a, b) => a.rencontres - b.rencontres || b.plafondRealise - a.plafondRealise || a.p - b.p);
|
||||
}
|
||||
|
||||
/**
|
||||
* La répartition d'une valeur par personne (§ 12.3) : une ligne par valeur
|
||||
* présente, de la plus basse à la plus haute, avec son effectif, l'effectif
|
||||
* cumulé jusqu'à elle, et le nombre de personnes à leur plafond — valeurs[i]
|
||||
* égale à plafonds[i] —, null quand plafonds l'est.
|
||||
*
|
||||
* Lève RangeError quand plafonds n'a pas la longueur de valeurs.
|
||||
*
|
||||
* @param {ArrayLike<number>} valeurs
|
||||
* @param {ArrayLike<number>|null} [plafonds]
|
||||
* @returns {Array<{valeur: number, effectif: number, cumul: number, auPlafond: number|null}>}
|
||||
*/
|
||||
export function repartition(valeurs, plafonds = null) {
|
||||
if (plafonds !== null) exigerParPersonne(plafonds, valeurs.length, 'plafonds');
|
||||
const rangs = Array.from({ length: valeurs.length }, (_, i) => i);
|
||||
rangs.sort((i, j) => valeurs[i] - valeurs[j]);
|
||||
const lignes = [];
|
||||
for (const i of rangs) {
|
||||
let ligne = lignes[lignes.length - 1];
|
||||
if (ligne === undefined || ligne.valeur !== valeurs[i]) {
|
||||
ligne = { valeur: valeurs[i], effectif: 0, cumul: 0, auPlafond: plafonds === null ? null : 0 };
|
||||
lignes.push(ligne);
|
||||
}
|
||||
ligne.effectif += 1;
|
||||
if (plafonds !== null && valeurs[i] === plafonds[i]) ligne.auPlafond += 1;
|
||||
}
|
||||
let cumul = 0;
|
||||
for (const ligne of lignes) {
|
||||
cumul += ligne.effectif;
|
||||
ligne.cumul = cumul;
|
||||
}
|
||||
return lignes;
|
||||
}
|
||||
|
||||
/**
|
||||
* La phrase de synthèse du profil (§ 12.3, B12) : le minimum des rencontres,
|
||||
* l'effectif à ce minimum, et l'effectif sous son plafond réalisé, lus dans
|
||||
* le profil trié, dont le premier rang porte le minimum. Un profil vide rend
|
||||
* un minimum null.
|
||||
*
|
||||
* @param {LigneProfil[]} profil profilRencontres
|
||||
* @returns {{minimum: number|null, effectifAuMinimum: number, effectifSousPlafond: number}}
|
||||
*/
|
||||
export function synthese(profil) {
|
||||
const minimum = profil.length === 0 ? null : profil[0].rencontres;
|
||||
let effectifAuMinimum = 0;
|
||||
let effectifSousPlafond = 0;
|
||||
for (const { rencontres, plafondRealise } of profil) {
|
||||
if (rencontres === minimum) effectifAuMinimum += 1;
|
||||
if (rencontres < plafondRealise) effectifSousPlafond += 1;
|
||||
}
|
||||
return { minimum, effectifAuMinimum, effectifSousPlafond };
|
||||
}
|
||||
|
||||
// Le premier index p de 0..N − 1 où estCandidat(p) est vrai, selon avant(p,
|
||||
// q), vrai quand p précède q ; null sans candidat. Les index se parcourent
|
||||
// croissants : à égalité, le plus petit reste.
|
||||
function premier(N, estCandidat, avant) {
|
||||
let tete = null;
|
||||
for (let p = 0; p < N; p += 1) {
|
||||
if (estCandidat(p) && (tete === null || avant(p, tete))) tete = p;
|
||||
}
|
||||
return tete;
|
||||
}
|
||||
|
||||
/**
|
||||
* Les bornes des tuiles (§ 12.2, D29, D34, D52, B11), chacune celle d'une
|
||||
* personne nommée :
|
||||
* - rang1 : la personne au rang 1 du profil, ses rencontres, son plafond
|
||||
* réalisé, son plafond a priori et leur pourcentage ; null sans personne ;
|
||||
* - manque : la tête du profil de manque (profilManque, § 12.10.6) et son
|
||||
* plafond réalisé, sans pourcentage ; null sans personne ;
|
||||
* - diversite : au plus petit A(p), à égalité la plus grande borne de
|
||||
* diversité, puis l'index — le tri du profil sur A(p) —, la personne et
|
||||
* sa borne, sans pourcentage ; null sans appartenance présente ;
|
||||
* - animateurs : le rang 1 du profil des animateurs et sa borne B(p), sans
|
||||
* pourcentage ; null sans personne concernée ;
|
||||
* - objectifs : au plus petit s(p) des concernées, à égalité les tours
|
||||
* possibles les plus nombreux, puis l'index, la personne, ses servis, ses
|
||||
* possibles et leur pourcentage ; null sans personne concernée ;
|
||||
* - soiree : Σ S_r, Σ Π_r et leur pourcentage, lus dans les objectifs par
|
||||
* tour, et les personnes jamais servies de la mesure ; null sans personne
|
||||
* concernée.
|
||||
*
|
||||
* @param {import('./types.js').Instance} instance N, ids, indexDe et
|
||||
* groupes sont lus
|
||||
* @param {{mesures: import('./indicateurs.js').Mesures,
|
||||
* plafondsRealises: ArrayLike<number>, aMax: ArrayLike<number>,
|
||||
* profil: LigneProfil[], animateurs: Array<Object>|null,
|
||||
* objectifs: Object|null}} lus
|
||||
* profil, profilRencontres ; animateurs, profilAnimateurs ;
|
||||
* objectifs, objectifsParTour
|
||||
*/
|
||||
export function bornesDesTuiles(instance, { mesures, plafondsRealises, aMax, profil, animateurs, objectifs }) {
|
||||
const { N, indexDe, groupes } = instance;
|
||||
let rang1 = null;
|
||||
let manque = null;
|
||||
if (profil.length > 0) {
|
||||
const { p, rencontres, plafondRealise, plafondAPriori, pourcent } = profil[0];
|
||||
rang1 = { p, rencontres, plafondRealise, plafondAPriori, pourcent };
|
||||
const [tete] = profilManque(instance, manques(plafondsRealises, mesures.rencontres), plafondsRealises);
|
||||
const q = indexDe.get(tete.id);
|
||||
manque = { p: q, plafondRealise: plafondsRealises[q] };
|
||||
}
|
||||
|
||||
let diversite = null;
|
||||
if (groupes.length > 0) {
|
||||
const A = mesures.appartenancesVues;
|
||||
const p = premier(N, () => true, (x, y) => A[x] < A[y] || (A[x] === A[y] && aMax[x] > aMax[y]));
|
||||
diversite = { p, aMax: aMax[p] };
|
||||
}
|
||||
|
||||
let parObjectif = null;
|
||||
let soiree = null;
|
||||
if (mesures.objectifs !== null) {
|
||||
const { tours: s, possibles: P, jamaisServies } = mesures.objectifs;
|
||||
const p = premier(N, (x) => P[x] > 0, (x, y) => s[x] < s[y] || (s[x] === s[y] && P[x] > P[y]));
|
||||
parObjectif = { p, servis: s[p], possibles: P[p], pourcent: pourcentage(s[p], P[p]) };
|
||||
const { servis, possibles, pourcent } = objectifs.soiree;
|
||||
soiree = { servis, possibles, pourcent, jamaisServies };
|
||||
}
|
||||
|
||||
return {
|
||||
rang1,
|
||||
manque,
|
||||
diversite,
|
||||
animateurs: animateurs === null ? null : { p: animateurs[0].p, borne: animateurs[0].borne },
|
||||
objectifs: parObjectif,
|
||||
soiree,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Le plancher des collisions cumulées de chaque appartenance (§ 5.6), dans
|
||||
* l'ordre de instance.groupes, lu dans le diagnostic : 0 pour une
|
||||
* appartenance qu'il ne nomme pas, dont le plancher est nul. La forme que
|
||||
* barresCollisions et la garde (verifierIndicateurs) reçoivent.
|
||||
*
|
||||
* Lève RangeError quand le diagnostic nomme une appartenance absente de
|
||||
* l'instance : il décrit une autre configuration.
|
||||
*
|
||||
* @param {import('./types.js').Instance} instance groupes est lu
|
||||
* @param {Array<{groupe: string, plancherCumulees: number}>} collisions
|
||||
* diagnostiquer(configuration).collisions
|
||||
* @returns {number[]}
|
||||
*/
|
||||
export function planchersParGroupe({ groupes }, collisions) {
|
||||
const rang = new Map(groupes.map((libelle, g) => [libelle, g]));
|
||||
const planchers = new Array(groupes.length).fill(0);
|
||||
for (const { groupe, plancherCumulees } of collisions) {
|
||||
const g = rang.get(groupe);
|
||||
if (g === undefined) throw new RangeError(`plancher d'une appartenance absente : ${decrire(groupe)}`);
|
||||
planchers[g] = plancherCumulees;
|
||||
}
|
||||
return planchers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Les barres des collisions d'appartenance (§ 12.4, D31, B13) : une ligne
|
||||
* par appartenance, ses collisions cumulées, paires distinctes et excédent
|
||||
* lus dans mesures.parGroupe, et, sur le domaine des planchers, ses deux
|
||||
* segments : prouvé inévitable, le plancher du diagnostic, et le reste,
|
||||
* cumulées moins ce plancher. Hors du domaine — un plan qui laisse
|
||||
* quelqu'un en réserve ou ne tient pas une réservation —, prouve et reste
|
||||
* valent null. Les lignes vont de l'excédent le plus grand au plus petit,
|
||||
* puis des collisions cumulées décroissantes, puis par la clé normalisée de
|
||||
* l'appartenance, puis par sa chaîne, comparées par unités de code.
|
||||
*
|
||||
* Lève RangeError quand planchers n'a pas une case par appartenance.
|
||||
*
|
||||
* @param {import('./types.js').Instance} instance groupes est lu
|
||||
* @param {import('./indicateurs.js').Mesures} mesures
|
||||
* @param {ArrayLike<number>} planchers planchersParGroupe
|
||||
* @param {boolean} planchersValides
|
||||
* @param {(libelle: string) => string} cle la clé normalisée (§ 10.1),
|
||||
* que l'appelant fournit : le moteur n'importe pas le CSV
|
||||
* @returns {Array<{g: number, groupe: string, effectif: number, cumulees: number,
|
||||
* prouve: number|null, reste: number|null, pairesDistinctes: number,
|
||||
* excedent: number}>}
|
||||
*/
|
||||
export function barresCollisions(instance, mesures, planchers, planchersValides, cle) {
|
||||
const G = instance.groupes.length;
|
||||
if (planchers.length !== G) throw new RangeError(`planchers : ${planchers.length} cases, ${G} attendues`);
|
||||
const lignes = mesures.parGroupe.map(({ groupe, effectif, collisionsCumulees, pairesDistinctes, excedent }, g) => {
|
||||
const prouve = planchersValides ? planchers[g] : null;
|
||||
return {
|
||||
g,
|
||||
groupe,
|
||||
effectif,
|
||||
cumulees: collisionsCumulees,
|
||||
prouve,
|
||||
reste: prouve === null ? null : collisionsCumulees - prouve,
|
||||
pairesDistinctes,
|
||||
excedent,
|
||||
};
|
||||
});
|
||||
const cles = lignes.map(({ groupe }) => cle(groupe));
|
||||
const comparer = (x, y) => (x < y ? -1 : x > y ? 1 : 0);
|
||||
return lignes.sort(
|
||||
(a, b) => b.excedent - a.excedent || b.cumulees - a.cumulees || comparer(cles[a.g], cles[b.g]) || comparer(a.groupe, b.groupe),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Le mélange des appartenances à chaque table (§ 12.11, D35, B20). Une
|
||||
* ligne par table, dans l'ordre de l'instance, et par tour une case : ses
|
||||
* occupants o, ses affiliés α, ses appartenances distinctes δ, les trois
|
||||
* comptes — sièges c − δ, assis o − δ, doublons α − δ, emboîtés — et leurs
|
||||
* trois pourcentages, δ sur c, sur o, sur α. La case vaut null quand α est
|
||||
* nul, table vide comprise.
|
||||
*
|
||||
* Les doublons se lisent dans les collègues de parcourirTours : chaque
|
||||
* affilié assis après un premier membre de son appartenance, dans l'ordre
|
||||
* des index, est le second terme d'une paire de collègues, si bien que les
|
||||
* seconds termes distincts sont α − δ, et un doublon est non nul exactement
|
||||
* là où la table compte une collision ce tour-là. Une personne sans
|
||||
* appartenance n'en forme aucune.
|
||||
*
|
||||
* Par position, pire et somme sont le plus grand compte et la somme des
|
||||
* comptes des cases non null de la ligne ; ils valent null, tous deux,
|
||||
* quand chaque case l'est. ordres porte, par position, les index de lignes
|
||||
* de la pire à la meilleure : pire décroissant, puis somme décroissante,
|
||||
* puis l'ordre des tables, les lignes à pire null en dernier.
|
||||
*
|
||||
* Lève TypeError quand l'occupation ne porte pas les affiliés.
|
||||
*
|
||||
* @param {import('./types.js').Instance} instance
|
||||
* @param {ArrayLike<number>} tableDe
|
||||
* @param {{occupants: number[], affilies: number[]|null}} occupation
|
||||
* occupations(instance, tableDe, {affilies: true})
|
||||
* @param {ReturnType<typeof parcourirTours>} [parcours] le même plan, déjà lu
|
||||
* @returns {{lignes: Array<{t: number, capacite: number, tours: Array<CaseMelange|null>,
|
||||
* pire: TroisComptes|null, somme: TroisComptes|null}>,
|
||||
* ordres: {sieges: number[], assis: number[], doublons: number[]}}}
|
||||
*/
|
||||
export function mixTables(instance, tableDe, occupation, parcours = parcourirTours(instance, tableDe)) {
|
||||
const { N, T, R, capacite } = instance;
|
||||
if (occupation.affilies === null) throw new TypeError('occupation sans affiliés : occupations(instance, tableDe, {affilies: true}) attendue');
|
||||
const { occupants, affilies } = occupation;
|
||||
// vu[b] vaut le jeton de la case où b est déjà compté comme second terme.
|
||||
const vu = new Int32Array(N).fill(-1);
|
||||
const lignes = [];
|
||||
for (let t = 0; t < T; t += 1) {
|
||||
const c = capacite[t];
|
||||
const tours = [];
|
||||
let pire = null;
|
||||
let somme = null;
|
||||
for (let r = 0; r < R; r += 1) {
|
||||
const o = occupants[t * R + r];
|
||||
const a = affilies[t * R + r];
|
||||
if (a === 0) {
|
||||
tours.push(null);
|
||||
continue;
|
||||
}
|
||||
const jeton = t * R + r;
|
||||
let doublons = 0;
|
||||
for (const [, b] of parcours.parTour[r][t].collegues) {
|
||||
if (vu[b] === jeton) continue;
|
||||
vu[b] = jeton;
|
||||
doublons += 1;
|
||||
}
|
||||
const d = a - doublons;
|
||||
const comptes = { sieges: c - d, assis: o - d, doublons };
|
||||
tours.push({
|
||||
occupants: o,
|
||||
affilies: a,
|
||||
appartenances: d,
|
||||
comptes,
|
||||
pourcents: { sieges: pourcentage(d, c), assis: pourcentage(d, o), doublons: pourcentage(d, a) },
|
||||
});
|
||||
if (pire === null) {
|
||||
pire = { ...comptes };
|
||||
somme = { ...comptes };
|
||||
continue;
|
||||
}
|
||||
for (const position of POSITIONS) {
|
||||
pire[position] = Math.max(pire[position], comptes[position]);
|
||||
somme[position] += comptes[position];
|
||||
}
|
||||
}
|
||||
lignes.push({ t, capacite: c, tours, pire, somme });
|
||||
}
|
||||
const ordre = (position) => {
|
||||
const rangs = Array.from({ length: T }, (_, i) => i);
|
||||
return rangs.sort((i, j) => {
|
||||
const x = lignes[i];
|
||||
const y = lignes[j];
|
||||
if (x.pire === null || y.pire === null) return (x.pire === null) - (y.pire === null) || i - j;
|
||||
return y.pire[position] - x.pire[position] || y.somme[position] - x.somme[position] || i - j;
|
||||
});
|
||||
};
|
||||
return { lignes, ordres: { sieges: ordre('sieges'), assis: ordre('assis'), doublons: ordre('doublons') } };
|
||||
}
|
||||
|
||||
/**
|
||||
* Les objectifs des tables tour par tour (§ 12.12, D36, B21), lus dans
|
||||
* parcourirTours et dans les planchers par tour du diagnostic, sans recompte.
|
||||
* Par tour : possibles, Π_r ; servis, S_r ; leur pourcentage ; hors, Π_r −
|
||||
* S_r ; plancher, le plancher du tour ; sansCouple, σ_r, les assis sans table
|
||||
* possible. Un tour sans couple possible a possibles 0, servis, pourcent,
|
||||
* hors et plancher null, et garde sansCouple.
|
||||
*
|
||||
* pireTour : l'index du tour au plus grand reste, hors − plancher, le
|
||||
* premier à égalité. soiree : les sommes des tours, Σ S_r sur Σ Π_r, la seule
|
||||
* somme d'un pourcentage admise (D52), et la somme des planchers.
|
||||
* jamaisServies : les concernées dont s(p) est nul, avec leurs tours
|
||||
* possibles, les plus nombreux d'abord, puis par index. repartition :
|
||||
* P(p) − s(p) sur les concernées, de la plus grande valeur à la plus petite.
|
||||
*
|
||||
* Rend null sans personne concernée. Lève RangeError quand planchers n'a pas
|
||||
* une case par tour.
|
||||
*
|
||||
* @param {import('./types.js').Instance} instance N, R et possibles sont lus
|
||||
* @param {ReturnType<typeof parcourirTours>} parcours
|
||||
* @param {ArrayLike<number>} planchers
|
||||
* diagnostiquer(configuration).objectifsInevitables.parTour
|
||||
*/
|
||||
export function objectifsParTour(instance, parcours, planchers) {
|
||||
const { N, R, possibles: P } = instance;
|
||||
if (planchers.length !== R) throw new RangeError(`planchers : ${planchers.length} cases, ${R} attendues`);
|
||||
const { servisParTour, possiblesParTour, assisSansCoupleParTour, servis: s } = parcours;
|
||||
const soiree = { possibles: 0, servis: 0, pourcent: null, hors: 0, plancher: 0 };
|
||||
const parTour = [];
|
||||
let pireTour = null;
|
||||
let pireReste = 0;
|
||||
for (let r = 0; r < R; r += 1) {
|
||||
const possibles = possiblesParTour[r];
|
||||
const sansCouple = assisSansCoupleParTour[r];
|
||||
if (possibles === 0) {
|
||||
parTour.push({ possibles, servis: null, pourcent: null, hors: null, plancher: null, sansCouple });
|
||||
continue;
|
||||
}
|
||||
const servis = servisParTour[r];
|
||||
const hors = possibles - servis;
|
||||
const plancher = planchers[r];
|
||||
parTour.push({ possibles, servis, pourcent: pourcentage(servis, possibles), hors, plancher, sansCouple });
|
||||
soiree.possibles += possibles;
|
||||
soiree.servis += servis;
|
||||
soiree.hors += hors;
|
||||
soiree.plancher += plancher;
|
||||
if (pireTour === null || hors - plancher > pireReste) {
|
||||
pireTour = r;
|
||||
pireReste = hors - plancher;
|
||||
}
|
||||
}
|
||||
if (soiree.possibles === 0) return null;
|
||||
soiree.pourcent = pourcentage(soiree.servis, soiree.possibles);
|
||||
|
||||
const jamaisServies = [];
|
||||
const horsParPersonne = [];
|
||||
for (let p = 0; p < N; p += 1) {
|
||||
if (P[p] === 0) continue;
|
||||
if (s[p] === 0) jamaisServies.push({ p, possibles: P[p] });
|
||||
horsParPersonne.push(P[p] - s[p]);
|
||||
}
|
||||
jamaisServies.sort((a, b) => b.possibles - a.possibles || a.p - b.p);
|
||||
const lignes = repartition(horsParPersonne).reverse();
|
||||
return {
|
||||
parTour,
|
||||
pireTour,
|
||||
soiree,
|
||||
jamaisServies,
|
||||
repartition: lignes.map(({ valeur, effectif }) => ({ valeur, effectif })),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Le profil des animateurs rencontrés (§ 12.13, D37) : une ligne par
|
||||
* personne concernée, B(p) > 0, avec m(p), sa borne B(p) et son statut ; m(p)
|
||||
* croissant, puis la borne décroissante, puis l'index. Ni manque ni
|
||||
* pourcentage : la borne ne se garantit pas (§ 4.5). Rend null sans personne
|
||||
* concernée.
|
||||
*
|
||||
* @param {import('./types.js').Instance} instance N et statut sont lus
|
||||
* @param {import('./indicateurs.js').Mesures} mesures
|
||||
* @returns {Array<{p: number, rencontres: number, borne: number,
|
||||
* statut: 'mobile'|'partiel'|'ancre'}>|null}
|
||||
*/
|
||||
export function profilAnimateurs({ N, statut }, mesures) {
|
||||
if (mesures.animateurs === null) return null;
|
||||
const { rencontres, bornes } = mesures.animateurs;
|
||||
const lignes = [];
|
||||
for (let p = 0; p < N; p += 1) {
|
||||
if (bornes[p] === 0) continue;
|
||||
lignes.push({ p, rencontres: rencontres[p], borne: bornes[p], statut: NOM_DU_STATUT[statut[p]] });
|
||||
}
|
||||
return lignes.sort((a, b) => a.rencontres - b.rencontres || b.borne - a.borne || a.p - b.p);
|
||||
}
|
||||
|
||||
/**
|
||||
* Le détail d'un placement (§ 12.9, D27), pour lui seul : parcourirTours et
|
||||
* occupations se lisent une fois chacun, et chaque partie en dérive.
|
||||
*
|
||||
* - profil, synthese, repartitionRencontres, bornes : profilRencontres et ce
|
||||
* qu'on en tire ;
|
||||
* - collisions : {planchersValides, lignes}, les barres, les planchers lus
|
||||
* dans diagnostic.collisions ;
|
||||
* - tables : mixTables ; paires : pairesRepetees ;
|
||||
* - matrice : {ordre, comptes}, matriceRencontres dans ordreMatrice, une
|
||||
* copie de la liste reçue ; null quand ordreMatrice l'est ;
|
||||
* - objectifs : objectifsParTour sur diagnostic.objectifsInevitables.parTour ;
|
||||
* - animateurs : {profil, repartition, diversite}, le profil des animateurs,
|
||||
* la répartition de m(p) sur ses lignes, la diversité par animateur ;
|
||||
* profil et repartition null sans personne concernée ; null en entier sans
|
||||
* personne concernée ni animateur.
|
||||
*
|
||||
* aMax, la borne de diversité, se calcule quand l'appelant ne la passe pas ;
|
||||
* l'appelant qui la tient déjà pour la garde la passe. Ne rend que des
|
||||
* listes et des objets simples ; ne modifie ni l'instance ni le plan ; lève
|
||||
* ce que lèvent les fonctions qu'il appelle.
|
||||
*
|
||||
* @param {import('./types.js').Instance} instance
|
||||
* @param {ArrayLike<number>} tableDe
|
||||
* @param {{diagnostic: import('./diagnostic.js').Diagnostic,
|
||||
* mesures: import('./indicateurs.js').Mesures,
|
||||
* plafondsRealises: ArrayLike<number>,
|
||||
* plafondsAPriori: ArrayLike<number>|null,
|
||||
* planchersValides: boolean,
|
||||
* cle: (libelle: string) => string,
|
||||
* ordreMatrice?: number[]|null,
|
||||
* aMax?: ArrayLike<number>}} options
|
||||
*/
|
||||
export function detailler(instance, tableDe, options) {
|
||||
const { diagnostic, mesures, plafondsRealises, plafondsAPriori, planchersValides, cle, ordreMatrice = null } = options;
|
||||
const aMax = options.aMax ?? borneDiversite(instance, tableDe).aMax;
|
||||
const parcours = parcourirTours(instance, tableDe);
|
||||
const occupation = occupations(instance, tableDe, { affilies: true });
|
||||
|
||||
const profil = profilRencontres(instance, mesures, plafondsRealises, plafondsAPriori, aMax);
|
||||
const objectifs = objectifsParTour(instance, parcours, diagnostic.objectifsInevitables.parTour);
|
||||
const profilDesAnimateurs = profilAnimateurs(instance, mesures);
|
||||
const diversite = diversiteAnimateurs(instance, tableDe);
|
||||
const planchers = planchersParGroupe(instance, diagnostic.collisions);
|
||||
|
||||
return {
|
||||
profil,
|
||||
synthese: synthese(profil),
|
||||
repartitionRencontres: repartition(mesures.rencontres, plafondsRealises),
|
||||
bornes: bornesDesTuiles(instance, {
|
||||
mesures,
|
||||
plafondsRealises,
|
||||
aMax,
|
||||
profil,
|
||||
animateurs: profilDesAnimateurs,
|
||||
objectifs,
|
||||
}),
|
||||
collisions: { planchersValides, lignes: barresCollisions(instance, mesures, planchers, planchersValides, cle) },
|
||||
tables: mixTables(instance, tableDe, occupation, parcours),
|
||||
paires: pairesRepetees(instance, tableDe, parcours),
|
||||
matrice: ordreMatrice === null ? null : { ordre: [...ordreMatrice], comptes: matriceRencontres(instance, tableDe, ordreMatrice, parcours) },
|
||||
objectifs,
|
||||
animateurs:
|
||||
profilDesAnimateurs === null && diversite.lignes.length === 0
|
||||
? null
|
||||
: {
|
||||
profil: profilDesAnimateurs,
|
||||
repartition: profilDesAnimateurs === null ? null : repartition(profilDesAnimateurs.map(({ rencontres }) => rencontres)),
|
||||
diversite,
|
||||
},
|
||||
};
|
||||
}
|
||||
967
src/moteur/qualite.test.js
Normal file
|
|
@ -0,0 +1,967 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Épreuves du détail d'un placement (§ 12, D27 à D37, D51, D52) : le
|
||||
// pourcentage et ses bords, le profil trié et ses marques, la répartition,
|
||||
// la synthèse, les bornes des tuiles, les barres des collisions, le mélange
|
||||
// des appartenances à chaque table et ses trois ordres, les objectifs tour
|
||||
// par tour, le profil des animateurs, et detailler qui les réunit. Chaque
|
||||
// configuration est inventée et petite ; ses valeurs sont comptées à la main
|
||||
// dans le commentaire qui la précède, et une lecture directe du plan, qui
|
||||
// n'emprunte rien au module éprouvé, recoupe les comptes par table et par
|
||||
// tour. Identifiants et index diffèrent quand l'ordre en dépend.
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { describe, test } from '../../test/lanceur.js';
|
||||
import { CATALOGUE, PLAN_PARFAIT_PETITE } from '../demo/catalogue.js';
|
||||
import { LIBRE, indexerPlan, normaliser } from './configuration.js';
|
||||
import { diagnostiquer } from './diagnostic.js';
|
||||
import { diversiteAnimateurs } from './diversite.js';
|
||||
import { mesurer, parcourirTours } from './indicateurs.js';
|
||||
import { matriceRencontres, pairesRepetees } from './paires.js';
|
||||
import { borneDiversite, occupations, plafondsAPriori, plafondsRealises } from './plafond.js';
|
||||
import {
|
||||
barresCollisions,
|
||||
bornesDesTuiles,
|
||||
detailler,
|
||||
mixTables,
|
||||
objectifsParTour,
|
||||
planchersParGroupe,
|
||||
pourcentage,
|
||||
profilAnimateurs,
|
||||
profilRencontres,
|
||||
repartition,
|
||||
synthese,
|
||||
} from './qualite.js';
|
||||
|
||||
const AUBIER = 'Coopérative Aubier';
|
||||
const RESSAC = 'Atelier Ressac';
|
||||
|
||||
const SANS_CONTRAINTE = {
|
||||
separerAppartenances: false,
|
||||
nouveauxVoisins: false,
|
||||
nouvelleTable: false,
|
||||
varierAppartenances: false,
|
||||
};
|
||||
|
||||
// La clé normalisée d'une appartenance, telle que l'appelant la passe : le
|
||||
// moteur n'importe pas le CSV, et l'épreuve n'en a pas besoin.
|
||||
const cle = (libelle) => libelle.toLowerCase();
|
||||
|
||||
const tous = (participant, table) => ({ participant, table, portee: 'tous' });
|
||||
const auTour = (participant, table, tour) => ({ participant, table, portee: 'tour', tour });
|
||||
|
||||
// Participant présent : [id, appartenance, étiquettes, tours animés], les
|
||||
// trois derniers facultatifs.
|
||||
const participant = ([id, appartenance = null, etiquettes, anime]) => ({
|
||||
id,
|
||||
nom: `P${id}`,
|
||||
appartenance,
|
||||
...(etiquettes === undefined ? {} : { etiquettes }),
|
||||
...(anime === undefined ? {} : { anime }),
|
||||
});
|
||||
|
||||
// Configuration, instance et plan indexé. tables : [id, capacité], dans
|
||||
// l'ordre de la configuration ; plan[r] associe à un identifiant de table
|
||||
// les identifiants qui s'y assoient au tour r + 1 ; un présent absent d'un
|
||||
// tour est en réserve.
|
||||
function preparer({ personnes, tables, plan, reservations = [], objectifs = [], contraintes = SANS_CONTRAINTE }) {
|
||||
const configuration = {
|
||||
participants: personnes.map(participant),
|
||||
tables: tables.map(([id, capacite], i) => ({ id, numero: i + 1, capacite })),
|
||||
tours: plan.length,
|
||||
reservations,
|
||||
contraintes,
|
||||
...(objectifs.length === 0 ? {} : { objectifs }),
|
||||
};
|
||||
const instance = normaliser(configuration);
|
||||
const ids = configuration.tables.map(({ id }) => id);
|
||||
const tours = plan.map((tour) => ids.map((id) => tour[id] ?? []));
|
||||
const reserves = tours.map((listes) => {
|
||||
const assis = new Set(listes.flat());
|
||||
return instance.ids.filter((id) => !assis.has(id));
|
||||
});
|
||||
const tableDe = indexerPlan(instance, { tables: ids, tours, reserves });
|
||||
return { configuration, instance, tableDe, tours };
|
||||
}
|
||||
|
||||
// Les mesures d'un plan préparé, ses plafonds et sa borne de diversité.
|
||||
function mesurerTout(prepare) {
|
||||
const { instance, tableDe } = prepare;
|
||||
return {
|
||||
mesures: mesurer(instance, tableDe),
|
||||
realises: plafondsRealises(instance, tableDe),
|
||||
aPriori: plafondsAPriori(instance),
|
||||
aMax: borneDiversite(instance, tableDe).aMax,
|
||||
};
|
||||
}
|
||||
|
||||
const index = (instance, id) => instance.indexDe.get(id);
|
||||
const idsDe = (instance, lignes) => lignes.map(({ p }) => instance.ids[p]);
|
||||
const somme = (valeurs) => valeurs.reduce((total, valeur) => total + valeur, 0);
|
||||
|
||||
describe('pourcentage (D52, § 5.4)', () => {
|
||||
test('les bords : 0 à numérateur nul, 100 au numérateur égal, null à dénominateur nul', () => {
|
||||
assert.equal(pourcentage(0, 27), 0);
|
||||
assert.equal(pourcentage(27, 27), 100);
|
||||
assert.equal(pourcentage(5, 0), null);
|
||||
assert.equal(pourcentage(0, 0), null);
|
||||
});
|
||||
|
||||
test('entre les bords, la partie entière, jamais 0 ni 100 : 259 sur 260 donne 99, 1 sur 300 donne 1', () => {
|
||||
assert.equal(pourcentage(259, 260), 99);
|
||||
assert.equal(pourcentage(1, 300), 1);
|
||||
assert.equal(pourcentage(24, 27), 88);
|
||||
assert.equal(pourcentage(2, 3), 66);
|
||||
assert.equal(pourcentage(1, 2), 50);
|
||||
assert.equal(pourcentage(5, 8), 62);
|
||||
// Le centième exact n'est pas arrondi à la baisse d'un cran.
|
||||
assert.equal(pourcentage(29, 100), 29);
|
||||
assert.equal(pourcentage(57, 100), 57);
|
||||
});
|
||||
|
||||
test('au-delà du dénominateur, que la garde refuse, le quotient entier : rien ne le ramène à 100', () => {
|
||||
assert.equal(pourcentage(28, 27), 103);
|
||||
});
|
||||
|
||||
test('lève RangeError sur un compte qui n’est pas un entier positif ou nul', () => {
|
||||
for (const [n, d] of [[-1, 4], [1.5, 4], [1, -4], [1, 2.5], [Number.NaN, 4], ['3', 4], [1, null]]) {
|
||||
assert.throws(() => pourcentage(n, d), RangeError, `${String(n)} sur ${String(d)}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Six personnes, deux tables de trois, deux tours ; 1 en réserve au tour 2.
|
||||
// Rencontres et plafonds réalisés, comptés à la main (aucun ancré, n_p = 5) :
|
||||
// 1 : {2, 3}, 2 sur 2 ; 2 : {1, 3, 4, 5}, 4 sur 4 ; 3 : {1, 2, 6}, 3 sur 3 ;
|
||||
// 4 : {5, 6, 2}, 3 sur 4 ; 5 : {4, 6, 2}, 3 sur 4 ; 6 : {4, 5, 3}, 3 sur 3.
|
||||
// À trois rencontres, 4 et 5, au plafond le plus haut, passent devant 3 et
|
||||
// 6 ; trié sur le pourcentage, 4 et 5, à 75, viendraient en tête.
|
||||
// Appartenances : 1, 3 Aubier ; 2, 4 Ressac ; 5, 6 sans. A(p) vaut 1 pour 4
|
||||
// et 5 seulement ; A_max(4) = min(Φ_aff = 1, G = 2, 4 − 2 + 1) = 1,
|
||||
// A_max(5) = min(Φ_aff = 3, 2) = 2.
|
||||
const PROFIL = {
|
||||
personnes: [[1, AUBIER], [2, RESSAC], [3, AUBIER], [4, RESSAC], [5], [6]],
|
||||
tables: [[10, 3], [20, 3]],
|
||||
plan: [
|
||||
{ 10: [1, 2, 3], 20: [4, 5, 6] },
|
||||
{ 10: [2, 4, 5], 20: [3, 6] },
|
||||
],
|
||||
};
|
||||
|
||||
describe('profilRencontres (§ 12.3, D28, D51)', () => {
|
||||
test('rencontres croissantes, puis plafond réalisé décroissant, puis identifiant : jamais le pourcentage', () => {
|
||||
const prepare = preparer(PROFIL);
|
||||
const { instance } = prepare;
|
||||
const { mesures, realises, aPriori, aMax } = mesurerTout(prepare);
|
||||
const profil = profilRencontres(instance, mesures, realises, aPriori, aMax);
|
||||
assert.deepEqual(idsDe(instance, profil), [1, 4, 5, 3, 6, 2]);
|
||||
assert.deepEqual(profil.map(({ rencontres, plafondRealise }) => [rencontres, plafondRealise]), [[2, 2], [3, 4], [3, 4], [3, 3], [3, 3], [4, 4]]);
|
||||
assert.deepEqual(profil.map(({ pourcent }) => pourcent), [100, 75, 75, 100, 100, 100]);
|
||||
});
|
||||
|
||||
test('chaque ligne porte ce que les mesures donnent pour sa personne, lu sans recompte', () => {
|
||||
const prepare = preparer(PROFIL);
|
||||
const { instance } = prepare;
|
||||
const { mesures, realises, aPriori, aMax } = mesurerTout(prepare);
|
||||
for (const ligne of profilRencontres(instance, mesures, realises, aPriori, aMax)) {
|
||||
const { p } = ligne;
|
||||
assert.deepEqual(ligne, {
|
||||
p,
|
||||
rencontres: mesures.rencontres[p],
|
||||
plafondRealise: realises[p],
|
||||
pourcent: pourcentage(mesures.rencontres[p], realises[p]),
|
||||
plafondAPriori: aPriori[p],
|
||||
appartenances: mesures.appartenancesVues[p],
|
||||
affilies: mesures.affilies[p],
|
||||
borneDiversite: aMax[p],
|
||||
toursNonAssis: 2 - mesures.toursAssis[p],
|
||||
marque: p === index(instance, 1) ? 'reserve' : null,
|
||||
});
|
||||
}
|
||||
// A(p) et A_max, comptés à la main.
|
||||
assert.deepEqual(mesures.appartenancesVues, [2, 2, 2, 1, 1, 2]);
|
||||
assert.deepEqual([aMax[3], aMax[4]], [1, 2]);
|
||||
});
|
||||
|
||||
test('plafond a priori inconnu : null sur chaque ligne', () => {
|
||||
const prepare = preparer(PROFIL);
|
||||
const { mesures, realises, aMax } = mesurerTout(prepare);
|
||||
const profil = profilRencontres(prepare.instance, mesures, realises, null, aMax);
|
||||
assert.ok(profil.every(({ plafondAPriori }) => plafondAPriori === null));
|
||||
});
|
||||
|
||||
// Tables 10, 20, 30, 40 de deux places, deux tours. 1 ancré à la table
|
||||
// 10 ; 2 réservé à la table 20 au tour 1 seulement, en réserve au tour 2 ;
|
||||
// 5 en réserve au tour 1 ; 6 seul à la table 30 aux deux tours, plafond
|
||||
// réalisé nul ; 7 seul à la table 40 au tour 1 et en réserve au tour 2,
|
||||
// plafond réalisé nul aussi : la réserve passe avant le plafond nul ; 3 et
|
||||
// 4 assis aux deux tours, chacun à son plafond.
|
||||
test('les marques, la première qui s’applique : ancré, partiellement fixé, en réserve, plafond nul ; le pourcentage null à plafond nul', () => {
|
||||
const prepare = preparer({
|
||||
personnes: [[1], [2], [3], [4], [5], [6], [7]],
|
||||
tables: [[10, 2], [20, 2], [30, 2], [40, 2]],
|
||||
plan: [
|
||||
{ 10: [1, 3], 20: [2, 4], 30: [6], 40: [7] },
|
||||
{ 10: [1, 5], 20: [3, 4], 30: [6] },
|
||||
],
|
||||
reservations: [tous(1, 10), auTour(2, 20, 1)],
|
||||
});
|
||||
const { instance } = prepare;
|
||||
const { mesures, realises, aPriori, aMax } = mesurerTout(prepare);
|
||||
const profil = profilRencontres(instance, mesures, realises, aPriori, aMax);
|
||||
const deId = (id) => profil.find(({ p }) => p === index(instance, id));
|
||||
assert.deepEqual([1, 2, 3, 4, 5, 6, 7].map((id) => deId(id).marque), ['ancre', 'partiel', null, null, 'reserve', 'plafondNul', 'reserve']);
|
||||
assert.equal(deId(2).toursNonAssis, 1);
|
||||
assert.deepEqual([deId(7).toursNonAssis, deId(7).plafondRealise], [1, 0]);
|
||||
assert.deepEqual([deId(6).plafondRealise, deId(6).pourcent], [0, null]);
|
||||
assert.deepEqual(idsDe(instance, profil).slice(0, 1), [6]);
|
||||
});
|
||||
|
||||
test('lève RangeError sur une liste qui n’a pas une case par personne', () => {
|
||||
const prepare = preparer(PROFIL);
|
||||
const { mesures, realises, aPriori, aMax } = mesurerTout(prepare);
|
||||
const { instance } = prepare;
|
||||
assert.throws(() => profilRencontres(instance, mesures, realises.slice(1), aPriori, aMax), RangeError);
|
||||
assert.throws(() => profilRencontres(instance, mesures, realises, aPriori.slice(1), aMax), RangeError);
|
||||
assert.throws(() => profilRencontres(instance, mesures, realises, aPriori, aMax.slice(1)), RangeError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('repartition et synthese (§ 12.3)', () => {
|
||||
test('une ligne par valeur présente, croissante, avec l’effectif, le cumul et les personnes à leur plafond, contre une lecture directe', () => {
|
||||
const prepare = preparer(PROFIL);
|
||||
const { mesures, realises } = mesurerTout(prepare);
|
||||
const lignes = repartition(mesures.rencontres, realises);
|
||||
assert.deepEqual(lignes, [
|
||||
{ valeur: 2, effectif: 1, cumul: 1, auPlafond: 1 },
|
||||
{ valeur: 3, effectif: 4, cumul: 5, auPlafond: 2 },
|
||||
{ valeur: 4, effectif: 1, cumul: 6, auPlafond: 1 },
|
||||
]);
|
||||
const valeurs = [...new Set(mesures.rencontres)].sort((a, b) => a - b);
|
||||
assert.deepEqual(lignes.map(({ valeur, effectif, auPlafond }) => ({ valeur, effectif, auPlafond })), valeurs.map((valeur) => ({
|
||||
valeur,
|
||||
effectif: mesures.rencontres.filter((v) => v === valeur).length,
|
||||
auPlafond: mesures.rencontres.filter((v, p) => v === valeur && v === realises[p]).length,
|
||||
})));
|
||||
});
|
||||
|
||||
test('sans plafonds, auPlafond vaut null ; sans valeur, aucune ligne', () => {
|
||||
assert.deepEqual(repartition([3, 1, 3], null), [
|
||||
{ valeur: 1, effectif: 1, cumul: 1, auPlafond: null },
|
||||
{ valeur: 3, effectif: 2, cumul: 3, auPlafond: null },
|
||||
]);
|
||||
assert.deepEqual(repartition([], []), []);
|
||||
assert.throws(() => repartition([1, 2], [1]), RangeError);
|
||||
});
|
||||
|
||||
test('la synthèse : le minimum, son effectif, et les personnes sous leur plafond réalisé', () => {
|
||||
const prepare = preparer(PROFIL);
|
||||
const { instance } = prepare;
|
||||
const { mesures, realises, aPriori, aMax } = mesurerTout(prepare);
|
||||
const resume = synthese(profilRencontres(instance, mesures, realises, aPriori, aMax));
|
||||
assert.deepEqual(resume, { minimum: 2, effectifAuMinimum: 1, effectifSousPlafond: 2 });
|
||||
assert.equal(resume.minimum, mesures.aggRencontres.tous.min);
|
||||
assert.deepEqual(synthese([]), { minimum: null, effectifAuMinimum: 0, effectifSousPlafond: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
// Dix « finance », 1 à 5 libres, 4 réservé à la table 2 au tour 1, 6 ancré à
|
||||
// la table 1 ; 7 à 9 sans étiquette. Table 1, trois places, « finance » aux
|
||||
// tours 1 et 2 ; tables 2 (trois places) et 3 (quatre places), sans objectif ;
|
||||
// trois tours, le troisième sans objectif. 9 est en réserve au tour 2.
|
||||
//
|
||||
// Couples possibles : tour 1, 1, 2, 3, 5 et 6 (4 est fixé à une table sans
|
||||
// objectif) ; tour 2, 1 à 6 ; tour 3, aucun. P_r, les non fixés, et L_r, les
|
||||
// deux sièges libres de la table 1 : planchers 4 − 2 = 2, 5 − 2 = 3, 0.
|
||||
// Tour 1 : 6, 1, 2 servis ; hors 2, plancher 2, reste 0 ; 4, 7, 8, 9 sans
|
||||
// table possible. Tour 2 : 6 et 3 servis ; hors 4, plancher 3, reste 1 ; 7
|
||||
// et 8 sans table possible, 9 en réserve. Tour 3 : rien de possible, les neuf
|
||||
// assis sans table possible.
|
||||
// s(p) : 1, 1, 1, 0, 0, 2 ; P(p) : 2, 2, 2, 1, 2, 2. Jamais servies : 5, puis
|
||||
// 4 ; au minimum de s(p), 5, aux tours possibles les plus nombreux.
|
||||
const OBJECTIFS = {
|
||||
personnes: [[1, null, ['finance']], [2, null, ['finance']], [3, null, ['finance']], [4, null, ['finance']], [5, null, ['finance']], [6, null, ['finance']], [7], [8], [9]],
|
||||
tables: [[1, 3], [2, 3], [3, 4]],
|
||||
reservations: [tous(6, 1), auTour(4, 2, 1)],
|
||||
objectifs: [
|
||||
{ table: 1, tour: 1, regle: 'ou', etiquettes: ['finance'] },
|
||||
{ table: 1, tour: 2, regle: 'ou', etiquettes: ['finance'] },
|
||||
],
|
||||
plan: [
|
||||
{ 1: [6, 1, 2], 2: [3, 4, 7], 3: [5, 8, 9] },
|
||||
{ 1: [6, 3, 7], 2: [1, 2, 8], 3: [4, 5] },
|
||||
{ 1: [6, 1, 2], 2: [3, 4, 5], 3: [7, 8, 9] },
|
||||
],
|
||||
};
|
||||
|
||||
// Lecture directe des objectifs d'un tour (§ 4.5, § 5.6), sans rien du
|
||||
// module éprouvé : P_r, les couples possibles dont la personne n'est pas
|
||||
// fixée ; S^nf_r, les servis non fixés ; σ_r, les assis sans couple possible.
|
||||
// tour, de 1 à R ; listes, celles du plan préparé à ce tour.
|
||||
function tourLu(configuration, tour, listes) {
|
||||
const { participants, tables, reservations, objectifs = [] } = configuration;
|
||||
const couvre = (res) => res.portee === 'tous' || res.tour === tour;
|
||||
const fixee = (id) => reservations.find((res) => res.participant === id && couvre(res))?.table;
|
||||
const libre = (table) => reservations.filter((res) => res.table === table.id && couvre(res)).length < table.capacite;
|
||||
const objectif = (table) => objectifs.find((o) => o.table === table && o.tour === tour) ?? objectifs.find((o) => o.table === table && o.tour === null);
|
||||
const correspond = (personne, table) => {
|
||||
const o = objectif(table);
|
||||
return o !== undefined && o.etiquettes.some((e) => (personne.etiquettes ?? []).includes(e));
|
||||
};
|
||||
const possible = (personne) => {
|
||||
const t = fixee(personne.id);
|
||||
return t === undefined ? tables.some((table) => libre(table) && correspond(personne, table.id)) : correspond(personne, t);
|
||||
};
|
||||
let nonFixes = 0;
|
||||
let servisNonFixes = 0;
|
||||
let sansCouple = 0;
|
||||
for (const personne of participants) {
|
||||
const i = listes.findIndex((occupants) => occupants.includes(personne.id));
|
||||
if (possible(personne) && fixee(personne.id) === undefined) nonFixes += 1;
|
||||
if (i === -1) continue;
|
||||
if (!possible(personne)) sansCouple += 1;
|
||||
const table = tables[i];
|
||||
if (fixee(personne.id) === undefined && libre(table) && correspond(personne, table.id)) servisNonFixes += 1;
|
||||
}
|
||||
return { nonFixes, servisNonFixes, sansCouple };
|
||||
}
|
||||
|
||||
// Le parcours, le diagnostic et les objectifs par tour d'un plan préparé.
|
||||
function objectifsDe(prepare) {
|
||||
const { configuration, instance, tableDe } = prepare;
|
||||
const parcours = parcourirTours(instance, tableDe);
|
||||
const diagnostic = diagnostiquer(configuration);
|
||||
return { parcours, diagnostic, objectifs: objectifsParTour(instance, parcours, diagnostic.objectifsInevitables.parTour) };
|
||||
}
|
||||
|
||||
describe('objectifsParTour (§ 12.12, D36, B21)', () => {
|
||||
test('chaque tour, compté à la main : possibles, servis, pourcentage, hors objectif, plancher, assis sans table possible', () => {
|
||||
const { objectifs } = objectifsDe(preparer(OBJECTIFS));
|
||||
assert.deepEqual(objectifs.parTour, [
|
||||
{ possibles: 5, servis: 3, pourcent: 60, hors: 2, plancher: 2, sansCouple: 4 },
|
||||
{ possibles: 6, servis: 2, pourcent: 33, hors: 4, plancher: 3, sansCouple: 2 },
|
||||
{ possibles: 0, servis: null, pourcent: null, hors: null, plancher: null, sansCouple: 9 },
|
||||
]);
|
||||
assert.equal(objectifs.pireTour, 1);
|
||||
assert.deepEqual(objectifs.soiree, { possibles: 11, servis: 5, pourcent: 45, hors: 6, plancher: 5 });
|
||||
});
|
||||
|
||||
test('les servis des tours somment s(p) ; Π_r − S_r = P_r − S^nf_r ; chaque plancher borne son tour ; σ_r + Π_r ne dépasse pas les présents', () => {
|
||||
const prepare = preparer(OBJECTIFS);
|
||||
const { configuration, instance, tableDe, tours } = prepare;
|
||||
const { objectifs, diagnostic } = objectifsDe(prepare);
|
||||
const mesures = mesurer(instance, tableDe);
|
||||
assert.equal(somme(objectifs.parTour.map(({ servis }) => servis ?? 0)), somme(mesures.objectifs.tours));
|
||||
assert.equal(objectifs.soiree.servis, somme(mesures.objectifs.tours));
|
||||
assert.equal(objectifs.soiree.possibles, somme(mesures.objectifs.possibles));
|
||||
objectifs.parTour.forEach(({ possibles, servis, hors, plancher, sansCouple }, r) => {
|
||||
const lu = tourLu(configuration, r + 1, tours[r]);
|
||||
assert.equal(sansCouple, lu.sansCouple, `tour ${r + 1}`);
|
||||
assert.ok(sansCouple + possibles <= instance.N, `tour ${r + 1}`);
|
||||
if (possibles === 0) return;
|
||||
assert.equal(possibles - servis, lu.nonFixes - lu.servisNonFixes, `tour ${r + 1}`);
|
||||
assert.ok(plancher <= hors, `tour ${r + 1}`);
|
||||
});
|
||||
// La somme des planchers des tours est le plancher que le diagnostic écrit.
|
||||
assert.equal(objectifs.soiree.plancher, diagnostic.objectifsInevitables.horsObjectif);
|
||||
});
|
||||
|
||||
test('les personnes jamais servies : celles de la mesure, aux tours possibles les plus nombreux d’abord ; la répartition de P(p) − s(p), de la plus grande valeur à la plus petite', () => {
|
||||
const prepare = preparer(OBJECTIFS);
|
||||
const { instance, tableDe } = prepare;
|
||||
const { objectifs } = objectifsDe(prepare);
|
||||
assert.deepEqual(objectifs.jamaisServies, [{ p: index(instance, 5), possibles: 2 }, { p: index(instance, 4), possibles: 1 }]);
|
||||
assert.equal(objectifs.jamaisServies.length, mesurer(instance, tableDe).objectifs.jamaisServies);
|
||||
assert.deepEqual(objectifs.repartition, [{ valeur: 2, effectif: 1 }, { valeur: 1, effectif: 4 }, { valeur: 0, effectif: 1 }]);
|
||||
// 2 passe le tour 1 à la table 2 : jamais servi, deux tours possibles
|
||||
// comme 5 ; à égalité, l'identifiant.
|
||||
const plan = OBJECTIFS.plan.map((tour) => ({ ...tour }));
|
||||
plan[0] = { 1: [6, 1, 3], 2: [2, 4, 7], 3: [5, 8, 9] };
|
||||
const autre = preparer({ ...OBJECTIFS, plan });
|
||||
assert.deepEqual(idsDe(autre.instance, objectifsDe(autre).objectifs.jamaisServies), [2, 5, 4]);
|
||||
});
|
||||
|
||||
test('à reste égal, le premier tour est le pire ; la personne fixée à une table à objectif est servie hors du plancher', () => {
|
||||
// Au tour 2, 1 rejoint 6 et 3 à la table 1 : trois servis, hors 3,
|
||||
// plancher 3, reste 0, comme au tour 1. 6, ancré, est servi à chaque
|
||||
// tour où la table porte un objectif, sans compter dans P_r.
|
||||
const plan = OBJECTIFS.plan.map((tour) => ({ ...tour }));
|
||||
plan[1] = { 1: [6, 3, 1], 2: [2, 7, 8], 3: [4, 5] };
|
||||
const prepare = preparer({ ...OBJECTIFS, plan });
|
||||
const { objectifs } = objectifsDe(prepare);
|
||||
assert.deepEqual(objectifs.parTour.map(({ hors, plancher }) => [hors, plancher]), [[2, 2], [3, 3], [null, null]]);
|
||||
assert.equal(objectifs.pireTour, 0);
|
||||
const { tours } = prepare;
|
||||
const lu = tourLu(prepare.configuration, 1, tours[0]);
|
||||
assert.equal(lu.nonFixes, 4);
|
||||
assert.equal(objectifs.parTour[0].possibles, lu.nonFixes + 1);
|
||||
});
|
||||
|
||||
test('critère décoché : les planchers des tours sont ceux du diagnostic, non nuls', () => {
|
||||
const contraintes = { ...SANS_CONTRAINTE, respecterObjectifs: false };
|
||||
const { objectifs } = objectifsDe(preparer({ ...OBJECTIFS, contraintes }));
|
||||
assert.deepEqual(objectifs.parTour.map(({ plancher }) => plancher), [2, 3, null]);
|
||||
});
|
||||
|
||||
test('sans personne concernée, null ; une liste de planchers d’une autre longueur lève RangeError', () => {
|
||||
const prepare = preparer({ ...OBJECTIFS, objectifs: [] });
|
||||
const { objectifs, parcours } = objectifsDe(prepare);
|
||||
assert.equal(objectifs, null);
|
||||
const avec = preparer(OBJECTIFS);
|
||||
assert.throws(() => objectifsParTour(avec.instance, parcourirTours(avec.instance, avec.tableDe), [2, 3]), RangeError);
|
||||
assert.ok(parcours.possiblesParTour.every((n) => n === 0));
|
||||
});
|
||||
});
|
||||
|
||||
// Onze personnes, cinq tables de trois, deux tours, sans réservation.
|
||||
// « atelier ressac » (1, 2), « Atelier Ressac » (3, 4), « Zinc » (5, 6),
|
||||
// « Grès » (7, 8, 9), « Coopérative Aubier » (10, 11), dans cet ordre
|
||||
// d'apparition. Tour 1 : chaque appartenance à sa table, Grès à trois. Tour 2 :
|
||||
// 1, 3, 5 ; 2, 4, 6 ; 7 ; 8 ; 9, 10, 11. Aubier : 2 collisions, 1 paire,
|
||||
// excédent 1 ; Grès : 3, 3, 0 ; les trois autres : 1, 1, 0.
|
||||
const COLLISIONS = {
|
||||
personnes: [[1, 'atelier ressac'], [2, 'atelier ressac'], [3, RESSAC], [4, RESSAC], [5, 'Zinc'], [6, 'Zinc'], [7, 'Grès'], [8, 'Grès'], [9, 'Grès'], [10, AUBIER], [11, AUBIER]],
|
||||
tables: [[1, 3], [2, 3], [3, 3], [4, 3], [5, 3]],
|
||||
plan: [
|
||||
{ 1: [1, 2], 2: [3, 4], 3: [5, 6], 4: [7, 8, 9], 5: [10, 11] },
|
||||
{ 1: [1, 3, 5], 2: [2, 4, 6], 3: [7], 4: [8], 5: [9, 10, 11] },
|
||||
],
|
||||
};
|
||||
|
||||
describe('barresCollisions et planchersParGroupe (§ 12.4, D31, B13)', () => {
|
||||
test('l’excédent décroissant, puis les collisions cumulées, puis la clé normalisée, puis la chaîne ; jamais la longueur de la barre', () => {
|
||||
const prepare = preparer(COLLISIONS);
|
||||
const { configuration, instance, tableDe } = prepare;
|
||||
const mesures = mesurer(instance, tableDe);
|
||||
const planchers = planchersParGroupe(instance, diagnostiquer(configuration).collisions);
|
||||
const lignes = barresCollisions(instance, mesures, planchers, true, cle);
|
||||
assert.deepEqual(lignes.map(({ groupe }) => groupe), [AUBIER, 'Grès', RESSAC, 'atelier ressac', 'Zinc']);
|
||||
assert.deepEqual(lignes[0], { g: 4, groupe: AUBIER, effectif: 2, cumulees: 2, prouve: 0, reste: 2, pairesDistinctes: 1, excedent: 1 });
|
||||
assert.deepEqual(lignes.map(({ cumulees, pairesDistinctes, excedent }) => [cumulees, pairesDistinctes, excedent]), [[2, 1, 1], [3, 3, 0], [1, 1, 0], [1, 1, 0], [1, 1, 0]]);
|
||||
// Sans réservation, le diagnostic se tait : « prouvé inévitable » vaut 0.
|
||||
assert.deepEqual(planchers, [0, 0, 0, 0, 0]);
|
||||
});
|
||||
|
||||
test('hors du domaine des planchers, prouvé et reste valent null ; le compte reste', () => {
|
||||
const prepare = preparer(COLLISIONS);
|
||||
const { instance, tableDe } = prepare;
|
||||
const lignes = barresCollisions(instance, mesurer(instance, tableDe), [0, 0, 0, 0, 0], false, cle);
|
||||
assert.ok(lignes.every(({ prouve, reste }) => prouve === null && reste === null));
|
||||
assert.equal(somme(lignes.map(({ cumulees }) => cumulees)), 8);
|
||||
});
|
||||
|
||||
test('la variante conflit : le plancher du diagnostic, 4, est prouvé inévitable pour l’appartenance de cinq', () => {
|
||||
const configuration = CATALOGUE.find(({ cle: nom }) => nom === 'petite-conflit').construire();
|
||||
const instance = normaliser(configuration);
|
||||
const tableDe = indexerPlan(instance, PLAN_PARFAIT_PETITE);
|
||||
const planchers = planchersParGroupe(instance, diagnostiquer(configuration).collisions);
|
||||
const lignes = barresCollisions(instance, mesurer(instance, tableDe), planchers, true, cle);
|
||||
const conflit = lignes.find(({ effectif }) => effectif === 5);
|
||||
assert.equal(conflit.prouve, 4);
|
||||
assert.equal(conflit.reste, conflit.cumulees - 4);
|
||||
assert.ok(lignes.filter((ligne) => ligne !== conflit).every(({ prouve }) => prouve === 0));
|
||||
});
|
||||
|
||||
test('un plancher d’une appartenance absente de l’instance, ou une liste d’une autre longueur, lève RangeError', () => {
|
||||
const { instance, tableDe } = preparer(COLLISIONS);
|
||||
assert.throws(() => planchersParGroupe(instance, [{ groupe: 'Ardoise', plancherCumulees: 2 }]), RangeError);
|
||||
assert.throws(() => barresCollisions(instance, mesurer(instance, tableDe), [0, 0], true, cle), RangeError);
|
||||
});
|
||||
});
|
||||
|
||||
// Lecture directe d'une case (§ 5.4, B4) : capacité, occupants, affiliés et
|
||||
// appartenances distinctes de la table i au tour r, sur le plan préparé.
|
||||
function caseLue({ configuration, tours }, r, i) {
|
||||
const appartenance = new Map(configuration.participants.map(({ id, appartenance: a }) => [id, a]));
|
||||
const occupants = tours[r][i];
|
||||
const affilies = occupants.filter((id) => appartenance.get(id) !== null);
|
||||
return {
|
||||
capacite: configuration.tables[i].capacite,
|
||||
occupants: occupants.length,
|
||||
affilies: affilies.length,
|
||||
appartenances: new Set(affilies.map((id) => appartenance.get(id))).size,
|
||||
};
|
||||
}
|
||||
|
||||
// Le mélange d'un plan préparé.
|
||||
function melange({ instance, tableDe }) {
|
||||
return mixTables(instance, tableDe, occupations(instance, tableDe, { affilies: true }));
|
||||
}
|
||||
|
||||
// Une table par cas, un seul tour, quatre appartenances : W, en tête de
|
||||
// l'ordre des tables, quatre places et deux sans appartenance ; X, huit
|
||||
// places, quatre affiliés de quatre appartenances et un sans ; Y, quatre
|
||||
// places, deux Aubier, un Ressac, un Grès ; Z, quatre places, un Aubier et
|
||||
// trois sans ; V, quatre places, pleine de quatre affiliés de quatre
|
||||
// appartenances. Sièges : —, 4, 1, 3, 0 ; assis : —, 1, 1, 3, 0 ;
|
||||
// doublons : —, 0, 1, 0, 0. W, à l'index 0, ne finit chaque ordre que par
|
||||
// la règle des tables toutes « — », non par l'ordre des tables ; V, sans
|
||||
// rien à compter, finit les tables mesurées.
|
||||
const TROIS_ORDRES = {
|
||||
personnes: [
|
||||
[1, AUBIER], [2, RESSAC], [3, 'Grès'], [4, 'Zinc'], [5], [6, AUBIER], [7, AUBIER], [8, RESSAC], [9, 'Grès'], [10, AUBIER], [11], [12], [13], [14], [15],
|
||||
[16, AUBIER], [17, RESSAC], [18, 'Grès'], [19, 'Zinc'],
|
||||
],
|
||||
tables: [[10, 4], [40, 8], [30, 4], [20, 4], [50, 4]],
|
||||
plan: [{ 40: [1, 2, 3, 4, 5], 30: [6, 7, 8, 9], 20: [10, 11, 12, 13], 10: [14, 15], 50: [16, 17, 18, 19] }],
|
||||
};
|
||||
|
||||
describe('mixTables (§ 12.11, D35, B20)', () => {
|
||||
test('chaque case, contre une lecture directe : les trois comptes, emboîtés, et leurs trois pourcentages', () => {
|
||||
const prepare = preparer(TROIS_ORDRES);
|
||||
const { lignes } = melange(prepare);
|
||||
assert.deepEqual(lignes.map(({ t, capacite }) => [t, capacite]), [[0, 4], [1, 8], [2, 4], [3, 4], [4, 4]]);
|
||||
lignes.forEach(({ t, tours }) => {
|
||||
const lu = caseLue(prepare, 0, t);
|
||||
const [cas] = tours;
|
||||
if (lu.affilies === 0) {
|
||||
assert.equal(cas, null);
|
||||
return;
|
||||
}
|
||||
const { capacite: c, occupants: o, affilies: a, appartenances: d } = lu;
|
||||
assert.deepEqual(cas, {
|
||||
occupants: o,
|
||||
affilies: a,
|
||||
appartenances: d,
|
||||
comptes: { sieges: c - d, assis: o - d, doublons: a - d },
|
||||
pourcents: { sieges: pourcentage(d, c), assis: pourcentage(d, o), doublons: pourcentage(d, a) },
|
||||
});
|
||||
assert.ok(cas.comptes.sieges >= cas.comptes.assis && cas.comptes.assis >= cas.comptes.doublons && cas.comptes.doublons >= 0);
|
||||
});
|
||||
// La table incomplète, avec un assis sans appartenance : 4 ≥ 1 ≥ 0,
|
||||
// quatre appartenances pour huit sièges, cinq assis, quatre affiliés.
|
||||
assert.deepEqual(lignes[1].tours[0].comptes, { sieges: 4, assis: 1, doublons: 0 });
|
||||
assert.deepEqual(lignes[1].tours[0].pourcents, { sieges: 50, assis: 80, doublons: 100 });
|
||||
// Le sans-appartenance de Z n'est pas un doublon.
|
||||
assert.deepEqual(lignes[3].tours[0].comptes, { sieges: 3, assis: 3, doublons: 0 });
|
||||
// La table pleine d'affiliés tous distincts : rien à compter.
|
||||
assert.deepEqual(lignes[4].tours[0].comptes, { sieges: 0, assis: 0, doublons: 0 });
|
||||
});
|
||||
|
||||
test('trois ordres, un par position, qui diffèrent : la pire table d’abord, celle dont toutes les cases sont vides en dernier', () => {
|
||||
const { ordres, lignes } = melange(preparer(TROIS_ORDRES));
|
||||
// La table toute « — » est la première des tables : seul son rang à part
|
||||
// la met en dernier, jamais l'ordre des tables.
|
||||
assert.deepEqual(ordres, { sieges: [1, 3, 2, 4, 0], assis: [3, 1, 2, 4, 0], doublons: [2, 1, 3, 4, 0] });
|
||||
assert.deepEqual(lignes.map(({ pire }) => pire), [
|
||||
null,
|
||||
{ sieges: 4, assis: 1, doublons: 0 },
|
||||
{ sieges: 1, assis: 1, doublons: 1 },
|
||||
{ sieges: 3, assis: 3, doublons: 0 },
|
||||
{ sieges: 0, assis: 0, doublons: 0 },
|
||||
]);
|
||||
assert.equal(lignes[0].somme, null);
|
||||
});
|
||||
|
||||
// Deux tours, cinq tables de trois ; 1 à 4 Aubier, les autres sans. A :
|
||||
// tour 1, 1, 2 et 5, un doublon ; tour 2, 1, 5, 6, aucun. B : 3, 4, 6, un
|
||||
// doublon ; puis 2, 3, 7, un doublon. C : sans affilié au tour 1 ; 4, 8, 9
|
||||
// au tour 2. D et E, vides aux deux tours, dans l'ordre des tables.
|
||||
test('à pire égal, la somme sur les tours départage, puis l’ordre des tables ; une case vide ou sans affilié est ignorée', () => {
|
||||
const prepare = preparer({
|
||||
personnes: [[1, AUBIER], [2, AUBIER], [3, AUBIER], [4, AUBIER], [5], [6], [7], [8], [9]],
|
||||
tables: [[1, 3], [2, 3], [3, 3], [4, 3], [5, 3]],
|
||||
plan: [
|
||||
{ 1: [1, 2, 5], 2: [3, 4, 6], 3: [7, 8, 9] },
|
||||
{ 1: [1, 5, 6], 2: [2, 3, 7], 3: [4, 8, 9] },
|
||||
],
|
||||
});
|
||||
const { ordres, lignes } = melange(prepare);
|
||||
assert.deepEqual(lignes.map(({ tours }) => tours.map((cas) => (cas === null ? null : cas.comptes.doublons))), [[1, 0], [1, 1], [null, 0], [null, null], [null, null]]);
|
||||
assert.deepEqual(lignes.map(({ somme: s }) => s), [
|
||||
{ sieges: 4, assis: 4, doublons: 1 },
|
||||
{ sieges: 4, assis: 4, doublons: 2 },
|
||||
{ sieges: 2, assis: 2, doublons: 0 },
|
||||
null,
|
||||
null,
|
||||
]);
|
||||
assert.deepEqual(ordres, { sieges: [0, 1, 2, 3, 4], assis: [0, 1, 2, 3, 4], doublons: [1, 0, 2, 3, 4] });
|
||||
});
|
||||
|
||||
// Deux tables de trois, trois tours ; 1 à 5 Aubier, les autres sans. La
|
||||
// table 1 reçoit trois Aubier au tour 1, un seul ensuite : doublons 2, 0,
|
||||
// 0, pire 2, somme 2. La table 2 en reçoit deux à chaque tour : doublons
|
||||
// 1, 1, 1, pire 1, somme 3. Le plus grand compte d'une case passe avant la
|
||||
// somme : la table 1 vient d'abord.
|
||||
test('le plus grand compte d’une case, puis la somme : une seule case lourde l’emporte sur trois cases légères', () => {
|
||||
const { ordres, lignes } = melange(
|
||||
preparer({
|
||||
personnes: [[1, AUBIER], [2, AUBIER], [3, AUBIER], [4, AUBIER], [5, AUBIER], [6], [7], [8]],
|
||||
tables: [[1, 3], [2, 3]],
|
||||
plan: [
|
||||
{ 1: [1, 2, 3], 2: [4, 5, 6] },
|
||||
{ 1: [1, 6, 7], 2: [2, 3, 8] },
|
||||
{ 1: [4, 6, 7], 2: [1, 5, 8] },
|
||||
],
|
||||
}),
|
||||
);
|
||||
assert.deepEqual(lignes.map(({ tours }) => tours.map(({ comptes }) => comptes.doublons)), [[2, 0, 0], [1, 1, 1]]);
|
||||
assert.deepEqual(lignes.map(({ pire, somme: s }) => [pire.doublons, s.doublons]), [[2, 2], [1, 3]]);
|
||||
assert.deepEqual(ordres.doublons, [0, 1]);
|
||||
});
|
||||
|
||||
test('un doublon d’appartenance est non nul exactement là où parcourirTours compte des collègues', () => {
|
||||
for (const description of [COLLISIONS, TROIS_ORDRES, PROFIL]) {
|
||||
const prepare = preparer(description);
|
||||
const { parTour } = parcourirTours(prepare.instance, prepare.tableDe);
|
||||
const { lignes } = melange(prepare);
|
||||
lignes.forEach(({ t, tours }) => {
|
||||
tours.forEach((cas, r) => {
|
||||
const doublons = cas === null ? 0 : cas.comptes.doublons;
|
||||
assert.equal(doublons > 0, parTour[r][t].collegues.length > 0, `tour ${r + 1}, table ${t}`);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('sans les affiliés de l’occupation, lève TypeError', () => {
|
||||
const { instance, tableDe } = preparer(TROIS_ORDRES);
|
||||
assert.throws(() => mixTables(instance, tableDe, occupations(instance, tableDe)), TypeError);
|
||||
});
|
||||
});
|
||||
|
||||
// Trois tables de deux, deux tours. 1 anime au tour 1 à la table 1, 2 au
|
||||
// tour 2 à la table 2 ; 3, ancré à la table 3 où personne n'anime, n'est pas
|
||||
// concerné (B(3) = 0). B(1) = B(2) = 1, l'autre animateur ; B(4) = B(5) =
|
||||
// B(6) = 2. 4 rencontre les deux ; 5, 6, 1, 2 aucun. Ordre : 5 et 6, à la
|
||||
// borne la plus haute, avant 1 et 2, puis 4.
|
||||
const ANIMATEURS = {
|
||||
personnes: [[1, null, undefined, [1]], [2, null, undefined, [2]], [3], [4], [5], [6]],
|
||||
tables: [[1, 2], [2, 2], [3, 2]],
|
||||
reservations: [auTour(1, 1, 1), auTour(2, 2, 2), tous(3, 3)],
|
||||
plan: [
|
||||
{ 1: [1, 4], 2: [2, 5], 3: [3, 6] },
|
||||
{ 1: [1, 5], 2: [2, 4], 3: [3, 6] },
|
||||
],
|
||||
};
|
||||
|
||||
describe('profilAnimateurs (§ 12.13, D37)', () => {
|
||||
test('les seules personnes concernées, m(p) croissant, puis la borne décroissante, puis l’identifiant, avec leur statut', () => {
|
||||
const prepare = preparer(ANIMATEURS);
|
||||
const { instance, tableDe } = prepare;
|
||||
const mesures = mesurer(instance, tableDe);
|
||||
const profil = profilAnimateurs(instance, mesures);
|
||||
assert.deepEqual(idsDe(instance, profil), [5, 6, 1, 2, 4]);
|
||||
assert.deepEqual(profil.map(({ rencontres, borne, statut }) => [rencontres, borne, statut]), [
|
||||
[0, 2, 'mobile'],
|
||||
[0, 2, 'mobile'],
|
||||
[0, 1, 'partiel'],
|
||||
[0, 1, 'partiel'],
|
||||
[2, 2, 'mobile'],
|
||||
]);
|
||||
assert.deepEqual(Object.keys(profil[0]), ['p', 'rencontres', 'borne', 'statut']);
|
||||
});
|
||||
|
||||
test('sans personne concernée, null', () => {
|
||||
const prepare = preparer({ ...ANIMATEURS, personnes: [[1], [2], [3], [4], [5], [6]], reservations: [] });
|
||||
assert.equal(profilAnimateurs(prepare.instance, mesurer(prepare.instance, prepare.tableDe)), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bornesDesTuiles (§ 12.2, D29, D34, D52, B11)', () => {
|
||||
test('le rang 1 du profil, la tête du profil de manque, le minimum de A(p) au même tri : chacun une personne nommée', () => {
|
||||
const prepare = preparer(PROFIL);
|
||||
const { instance } = prepare;
|
||||
const { mesures, realises, aPriori, aMax } = mesurerTout(prepare);
|
||||
const profil = profilRencontres(instance, mesures, realises, aPriori, aMax);
|
||||
const bornes = bornesDesTuiles(instance, { mesures, plafondsRealises: realises, aMax, profil, animateurs: null, objectifs: null });
|
||||
const un = index(instance, 1);
|
||||
assert.deepEqual(bornes.rang1, { p: un, rencontres: 2, plafondRealise: 2, plafondAPriori: aPriori[un], pourcent: 100 });
|
||||
// Manque 1 pour 4 et 5, au même plafond 4 : l'identifiant le plus petit.
|
||||
assert.deepEqual(bornes.manque, { p: index(instance, 4), plafondRealise: 4 });
|
||||
// A(p) = 1 pour 4 et 5 : 5, à la borne de diversité la plus haute.
|
||||
assert.deepEqual(bornes.diversite, { p: index(instance, 5), aMax: 2 });
|
||||
assert.equal(bornes.animateurs, null);
|
||||
assert.equal(bornes.objectifs, null);
|
||||
assert.equal(bornes.soiree, null);
|
||||
});
|
||||
|
||||
test('aucun pourcentage pour le manque, la diversité ni les animateurs ; celui du minimum de s(p) et de la soirée', () => {
|
||||
const avec = preparer(OBJECTIFS);
|
||||
const { instance, tableDe } = avec;
|
||||
const { mesures, realises, aPriori, aMax } = mesurerTout(avec);
|
||||
const profil = profilRencontres(instance, mesures, realises, aPriori, aMax);
|
||||
const { objectifs } = objectifsDe(avec);
|
||||
const bornes = bornesDesTuiles(instance, { mesures, plafondsRealises: realises, aMax, profil, animateurs: null, objectifs });
|
||||
// s(p) = 0 pour 4 et 5 : 5, aux tours possibles les plus nombreux.
|
||||
assert.deepEqual(bornes.objectifs, { p: index(instance, 5), servis: 0, possibles: 2, pourcent: 0 });
|
||||
assert.deepEqual(bornes.soiree, { servis: 5, possibles: 11, pourcent: 45, jamaisServies: 2 });
|
||||
assert.equal(bornes.soiree.jamaisServies, mesurer(instance, tableDe).objectifs.jamaisServies);
|
||||
assert.deepEqual(Object.keys(bornes.manque), ['p', 'plafondRealise']);
|
||||
assert.equal(bornes.diversite, null);
|
||||
|
||||
// La table 1 à quatre places : chaque « finance » libre est servi une
|
||||
// fois, 6 deux fois. Le minimum de s(p) vaut 1 chez les concernées ; 7,
|
||||
// 8 et 9, à s(p) = P(p) = 0, n'en sont pas et ne le portent jamais.
|
||||
const servis = preparer({
|
||||
...OBJECTIFS,
|
||||
tables: [[1, 4], [2, 3], [3, 4]],
|
||||
plan: [
|
||||
{ 1: [6, 1, 2, 3], 2: [4, 7, 8], 3: [5, 9] },
|
||||
{ 1: [6, 4, 5, 7], 2: [1, 2, 8], 3: [3, 9] },
|
||||
{ 1: [6, 1, 2], 2: [3, 4, 5], 3: [7, 8, 9] },
|
||||
],
|
||||
});
|
||||
const lu = mesurerTout(servis);
|
||||
const parServis = bornesDesTuiles(servis.instance, {
|
||||
mesures: lu.mesures,
|
||||
plafondsRealises: lu.realises,
|
||||
aMax: lu.aMax,
|
||||
profil: profilRencontres(servis.instance, lu.mesures, lu.realises, lu.aPriori, lu.aMax),
|
||||
animateurs: null,
|
||||
objectifs: objectifsDe(servis).objectifs,
|
||||
});
|
||||
assert.deepEqual(lu.mesures.objectifs.tours, [1, 1, 1, 1, 1, 2, 0, 0, 0]);
|
||||
// s(p) = 1 pour 1, 2, 3, 5 à P(p) = 2 et pour 4 à P(p) = 1 : 1, le
|
||||
// premier aux tours possibles les plus nombreux.
|
||||
assert.deepEqual(parServis.objectifs, { p: index(servis.instance, 1), servis: 1, possibles: 2, pourcent: 50 });
|
||||
|
||||
const animes = preparer(ANIMATEURS);
|
||||
const m = mesurer(animes.instance, animes.tableDe);
|
||||
const tout = mesurerTout(animes);
|
||||
const profilA = profilRencontres(animes.instance, m, tout.realises, tout.aPriori, tout.aMax);
|
||||
const deAnimateurs = bornesDesTuiles(animes.instance, {
|
||||
mesures: m,
|
||||
plafondsRealises: tout.realises,
|
||||
aMax: tout.aMax,
|
||||
profil: profilA,
|
||||
animateurs: profilAnimateurs(animes.instance, m),
|
||||
objectifs: null,
|
||||
});
|
||||
assert.deepEqual(deAnimateurs.animateurs, { p: index(animes.instance, 5), borne: 2 });
|
||||
});
|
||||
|
||||
test('sans personne présente : ni rang 1, ni tête de manque', () => {
|
||||
const vide = normaliser({
|
||||
participants: [{ id: 1, nom: 'P1', appartenance: AUBIER, exclu: true }],
|
||||
tables: [{ id: 1, numero: 1, capacite: 2 }],
|
||||
tours: 1,
|
||||
reservations: [],
|
||||
contraintes: SANS_CONTRAINTE,
|
||||
});
|
||||
const plan = new Int32Array(0);
|
||||
const mesures = mesurer(vide, plan);
|
||||
const bornes = bornesDesTuiles(vide, { mesures, plafondsRealises: [], aMax: [], profil: [], animateurs: null, objectifs: null });
|
||||
assert.deepEqual(bornes, { rang1: null, manque: null, diversite: null, animateurs: null, objectifs: null, soiree: null });
|
||||
});
|
||||
});
|
||||
|
||||
// Tous les plans indexés d'une instance qui assoient chacun à chaque tour,
|
||||
// honorent les réservations et tiennent les capacités ; le même tableau est
|
||||
// rendu, rempli, à chaque plan.
|
||||
function* tousLesPlans({ N, T, R, capacite, fixe }) {
|
||||
const tableDe = new Int32Array(N * R);
|
||||
const occupation = new Int32Array(T * R);
|
||||
function* placer(i) {
|
||||
if (i === N * R) {
|
||||
yield tableDe;
|
||||
return;
|
||||
}
|
||||
const r = i % R;
|
||||
for (let t = 0; t < T; t += 1) {
|
||||
if ((fixe[i] !== LIBRE && fixe[i] !== t) || occupation[t * R + r] === capacite[t]) continue;
|
||||
occupation[t * R + r] += 1;
|
||||
tableDe[i] = t;
|
||||
yield* placer(i + 1);
|
||||
occupation[t * R + r] -= 1;
|
||||
}
|
||||
}
|
||||
yield* placer(0);
|
||||
}
|
||||
|
||||
describe('le détail sur chaque plan d’une petite configuration (§ 14.10, B25)', () => {
|
||||
// Six personnes, trois tables de deux, deux tours ; 1 ancré à la table 1,
|
||||
// « finance » pour la soirée ; la table 2, « finance » au seul tour 2 ; la
|
||||
// table 3 sans objectif. Trente plans par tour, neuf cents en tout.
|
||||
test('les comptes par table et par tour tiennent leurs identités sur chacun des neuf cents plans', () => {
|
||||
const configuration = {
|
||||
participants: [
|
||||
participant([1, AUBIER, ['finance']]),
|
||||
participant([2, AUBIER, ['finance']]),
|
||||
participant([3, RESSAC, ['finance']]),
|
||||
participant([4, RESSAC]),
|
||||
participant([5, null, ['finance']]),
|
||||
participant([6]),
|
||||
],
|
||||
tables: [{ id: 1, numero: 1, capacite: 2 }, { id: 2, numero: 2, capacite: 2 }, { id: 3, numero: 3, capacite: 2 }],
|
||||
tours: 2,
|
||||
reservations: [tous(1, 1)],
|
||||
contraintes: SANS_CONTRAINTE,
|
||||
objectifs: [
|
||||
{ table: 1, tour: null, regle: 'ou', etiquettes: ['finance'] },
|
||||
{ table: 2, tour: 2, regle: 'ou', etiquettes: ['finance'] },
|
||||
],
|
||||
};
|
||||
const instance = normaliser(configuration);
|
||||
const { N, R, T, ids } = instance;
|
||||
const diagnostic = diagnostiquer(configuration);
|
||||
const appartenance = new Map(configuration.participants.map(({ id, appartenance: a }) => [id, a]));
|
||||
let plans = 0;
|
||||
for (const tableDe of tousLesPlans(instance)) {
|
||||
plans += 1;
|
||||
const tours = Array.from({ length: R }, (_, r) => Array.from({ length: T }, (__, t) => ids.filter((___, p) => tableDe[p * R + r] === t)));
|
||||
const parcours = parcourirTours(instance, tableDe);
|
||||
const { lignes } = mixTables(instance, tableDe, occupations(instance, tableDe, { affilies: true }), parcours);
|
||||
for (const { t, tours: cases } of lignes) {
|
||||
cases.forEach((cas, r) => {
|
||||
const affilies = tours[r][t].filter((id) => appartenance.get(id) !== null);
|
||||
if (affilies.length === 0) {
|
||||
assert.equal(cas, null);
|
||||
return;
|
||||
}
|
||||
const d = new Set(affilies.map((id) => appartenance.get(id))).size;
|
||||
assert.equal(cas.appartenances, d);
|
||||
assert.equal(cas.comptes.doublons, affilies.length - d);
|
||||
assert.equal(cas.comptes.doublons > 0, parcours.parTour[r][t].collegues.length > 0);
|
||||
assert.ok(cas.comptes.sieges >= cas.comptes.assis && cas.comptes.assis >= cas.comptes.doublons);
|
||||
});
|
||||
}
|
||||
const objectifs = objectifsParTour(instance, parcours, diagnostic.objectifsInevitables.parTour);
|
||||
assert.equal(objectifs.soiree.servis, somme(parcours.servis));
|
||||
assert.equal(objectifs.soiree.plancher, diagnostic.objectifsInevitables.horsObjectif);
|
||||
objectifs.parTour.forEach(({ possibles, servis, hors, plancher, sansCouple }, r) => {
|
||||
const lu = tourLu(configuration, r + 1, tours[r]);
|
||||
assert.equal(sansCouple, lu.sansCouple);
|
||||
assert.ok(sansCouple + possibles <= N);
|
||||
assert.equal(possibles - servis, lu.nonFixes - lu.servisNonFixes);
|
||||
assert.ok(plancher <= hors);
|
||||
});
|
||||
}
|
||||
assert.equal(plans, 900);
|
||||
});
|
||||
});
|
||||
|
||||
// Un objet simple ou une liste, à toute profondeur, sans tableau typé.
|
||||
function sansTableauType(valeur, chemin = 'détail') {
|
||||
if (valeur === null || typeof valeur !== 'object') return;
|
||||
assert.ok(!ArrayBuffer.isView(valeur), `${chemin} est un tableau typé`);
|
||||
assert.ok(Array.isArray(valeur) || Object.getPrototypeOf(valeur) === Object.prototype, `${chemin} n'est ni une liste ni un objet simple`);
|
||||
for (const [cle2, element] of Object.entries(valeur)) sansTableauType(element, `${chemin}.${cle2}`);
|
||||
}
|
||||
|
||||
describe('detailler (§ 12.9, D27)', () => {
|
||||
// Les deux configurations réunies : appartenances, objectifs et animateurs.
|
||||
function complet() {
|
||||
const prepare = preparer({
|
||||
...OBJECTIFS,
|
||||
personnes: OBJECTIFS.personnes.map(([id, , etiquettes], i) => [id, [AUBIER, RESSAC, null][i % 3], etiquettes, id === 7 ? [2] : undefined]),
|
||||
reservations: [...OBJECTIFS.reservations, auTour(7, 1, 2)],
|
||||
});
|
||||
const { configuration, instance, tableDe } = prepare;
|
||||
const { mesures, realises, aPriori, aMax } = mesurerTout(prepare);
|
||||
const options = {
|
||||
diagnostic: diagnostiquer(configuration),
|
||||
mesures,
|
||||
plafondsRealises: realises,
|
||||
plafondsAPriori: aPriori,
|
||||
planchersValides: true,
|
||||
cle,
|
||||
ordreMatrice: null,
|
||||
};
|
||||
return { prepare, instance, tableDe, mesures, realises, aPriori, aMax, options };
|
||||
}
|
||||
|
||||
test('réunit chaque fonction du détail sur le même plan, et ne rend aucun tableau typé', () => {
|
||||
const { instance, tableDe, mesures, realises, aPriori, aMax, options } = complet();
|
||||
const detail = detailler(instance, tableDe, options);
|
||||
sansTableauType(detail);
|
||||
const profil = profilRencontres(instance, mesures, realises, aPriori, aMax);
|
||||
assert.deepEqual(detail.profil, profil);
|
||||
assert.deepEqual(detail.synthese, synthese(profil));
|
||||
assert.deepEqual(detail.repartitionRencontres, repartition(mesures.rencontres, realises));
|
||||
assert.deepEqual(detail.collisions, {
|
||||
planchersValides: true,
|
||||
lignes: barresCollisions(instance, mesures, planchersParGroupe(instance, options.diagnostic.collisions), true, cle),
|
||||
});
|
||||
assert.deepEqual(detail.tables, mixTables(instance, tableDe, occupations(instance, tableDe, { affilies: true })));
|
||||
assert.deepEqual(detail.paires, pairesRepetees(instance, tableDe));
|
||||
assert.equal(detail.paires.choisies.length, mesures.rencontresRepetees.choisies);
|
||||
assert.equal(detail.paires.imposees.length, mesures.rencontresRepetees.imposees);
|
||||
const parcours = parcourirTours(instance, tableDe);
|
||||
const objectifs = objectifsParTour(instance, parcours, options.diagnostic.objectifsInevitables.parTour);
|
||||
assert.deepEqual(detail.objectifs, objectifs);
|
||||
const animateurs = profilAnimateurs(instance, mesures);
|
||||
assert.deepEqual(detail.animateurs, {
|
||||
profil: animateurs,
|
||||
repartition: repartition(animateurs.map(({ rencontres }) => rencontres), null),
|
||||
diversite: diversiteAnimateurs(instance, tableDe),
|
||||
});
|
||||
assert.deepEqual(detail.bornes, bornesDesTuiles(instance, { mesures, plafondsRealises: realises, aMax, profil, animateurs, objectifs }));
|
||||
assert.equal(detail.matrice, null);
|
||||
});
|
||||
|
||||
test('la borne de diversité reçue sert telle quelle ; la matrice suit l’ordre demandé', () => {
|
||||
const { instance, tableDe, options } = complet();
|
||||
const aMax = new Array(instance.N).fill(1);
|
||||
const ordre = [4, 0, 2];
|
||||
const detail = detailler(instance, tableDe, { ...options, aMax, ordreMatrice: ordre });
|
||||
assert.ok(detail.profil.every(({ borneDiversite: b }) => b === 1));
|
||||
assert.deepEqual(detail.matrice, { ordre, comptes: matriceRencontres(instance, tableDe, ordre) });
|
||||
assert.notEqual(detail.matrice.ordre, ordre);
|
||||
sansTableauType(detail);
|
||||
});
|
||||
|
||||
test('sans animateur ni personne concernée, les animateurs valent null ; hors du domaine, les barres n’ont qu’un segment', () => {
|
||||
const prepare = preparer(PROFIL);
|
||||
const { configuration, instance, tableDe } = prepare;
|
||||
const { mesures, realises, aPriori } = mesurerTout(prepare);
|
||||
const detail = detailler(instance, tableDe, {
|
||||
diagnostic: diagnostiquer(configuration),
|
||||
mesures,
|
||||
plafondsRealises: realises,
|
||||
plafondsAPriori: aPriori,
|
||||
planchersValides: false,
|
||||
cle,
|
||||
ordreMatrice: null,
|
||||
});
|
||||
assert.equal(detail.animateurs, null);
|
||||
assert.equal(detail.objectifs, null);
|
||||
assert.ok(detail.collisions.lignes.every(({ prouve, reste }) => prouve === null && reste === null));
|
||||
assert.equal(detail.collisions.planchersValides, false);
|
||||
});
|
||||
|
||||
test('des animateurs sans personne concernée : le profil et sa répartition valent null, la diversité reste', () => {
|
||||
// 1 anime, seul présent : il ne se rencontre pas lui-même, et personne
|
||||
// d'autre ne peut le rejoindre.
|
||||
const prepare = preparer({
|
||||
personnes: [[1, null, undefined, [1]]],
|
||||
tables: [[1, 2]],
|
||||
reservations: [auTour(1, 1, 1)],
|
||||
plan: [{ 1: [1] }],
|
||||
});
|
||||
const { configuration, instance, tableDe } = prepare;
|
||||
const { mesures, realises, aPriori } = mesurerTout(prepare);
|
||||
assert.equal(mesures.animateurs, null);
|
||||
const detail = detailler(instance, tableDe, {
|
||||
diagnostic: diagnostiquer(configuration),
|
||||
mesures,
|
||||
plafondsRealises: realises,
|
||||
plafondsAPriori: aPriori,
|
||||
planchersValides: true,
|
||||
cle,
|
||||
ordreMatrice: null,
|
||||
});
|
||||
assert.equal(detail.animateurs.profil, null);
|
||||
assert.equal(detail.animateurs.repartition, null);
|
||||
assert.equal(detail.animateurs.diversite.lignes.length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('qualite : la source du module', () => {
|
||||
const source = readFileSync(new URL('./qualite.js', import.meta.url), 'utf8');
|
||||
|
||||
test('aucun couple possible ni correspondance relus : les comptes par tour viennent de parcourirTours', () => {
|
||||
assert.doesNotMatch(source, /\bpossible\[/);
|
||||
assert.doesNotMatch(source, /\bcorrespond\[/);
|
||||
assert.doesNotMatch(source, /\bobjectifDe\[/);
|
||||
});
|
||||
|
||||
test('une seule multiplication par cent : celle de pourcentage', () => {
|
||||
assert.equal(source.match(/\b100\s*\*/g).length, 1);
|
||||
});
|
||||
|
||||
test('aucun plan relu case à case : le détail lit le parcours et l’occupation', () => {
|
||||
assert.doesNotMatch(source, /\btableDe\[/);
|
||||
});
|
||||
});
|
||||
|
|
@ -1454,7 +1454,8 @@ describe('rechercher : chaque mode descend jusqu’aux planchers du diagnostic (
|
|||
minimum: ['horsObjectifMax', 'horsObjectif'],
|
||||
};
|
||||
const CONTRASTE = { chacun: 'jamaisServies', minimum: 'horsObjectifMax' };
|
||||
const PLANCHERS = { horsObjectif: 12, jamaisServies: 0, horsObjectifMax: 2 };
|
||||
// Par tour, P_r = 10 et L_r = 8 − 1, l'ancré de la table : 3, et 12 en tout.
|
||||
const PLANCHERS = { horsObjectif: 12, jamaisServies: 0, horsObjectifMax: 2, parTour: [3, 3, 3, 3] };
|
||||
const objectifsDu = (configuration, graine) => {
|
||||
const instance = normaliser(configuration);
|
||||
const [{ plan }] = rechercher(configuration, { graine, arret: 20_000, nombre: 1 });
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ const regleDuChamp = (regle, cle) => regle.champs.find(([nom]) => nom === cle)[1
|
|||
const ENTETE = regleDuChamp(SCHEMA, 'entete');
|
||||
const CHARGE = regleDuChamp(SCHEMA, 'charge');
|
||||
const RETENU = regleDuChamp(CHARGE, 'retenu');
|
||||
const PLACEMENT = regleDuChamp(regleDuChamp(CHARGE, 'propositions').element, 'placement');
|
||||
|
||||
// Vrai pour un objet qui n'est ni null ni une liste.
|
||||
const estObjet = (valeur) => typeof valeur === 'object' && valeur !== null && !Array.isArray(valeur);
|
||||
|
|
@ -176,6 +177,22 @@ export function serialiser(charge, { revision, produitVersion }) {
|
|||
return `${rendre({ entete, charge: copie }, SCHEMA, 0)}\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Texte canonique du placement d'une proposition : JSON.stringify de sa
|
||||
* copie canonique, sur une ligne. Chaque réserve s'y range par identifiant
|
||||
* croissant ; chaque liste de table aussi quand siegesAttribues est faux,
|
||||
* et reste dans l'ordre des sièges quand il est vrai. Deux placements qui
|
||||
* ne diffèrent que par un ordre que canoniser range ont le même texte. La
|
||||
* proposition reçue n'est pas modifiée ; un placement hors de sa règle lève
|
||||
* TypeError ou rend un texte quelconque, comme copier.
|
||||
*
|
||||
* @param {{placement: Object[], siegesAttribues: boolean}} proposition
|
||||
* @returns {string}
|
||||
*/
|
||||
export function texteDuPlacement({ placement, siegesAttribues }) {
|
||||
return JSON.stringify(copier(placement, PLACEMENT, 'placement', siegesAttribues));
|
||||
}
|
||||
|
||||
/**
|
||||
* Texte canonique de la charge seule, dans la mise en page du fichier, sans
|
||||
* en-tête, et une fin de ligne finale : ce que comparent l'empreinte des
|
||||
|
|
|
|||
|
|
@ -1958,6 +1958,20 @@ describe('le placement daté : un champ facultatif du retenu et des propositions
|
|||
});
|
||||
});
|
||||
|
||||
describe('le journal ne porte aucune course de la recherche (§ 8.6, § 8.9, B31)', () => {
|
||||
// La course va au fichier compagnon des courses (courses.js) : une
|
||||
// proposition qui la porte sort de sa règle, et aucune ligne ne l'écrit.
|
||||
test('ligneEntree refuse une proposition qui porte une course, en instantané comme en correctif, avant toute écriture', () => {
|
||||
const avant = petite();
|
||||
const apres = petite();
|
||||
apres.propositions[0].course = { evalues: 0, releves: [] };
|
||||
const commun = { libelle: 'Geste', horodatage: '2031-05-17T20:15:00-04:00' };
|
||||
const premiere = { ...commun, revision: 1, produitVersion: VERSION.affichee };
|
||||
assert.throws(() => ligneEntree({ ...premiere, apres, instantane: true }), /course/);
|
||||
assert.throws(() => ligneEntree({ ...commun, revision: 2, produitVersion: null, avant, apres, instantane: false }), /course/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('les étiquettes, les objectifs et les réglages du format 2 dans un correctif (§ 8.6, § 8.9)', () => {
|
||||
// Une charge de deux personnes et deux tables, sans champ du format 2 ;
|
||||
// modifier passe sur une copie.
|
||||
|
|
|
|||
269
src/stockage/courses.js
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Le compagnon des courses (§ 5.10, § 8.6, § 8.9) : le fichier
|
||||
// <base>.gtt-courses.json, à côté du fichier d'état, qui garde la course de
|
||||
// la recherche de chaque proposition — ses mouvements évalués et les
|
||||
// placements de ses points de relevé. Ni le fichier d'état ni le journal
|
||||
// ne la portent : la course pèse plusieurs fois la proposition, et l'écrire
|
||||
// avec l'état ferait payer son poids à chaque geste. Le dépôt n'écrit le
|
||||
// compagnon que quand l'ensemble des propositions change — génération,
|
||||
// effacement, retour qui les rend ou les retire —, jamais à un autre geste.
|
||||
//
|
||||
// Le texte : une première ligne qui porte le format de ce fichier,
|
||||
// FORMAT_COURSES, et l'identifiant de l'événement, puis une entrée par
|
||||
// ligne, puis la fermeture. Une entrée porte l'identité de sa proposition —
|
||||
// id, graine, arret, historique, l'horodatage de sa génération quand elle
|
||||
// en a un, et l'empreinte de son placement — puis sa course, sous la règle
|
||||
// ENTREE_DE_COURSE du schéma. Une entrée ne vaut que pour la proposition de
|
||||
// la charge qui porte exactement cette identité : une entrée périmée,
|
||||
// laissée par un effacement, une coupure ou une copie à la main, ne trouve
|
||||
// pas sa proposition et s'ignore.
|
||||
//
|
||||
// Les quatre premiers champs ne suffisent pas : le compteur des
|
||||
// propositions recule quand l'opérateur reprend un état d'avant une
|
||||
// génération, et la graine proposée peut être la même. Après un changement
|
||||
// qui laisse la déclaration intacte — une appartenance, une réservation, un
|
||||
// réglage —, la recherche suivante porte alors les mêmes quatre champs et
|
||||
// une autre course, qui finit sur un autre placement ou sur le même.
|
||||
// L'horodatage les départage : la génération le pose une fois sur chaque
|
||||
// proposition qu'elle ajoute, à la seconde de l'horloge qui la date
|
||||
// (appliquerCommande), et les gestes suivants le gardent. Deux propositions
|
||||
// de même clé portent ainsi les mêmes quatre champs, le même horodatage et
|
||||
// la même empreinte : deux recherches ne s'y confondent que générées sous
|
||||
// la même graine à la même seconde de leurs horloges, et finies sur le
|
||||
// même placement à une collision d'empreinte près. Une proposition sans
|
||||
// horodatage — d'un fichier écrit avant que la génération ne date ses
|
||||
// propositions, ou d'une commande appliquée sans instant — n'a que les
|
||||
// quatre champs et l'empreinte : deux recherches de même identité qui
|
||||
// finissent sur le même placement s'y confondent, et l'une peut recevoir
|
||||
// la course de l'autre. Une entrée sans horodatage ne vaut que pour une
|
||||
// proposition sans horodatage, et l'inverse.
|
||||
//
|
||||
// Ce que le compagnon porte se dérive de la recherche : il n'est jamais une
|
||||
// donnée de l'opérateur. Un compagnon absent, illisible, d'un autre format
|
||||
// ou d'un autre événement, ou dont une entrée contredit sa proposition, ne
|
||||
// refuse ni l'ouverture ni l'écriture : les courses qu'il ne confirme pas
|
||||
// sont inconnues, et la page de qualité dit que la course n'est pas gardée.
|
||||
//
|
||||
// En mémoire, les courses sont une Map de cleDeCourse à l'entrée : celles
|
||||
// que la lecture du compagnon a admises, et celles des générations de la
|
||||
// séance. Une Map ne se modifie jamais : avecCourses en rend une neuve. Le
|
||||
// module est pur : il ne touche aucun fichier, ne lit ni horloge ni aléa.
|
||||
import { texteDuPlacement } from './canonique.js';
|
||||
import { ENTREE_DE_COURSE, clesRangees, premiereFaute } from './document.js';
|
||||
import { courseDepuisFichier, fauteDeCourse } from './placements.js';
|
||||
|
||||
/**
|
||||
* Version du format du compagnon des courses, distincte de celle du
|
||||
* fichier d'état. Un compagnon d'un autre format ne se lit pas : ses
|
||||
* courses sont inconnues.
|
||||
*/
|
||||
export const FORMAT_COURSES = 1;
|
||||
|
||||
// L'empreinte du texte : un entier de 0 à 2^53 − 1, le même sur toute
|
||||
// machine, qui ne lit que les unités UTF-16 du texte. Deux états de 32 bits
|
||||
// absorbent chaque unité par un ou exclusif puis une multiplication
|
||||
// (Math.imul) par un grand impair, chacun le sien ; un mélange croisé les
|
||||
// brasse ensuite, et 21 bits de l'un suivis des 32 de l'autre font l'entier.
|
||||
// Elle n'est pas cryptographique : elle départage deux placements qui
|
||||
// partagent déjà les autres champs de l'identité.
|
||||
function empreinteDuTexte(texte) {
|
||||
let h1 = 0xdeadbeef;
|
||||
let h2 = 0x41c6ce57;
|
||||
for (let i = 0; i < texte.length; i += 1) {
|
||||
const unite = texte.charCodeAt(i);
|
||||
h1 = Math.imul(h1 ^ unite, 2654435761);
|
||||
h2 = Math.imul(h2 ^ unite, 1597334677);
|
||||
}
|
||||
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507);
|
||||
h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909);
|
||||
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507);
|
||||
h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909);
|
||||
return 4294967296 * (h2 & 0x1fffff) + (h1 >>> 0);
|
||||
}
|
||||
|
||||
// Les empreintes déjà calculées, par objet placement, chacune avec le
|
||||
// drapeau siegesAttribues sous lequel elle l'a été. Un placement de
|
||||
// proposition ne se modifie jamais en place : une commande en fait un neuf,
|
||||
// ou modifie une copie (structuredClone) avant toute lecture. Chaque
|
||||
// placement se hache ainsi une fois, et les propositions qu'un geste garde,
|
||||
// ou qu'une datation recopie, retrouvent la leur sans la recalculer.
|
||||
const EMPREINTES = new WeakMap();
|
||||
|
||||
/**
|
||||
* L'empreinte du placement de la proposition p : celle de son texte
|
||||
* canonique (texteDuPlacement), un entier de 0 à 2^53 − 1. Ni
|
||||
* l'horodatage, ni produitVersion, ni l'ordre qu'une liste de table sans
|
||||
* sièges attribués ou une réserve reçoit n'y entrent. p.placement est un
|
||||
* objet, qui ne se modifie plus après cet appel (EMPREINTES).
|
||||
*
|
||||
* @param {{placement: Object[], siegesAttribues: boolean}} p
|
||||
* @returns {number}
|
||||
*/
|
||||
export function empreinteDe(p) {
|
||||
const connue = EMPREINTES.get(p.placement);
|
||||
if (connue !== undefined && connue.siegesAttribues === p.siegesAttribues) return connue.empreinte;
|
||||
const empreinte = empreinteDuTexte(texteDuPlacement(p));
|
||||
EMPREINTES.set(p.placement, { siegesAttribues: p.siegesAttribues, empreinte });
|
||||
return empreinte;
|
||||
}
|
||||
|
||||
/**
|
||||
* La clé d'une proposition dans les courses en mémoire : son identifiant,
|
||||
* sa graine, son compte d'arrêt, son historique, son horodatage — vide
|
||||
* quand x n'en porte pas — et l'empreinte de son placement, joints par
|
||||
* « | ». x est une entrée du compagnon, qui porte son empreinte, ou une
|
||||
* proposition, dont l'empreinte se calcule (empreinteDe). Deux
|
||||
* propositions de même clé portent ces six valeurs ; une proposition dont
|
||||
* l'une change n'a plus la course de l'autre. Ce que la clé garantit, et
|
||||
* ce qu'elle ne garantit pas sans horodatage : l'en-tête du module.
|
||||
*
|
||||
* @param {{id: number, graine: number, arret: number, historique: number, horodatage?: string}} x
|
||||
* @returns {string}
|
||||
*/
|
||||
export function cleDeCourse(x) {
|
||||
const empreinte = Object.hasOwn(x, 'empreinte') ? x.empreinte : empreinteDe(x);
|
||||
return `${x.id}|${x.graine}|${x.arret}|${x.historique}|${x.horodatage ?? ''}|${empreinte}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* L'entrée du compagnon de la proposition de fichier p, pour sa course
|
||||
* mise en forme de fichier (courseVersFichier) : { id, graine, arret,
|
||||
* historique, horodatage, empreinte, course }, dans cet ordre de clés,
|
||||
* horodatage celui de p, absent quand p n'en porte pas, empreinte celle
|
||||
* du placement de p (empreinteDe), une copie de course, rien n'étant
|
||||
* partagé avec les arguments. Ne contrôle rien : fauteDeCourse a contrôlé
|
||||
* la course à sa mise en forme.
|
||||
*
|
||||
* @param {import('./types.js').PropositionFichier} p
|
||||
* @param {{evalues: number, releves: Object[]}} course
|
||||
* @returns {{id: number, graine: number, arret: number, historique: number, horodatage?: string, empreinte: number, course: Object}}
|
||||
*/
|
||||
export function entreeDeCourse(p, course) {
|
||||
const { id, graine, arret, historique, horodatage } = p;
|
||||
const date = horodatage === undefined ? {} : { horodatage };
|
||||
return { id, graine, arret, historique, ...date, empreinte: empreinteDe(p), course: structuredClone(course) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Le texte du compagnon de l'événement d'identifiant evenement, portant
|
||||
* entrees dans leur ordre : la ligne d'ouverture, une entrée par ligne
|
||||
* (JSON.stringify, ses clés dans l'ordre d'entreeDeCourse), la fermeture,
|
||||
* puis une fin de ligne. Sans entrée, une seule ligne.
|
||||
*
|
||||
* @param {string} evenement
|
||||
* @param {Object[]} entrees
|
||||
* @returns {string}
|
||||
*/
|
||||
export function texteDesCourses(evenement, entrees) {
|
||||
const ouverture = `{"format":${FORMAT_COURSES},"evenement":${JSON.stringify(evenement)},"courses":[`;
|
||||
if (entrees.length === 0) return `${ouverture}]}\n`;
|
||||
return `${ouverture}\n${entrees.map((entree) => JSON.stringify(entree)).join(',\n')}\n]}\n`;
|
||||
}
|
||||
|
||||
// Les clés de la racine d'un compagnon, rangées comme clesRangees les rend.
|
||||
const RACINE_RANGEE = ['courses', 'evenement', 'format'].join('|');
|
||||
|
||||
// La liste des entrées d'un texte de compagnon pour l'événement evenement,
|
||||
// ou null quand le texte ne se lit pas : absent, hors JSON — une marque
|
||||
// d'ordre d'octets en tête mise à part —, une racine qui n'a pas exactement
|
||||
// les clés format, evenement et courses, un autre format, un autre
|
||||
// événement, des courses qui ne sont pas une liste.
|
||||
function entreesDuTexte(texte, evenement) {
|
||||
if (typeof texte !== 'string') return null;
|
||||
let lu;
|
||||
try {
|
||||
lu = JSON.parse(texte.replace(/^\u{FEFF}/u, ''));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (typeof lu !== 'object' || lu === null || Array.isArray(lu)) return null;
|
||||
if (clesRangees(lu).join('|') !== RACINE_RANGEE) return null;
|
||||
if (lu.format !== FORMAT_COURSES || lu.evenement !== evenement || !Array.isArray(lu.courses)) return null;
|
||||
return lu.courses;
|
||||
}
|
||||
|
||||
/**
|
||||
* Les courses qu'un texte de compagnon confirme pour charge, en mémoire :
|
||||
* une Map de cleDeCourse à l'entrée, dans l'ordre du texte. Ne lève jamais.
|
||||
* Un texte null, illisible, d'un autre format ou d'un autre événement que
|
||||
* charge.evenement.id ne confirme rien (entreesDuTexte). Une entrée se
|
||||
* garde quand elle suit sa règle (ENTREE_DE_COURSE), qu'une proposition de
|
||||
* charge porte exactement son identité, qu'aucune entrée gardée avant elle
|
||||
* n'a sa clé, et que sa course ne contredit pas cette proposition
|
||||
* (fauteDeCourse) ; toute autre s'ignore seule. Une course contradictoire
|
||||
* n'entre ainsi jamais en mémoire depuis le disque. charge est celle
|
||||
* qu'examiner rend, dont chaque proposition est cohérente.
|
||||
*
|
||||
* @param {string|null} texte
|
||||
* @param {import('./types.js').Charge} charge
|
||||
* @returns {Map<string, Object>}
|
||||
*/
|
||||
export function lireCourses(texte, charge) {
|
||||
const courses = new Map();
|
||||
const entrees = entreesDuTexte(texte, charge.evenement.id);
|
||||
if (entrees === null) return courses;
|
||||
const proposition = new Map(charge.propositions.map((p) => [cleDeCourse(p), p]));
|
||||
for (const entree of entrees) {
|
||||
if (premiereFaute(entree, ENTREE_DE_COURSE) !== null) continue;
|
||||
const cle = cleDeCourse(entree);
|
||||
const p = proposition.get(cle);
|
||||
if (p === undefined || courses.has(cle) || fauteDeCourse(p, entree.course) !== null) continue;
|
||||
courses.set(cle, entree);
|
||||
}
|
||||
return courses;
|
||||
}
|
||||
|
||||
/**
|
||||
* Une Map neuve : courses, puis chaque entrée d'entrees sous sa clé, la
|
||||
* dernière d'une même clé gardée. courses n'est pas modifiée.
|
||||
*
|
||||
* @param {Map<string, Object>} courses
|
||||
* @param {Object[]} entrees
|
||||
* @returns {Map<string, Object>}
|
||||
*/
|
||||
export function avecCourses(courses, entrees) {
|
||||
const suite = new Map(courses);
|
||||
for (const entree of entrees) suite.set(cleDeCourse(entree), entree);
|
||||
return suite;
|
||||
}
|
||||
|
||||
/**
|
||||
* Les entrées de courses pour les propositions données, dans leur ordre :
|
||||
* celle de la clé de chacune, quand courses la connaît. C'est ce que le
|
||||
* compagnon d'une charge porte.
|
||||
*
|
||||
* @param {Map<string, Object>} courses
|
||||
* @param {Object[]} propositions
|
||||
* @returns {Object[]}
|
||||
*/
|
||||
export function entreesPour(courses, propositions) {
|
||||
return propositions.map((p) => courses.get(cleDeCourse(p))).filter((entree) => entree !== undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* La course de la proposition id de charge, en plans par identifiants
|
||||
* (courseDepuisFichier), ou null : aucune proposition id, aucune course de
|
||||
* son identité dans courses, ou une course qui la contredit. La course se
|
||||
* contrôle ici, contre la proposition que la charge porte : une course
|
||||
* contradictoire n'atteint jamais le détail, d'où qu'elle vienne. charge
|
||||
* est celle d'un Ouvert, dont chaque proposition est cohérente.
|
||||
*
|
||||
* @param {{charge: import('./types.js').Charge, courses: Map<string, Object>}} ouvert
|
||||
* @param {number} id
|
||||
* @returns {{evalues: number, releves: {mouvements: number, plan: import('../moteur/types.js').Plan}[]}|null}
|
||||
*/
|
||||
export function courseDe({ charge, courses }, id) {
|
||||
const p = charge.propositions.find((candidate) => candidate.id === id);
|
||||
if (p === undefined) return null;
|
||||
const entree = courses.get(cleDeCourse(p));
|
||||
if (entree === undefined) return null;
|
||||
try {
|
||||
return courseDepuisFichier(p, entree.course);
|
||||
} catch (erreur) {
|
||||
// courseDepuisFichier lève TypeError sur une course qui contredit p.
|
||||
if (erreur instanceof TypeError) return null;
|
||||
throw erreur;
|
||||
}
|
||||
}
|
||||
283
src/stockage/courses.test.js
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Épreuves du compagnon des courses (§ 5.10, § 8.9, B31) : son texte, sa
|
||||
// lecture qui ne lève jamais et n'admet que les courses que confirment les
|
||||
// propositions de la charge, et l'accès à la course d'une proposition, qui
|
||||
// ne rend jamais une course contradictoire. Les charges d'épreuve reprennent
|
||||
// la petite démonstration et son plan parfait ; leurs noms sont inventés.
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, test } from '../../test/lanceur.js';
|
||||
import { CATALOGUE, PLAN_PARFAIT_PETITE } from '../demo/catalogue.js';
|
||||
import { VERSION } from '../version.genere.js';
|
||||
import {
|
||||
FORMAT_COURSES,
|
||||
avecCourses,
|
||||
cleDeCourse,
|
||||
courseDe,
|
||||
entreeDeCourse,
|
||||
entreesPour,
|
||||
lireCourses,
|
||||
texteDesCourses,
|
||||
} from './courses.js';
|
||||
import { creerCharge } from './document.js';
|
||||
import { courseVersFichier, versFichier } from './placements.js';
|
||||
|
||||
const EVENEMENT = 'evt-mesanges';
|
||||
|
||||
// La configuration de la petite démonstration (§ 15.3), neuve à chaque appel.
|
||||
const petite = () => CATALOGUE.find(({ cle }) => cle === 'petite').construire();
|
||||
|
||||
// La proposition du moteur d'identifiant id sur plan, le plan parfait de la
|
||||
// petite démonstration par défaut, à la graine 100 + id, portant une course
|
||||
// à evalues mouvements : chaque point de relevé reprend les tours du plan
|
||||
// décalés de son rang.
|
||||
function propositionDuMoteur(id, evalues = 120, plan = PLAN_PARFAIT_PETITE) {
|
||||
const { tours } = plan;
|
||||
const releves = [];
|
||||
for (let k = 0, mouvements = 1; mouvements < evalues; k += 1, mouvements *= 10) {
|
||||
const decales = tours.map((_, r) => tours[(r + k + 1) % tours.length]);
|
||||
releves.push({ mouvements, plan: { tables: [1, 2, 3, 4], tours: decales, reserves: decales.map(() => []) } });
|
||||
}
|
||||
return { id, graine: 100 + id, arret: 20_000, historique: 1_000, plan, evalues, releves };
|
||||
}
|
||||
|
||||
// Le plan parfait de la petite démonstration, ses tours dans l'ordre
|
||||
// inverse : un autre placement des mêmes tables et des mêmes personnes.
|
||||
const PLAN_INVERSE = { ...PLAN_PARFAIT_PETITE, tours: [...PLAN_PARFAIT_PETITE.tours].reverse() };
|
||||
|
||||
// Une charge de l'événement EVENEMENT portant les propositions de fichier
|
||||
// des identifiants donnés, sur plan, et l'entrée du compagnon de chacune.
|
||||
function chargeEtEntrees(ids = [1, 2, 3], plan = PLAN_PARFAIT_PETITE) {
|
||||
const charge = creerCharge({ id: EVENEMENT, nom: 'Soirée des Mésanges', siegesParDefaut: 3, tours: 4 });
|
||||
const options = { produitVersion: VERSION.affichee, attribuerSieges: false, decalage: 0 };
|
||||
const entrees = [];
|
||||
charge.propositions = ids.map((id) => {
|
||||
const proposition = propositionDuMoteur(id, 120, plan);
|
||||
const fichier = versFichier(proposition, petite(), options);
|
||||
entrees.push(entreeDeCourse(fichier, courseVersFichier(proposition, fichier, { attribuerSieges: false })));
|
||||
return fichier;
|
||||
});
|
||||
return { charge, entrees };
|
||||
}
|
||||
|
||||
// Le texte du compagnon des entrees, abîmé : abimer reçoit son JSON et le
|
||||
// modifie.
|
||||
function texteAbime(entrees, abimer) {
|
||||
const lu = JSON.parse(texteDesCourses(EVENEMENT, entrees));
|
||||
abimer(lu);
|
||||
return JSON.stringify(lu);
|
||||
}
|
||||
|
||||
const cles = (courses) => [...courses.keys()];
|
||||
|
||||
describe('le compagnon des courses : son texte (§ 8.9, B31)', () => {
|
||||
test("une entrée par ligne, l'identité de la proposition puis sa course ; aucune entrée, une seule ligne ; le texte finit par une fin de ligne", () => {
|
||||
const { entrees } = chargeEtEntrees([1, 2]);
|
||||
const texte = texteDesCourses(EVENEMENT, entrees);
|
||||
const lignes = texte.split('\n');
|
||||
assert.equal(lignes[0], `{"format":${FORMAT_COURSES},"evenement":"${EVENEMENT}","courses":[`);
|
||||
assert.equal(lignes[1], `${JSON.stringify(entrees[0])},`);
|
||||
assert.equal(lignes[2], JSON.stringify(entrees[1]));
|
||||
assert.deepEqual(lignes.slice(3), [']}', '']);
|
||||
assert.deepEqual(Object.keys(entrees[0]), ['id', 'graine', 'arret', 'historique', 'empreinte', 'course']);
|
||||
assert.equal(texteDesCourses(EVENEMENT, []), `{"format":${FORMAT_COURSES},"evenement":"${EVENEMENT}","courses":[]}\n`);
|
||||
assert.equal(FORMAT_COURSES, 1);
|
||||
});
|
||||
|
||||
test('entreeDeCourse ne partage rien avec la course reçue ; cleDeCourse lit l’identité, l’horodatage et l’empreinte du placement', () => {
|
||||
const { charge, entrees } = chargeEtEntrees([1]);
|
||||
const [p] = charge.propositions;
|
||||
const entree = entreeDeCourse(p, entrees[0].course);
|
||||
assert.notEqual(entree.course, entrees[0].course);
|
||||
assert.notEqual(entree.course.releves[0].placement, entrees[0].course.releves[0].placement);
|
||||
assert.equal(cleDeCourse(p), cleDeCourse(entree));
|
||||
assert.notEqual(cleDeCourse({ ...p, graine: p.graine + 1 }), cleDeCourse(p));
|
||||
assert.notEqual(cleDeCourse({ ...p, historique: 7 }), cleDeCourse(p));
|
||||
assert.notEqual(cleDeCourse({ ...p, arret: 7 }), cleDeCourse(p));
|
||||
// La version qui a produit la proposition n'y entre pas ; l'horodatage
|
||||
// de sa génération, si.
|
||||
assert.equal(cleDeCourse({ ...p, produitVersion: 'x' }), cleDeCourse(p));
|
||||
const datee = { ...p, horodatage: '2031-05-17T08:00:00-04:00' };
|
||||
assert.notEqual(cleDeCourse(datee), cleDeCourse(p));
|
||||
assert.notEqual(cleDeCourse({ ...p, horodatage: '2031-05-17T08:00:01-04:00' }), cleDeCourse(datee));
|
||||
assert.equal(Object.hasOwn(entreeDeCourse(p, entrees[0].course), 'horodatage'), false);
|
||||
const entreeDatee = entreeDeCourse(datee, entrees[0].course);
|
||||
assert.deepEqual(Object.keys(entreeDatee), ['id', 'graine', 'arret', 'historique', 'horodatage', 'empreinte', 'course']);
|
||||
assert.equal(cleDeCourse(entreeDatee), cleDeCourse(datee));
|
||||
});
|
||||
|
||||
test("l'empreinte est celle du placement canonique : l'ordre d'une liste de table sans sièges attribués ni celui d'une réserve n'y entre pas", () => {
|
||||
const { charge } = chargeEtEntrees([1]);
|
||||
const [p] = charge.propositions;
|
||||
assert.equal(p.siegesAttribues, false);
|
||||
const melange = structuredClone(p);
|
||||
melange.placement[0].sieges[0].reverse();
|
||||
assert.equal(cleDeCourse(melange), cleDeCourse(p));
|
||||
assert.notEqual(cleDeCourse({ ...melange, siegesAttribues: true }), cleDeCourse(p));
|
||||
assert.ok(Number.isSafeInteger(entreeDeCourse(p, { evalues: 0, releves: [] }).empreinte));
|
||||
});
|
||||
});
|
||||
|
||||
describe("le compagnon des courses : une identité désigne une seule recherche (§ 8.9, B31)", () => {
|
||||
// Une génération défaite remet le compteur des propositions en arrière, et
|
||||
// la graine proposée repart de la même valeur : après un changement qui
|
||||
// laisse la déclaration intacte — une appartenance, une réservation, un
|
||||
// réglage —, la recherche suivante porte les mêmes id, graine, arret et
|
||||
// historique, et un autre placement.
|
||||
test('une proposition régénérée sous la même identité, sur un autre placement, ne reçoit jamais la course de la première', () => {
|
||||
const premiere = chargeEtEntrees([1, 2]);
|
||||
const seconde = chargeEtEntrees([1, 2], PLAN_INVERSE);
|
||||
const [p1] = premiere.charge.propositions;
|
||||
const [q1] = seconde.charge.propositions;
|
||||
assert.deepEqual([q1.id, q1.graine, q1.arret, q1.historique], [p1.id, p1.graine, p1.arret, p1.historique]);
|
||||
assert.notDeepEqual(q1.placement, p1.placement);
|
||||
assert.notEqual(cleDeCourse(q1), cleDeCourse(p1));
|
||||
|
||||
// En mémoire, les deux courses coexistent, chacune sous sa proposition.
|
||||
const courses = avecCourses(avecCourses(new Map(), premiere.entrees), seconde.entrees);
|
||||
assert.equal(courses.size, 4);
|
||||
assert.deepEqual(courseDe({ charge: premiere.charge, courses }, 1).releves[0].plan.tours[0], PLAN_PARFAIT_PETITE.tours[1]);
|
||||
assert.deepEqual(courseDe({ charge: seconde.charge, courses }, 1).releves[0].plan.tours[0], PLAN_INVERSE.tours[1]);
|
||||
// Sans la course de la première, sa proposition n'en a aucune.
|
||||
assert.equal(courseDe({ charge: premiere.charge, courses: avecCourses(new Map(), seconde.entrees) }, 1), null);
|
||||
|
||||
// Le compagnon de la seconde ne confirme rien pour la première.
|
||||
assert.deepEqual(cles(lireCourses(texteDesCourses(EVENEMENT, seconde.entrees), premiere.charge)), []);
|
||||
// Ni l'inverse.
|
||||
assert.deepEqual(cles(lireCourses(texteDesCourses(EVENEMENT, premiere.entrees), seconde.charge)), []);
|
||||
// Ce que le compagnon de la première porterait se reconnaît.
|
||||
assert.deepEqual(entreesPour(courses, premiere.charge.propositions), premiere.entrees);
|
||||
});
|
||||
|
||||
// La recherche suivante peut aussi finir sur le même placement par une
|
||||
// autre course : seul l'horodatage de sa génération la départage.
|
||||
test("une proposition régénérée sous la même identité, sur le même placement et à un autre instant, ne reçoit jamais la course de la première", () => {
|
||||
const dater = ({ charge, entrees }, horodatage) => {
|
||||
charge.propositions = charge.propositions.map((p) => ({ ...p, horodatage }));
|
||||
const course = (p) => entrees.find(({ id }) => id === p.id).course;
|
||||
return { charge, entrees: charge.propositions.map((p) => entreeDeCourse(p, course(p))) };
|
||||
};
|
||||
const premiere = dater(chargeEtEntrees([1, 2]), '2031-05-17T08:02:00-04:00');
|
||||
const seconde = dater(chargeEtEntrees([1, 2]), '2031-05-17T08:05:00-04:00');
|
||||
for (const entree of seconde.entrees) entree.course.evalues -= 1;
|
||||
const [p1] = premiere.charge.propositions;
|
||||
const [q1] = seconde.charge.propositions;
|
||||
assert.deepEqual(q1.placement, p1.placement);
|
||||
assert.notEqual(cleDeCourse(q1), cleDeCourse(p1));
|
||||
|
||||
const courses = avecCourses(avecCourses(new Map(), premiere.entrees), seconde.entrees);
|
||||
assert.equal(courses.size, 4);
|
||||
assert.equal(courseDe({ charge: premiere.charge, courses }, 1).evalues, 120);
|
||||
assert.equal(courseDe({ charge: seconde.charge, courses }, 1).evalues, 119);
|
||||
assert.equal(courseDe({ charge: premiere.charge, courses: avecCourses(new Map(), seconde.entrees) }, 1), null);
|
||||
assert.deepEqual(cles(lireCourses(texteDesCourses(EVENEMENT, seconde.entrees), premiere.charge)), []);
|
||||
assert.deepEqual(cles(lireCourses(texteDesCourses(EVENEMENT, premiere.entrees), seconde.charge)), []);
|
||||
|
||||
// Sans horodatage, les deux propositions ont la même clé : l'identité ne
|
||||
// les départage plus. Une entrée sans horodatage ne vaut pas pour une
|
||||
// proposition datée, ni l'inverse.
|
||||
const sansDate = chargeEtEntrees([1, 2]);
|
||||
assert.equal(cleDeCourse(sansDate.charge.propositions[0]), cleDeCourse({ ...q1, horodatage: undefined }));
|
||||
assert.deepEqual(cles(lireCourses(texteDesCourses(EVENEMENT, sansDate.entrees), premiere.charge)), []);
|
||||
assert.deepEqual(cles(lireCourses(texteDesCourses(EVENEMENT, premiere.entrees), sansDate.charge)), []);
|
||||
});
|
||||
});
|
||||
|
||||
describe('le compagnon des courses : sa lecture, qui ne lève jamais (§ 8.9, B31)', () => {
|
||||
test('relu, le texte rend chaque entrée sous la clé de sa proposition, dans l’ordre du fichier', () => {
|
||||
const { charge, entrees } = chargeEtEntrees();
|
||||
const courses = lireCourses(texteDesCourses(EVENEMENT, entrees), charge);
|
||||
assert.deepEqual(cles(courses), charge.propositions.map(cleDeCourse));
|
||||
assert.deepEqual([...courses.values()], entrees);
|
||||
// Une marque d'ordre d'octets en tête ne change rien.
|
||||
assert.deepEqual(lireCourses(`\u{FEFF}${texteDesCourses(EVENEMENT, entrees)}`, charge), courses);
|
||||
});
|
||||
|
||||
test("absent, vide, hors JSON, d'une autre forme, d'un autre format ou d'un autre événement : aucune course, sans lever", () => {
|
||||
const { charge, entrees } = chargeEtEntrees();
|
||||
const textes = [
|
||||
null,
|
||||
'',
|
||||
'pas du JSON',
|
||||
texteDesCourses(EVENEMENT, entrees).slice(0, -40),
|
||||
'null',
|
||||
'[]',
|
||||
texteAbime(entrees, (lu) => { lu.format = FORMAT_COURSES + 1; }),
|
||||
texteAbime(entrees, (lu) => { lu.format = 0; }),
|
||||
texteAbime(entrees, (lu) => { delete lu.format; }),
|
||||
texteAbime(entrees, (lu) => { lu.evenement = 'evt-autre'; }),
|
||||
texteAbime(entrees, (lu) => { lu.courses = {}; }),
|
||||
texteAbime(entrees, (lu) => { lu.duree = 3; }),
|
||||
];
|
||||
for (const texte of textes) assert.deepEqual(cles(lireCourses(texte, charge)), [], String(texte).slice(0, 60));
|
||||
});
|
||||
|
||||
test("une entrée hors de sa règle, sans proposition de son identité, répétée ou dont la course contredit sa proposition s'écarte seule ; les autres restent", () => {
|
||||
const { charge, entrees } = chargeEtEntrees();
|
||||
const [une, deux, trois] = charge.propositions.map(cleDeCourse);
|
||||
const cas = [
|
||||
['une durée dans la course', (lu) => { lu.courses[1].course.releves[0].duree = 3; }, [une, trois]],
|
||||
['un identifiant hors de sa règle', (lu) => { lu.courses[1].id = 0; }, [une, trois]],
|
||||
['une autre graine : une proposition périmée', (lu) => { lu.courses[0].graine += 1; }, [deux, trois]],
|
||||
['un autre historique', (lu) => { lu.courses[2].historique = 7; }, [une, deux]],
|
||||
['une autre empreinte : la course d’un autre placement', (lu) => { lu.courses[1].empreinte += 1; }, [une, trois]],
|
||||
['une empreinte absente', (lu) => { delete lu.courses[0].empreinte; }, [deux, trois]],
|
||||
['une empreinte hors de sa règle', (lu) => { lu.courses[2].empreinte = '7'; }, [une, deux]],
|
||||
['un horodatage que sa proposition ne porte pas', (lu) => {
|
||||
lu.courses[0].horodatage = '2031-05-17T08:00:00-04:00';
|
||||
}, [deux, trois]],
|
||||
['un horodatage hors de sa règle', (lu) => { lu.courses[1].horodatage = null; }, [une, trois]],
|
||||
['un identifiant absent de la charge', (lu) => { lu.courses[2].id = 9; }, [une, deux]],
|
||||
['evalues au-delà de arret', (lu) => { lu.courses[0].course.evalues = 20_001; }, [deux, trois]],
|
||||
['un doublon dans un placement de relevé', (lu) => {
|
||||
lu.courses[1].course.releves[1].placement[0].reserve.push(1);
|
||||
}, [une, trois]],
|
||||
['une entrée répétée : la seconde', (lu) => { lu.courses.push(structuredClone(lu.courses[0])); }, [une, deux, trois]],
|
||||
['une entrée qui n’est pas un objet', (lu) => { lu.courses[1] = 3; }, [une, trois]],
|
||||
];
|
||||
const ecarts = cas.flatMap(([libelle, abimer, attendues]) => {
|
||||
const lues = cles(lireCourses(texteAbime(entrees, abimer), charge));
|
||||
return JSON.stringify(lues) === JSON.stringify(attendues) ? [] : [`${libelle} : ${JSON.stringify(lues)}`];
|
||||
});
|
||||
assert.deepEqual(ecarts, []);
|
||||
});
|
||||
});
|
||||
|
||||
describe("le compagnon des courses : la course d'une proposition (§ 8.9, B31)", () => {
|
||||
test('courseDe rend la course en plans par identifiants, ou null : proposition absente, course inconnue ou d’une autre identité', () => {
|
||||
const { charge, entrees } = chargeEtEntrees();
|
||||
const courses = avecCourses(new Map(), entrees.slice(0, 2));
|
||||
const course = courseDe({ charge, courses }, 2);
|
||||
assert.equal(course.evalues, 120);
|
||||
assert.deepEqual(course.releves.map(({ mouvements }) => mouvements), [1, 10, 100]);
|
||||
assert.deepEqual(course.releves[0].plan.tables, [1, 2, 3, 4]);
|
||||
assert.equal(courseDe({ charge, courses }, 3), null);
|
||||
assert.equal(courseDe({ charge, courses }, 9), null);
|
||||
const autreGraine = structuredClone(charge);
|
||||
autreGraine.propositions[1].graine += 1;
|
||||
assert.equal(courseDe({ charge: autreGraine, courses }, 2), null);
|
||||
});
|
||||
|
||||
test("une course contradictoire n'atteint jamais le détail : courseDe la relit et rend null, d'où qu'elle vienne", () => {
|
||||
const { charge, entrees } = chargeEtEntrees();
|
||||
const forgee = structuredClone(entrees[0]);
|
||||
forgee.course.releves[2].placement[3].reserve.push(1);
|
||||
assert.equal(courseDe({ charge, courses: avecCourses(new Map(), [forgee]) }, 1), null);
|
||||
});
|
||||
|
||||
test("avecCourses rend une table neuve, la plus récente de deux entrées d'une même clé gardée ; entreesPour rend les entrées des propositions données, dans leur ordre, sans celles qu'elle ne connaît pas", () => {
|
||||
const { charge, entrees } = chargeEtEntrees();
|
||||
const avant = avecCourses(new Map(), entrees.slice(0, 1));
|
||||
const apres = avecCourses(avant, entrees.slice(1));
|
||||
assert.notEqual(apres, avant);
|
||||
assert.deepEqual(cles(avant), [cleDeCourse(entrees[0])]);
|
||||
assert.deepEqual(cles(apres), entrees.map(cleDeCourse));
|
||||
const remplacee = { ...entrees[0], course: { evalues: 0, releves: [] } };
|
||||
assert.equal(avecCourses(apres, [remplacee]).get(cleDeCourse(remplacee)), remplacee);
|
||||
const inverses = [...charge.propositions].reverse();
|
||||
assert.deepEqual(entreesPour(apres, inverses), [...entrees].reverse());
|
||||
assert.deepEqual(entreesPour(avant, charge.propositions), [entrees[0]]);
|
||||
assert.deepEqual(entreesPour(apres, []), []);
|
||||
});
|
||||
});
|
||||
|
|
@ -96,7 +96,18 @@
|
|||
// journal que l'identifiant apparie, l'Ouvert l'annonce, JOURNAL_ILLISIBLE,
|
||||
// et la première écriture pose l'historique sous une base libre, comme pour
|
||||
// le journal d'un autre événement.
|
||||
//
|
||||
// Le compagnon des courses, <base>.gtt-courses.json (courses.js), suit la
|
||||
// base de l'état. L'ouverture le lit sans jamais s'y arrêter : absent,
|
||||
// illisible ou contradictoire, il laisse inconnues les courses qu'il ne
|
||||
// confirme pas. L'Ouvert garde en mémoire les courses lues et celles des
|
||||
// générations de la séance, y compris celles de propositions qu'un geste a
|
||||
// retirées : un retour qui les rend retrouve leur course. Une écriture pose
|
||||
// le compagnon après l'état, et seulement quand les courses connues des
|
||||
// propositions écrites ne sont plus celles qu'il porte (poserCompagnon) ;
|
||||
// un refus d'écrire le compagnon ne refuse pas l'écriture.
|
||||
import { canoniser, serialiser } from './canonique.js';
|
||||
import { avecCourses, cleDeCourse, entreesPour, lireCourses, texteDesCourses } from './courses.js';
|
||||
import { FORMAT, analyser } from './document.js';
|
||||
import { ErreurStockage } from './erreurs.js';
|
||||
import {
|
||||
|
|
@ -154,6 +165,13 @@ import { examiner } from './placements.js';
|
|||
* @property {boolean} etatMalEncode l'état sur le disque, à côté du secours
|
||||
* ouvert, n'est pas de l'UTF-8 : le premier geste le met de côté
|
||||
* dans la corbeille (mettreDeCoteEtatMalEncode) avant d'écrire
|
||||
* @property {Map<string, Object>} courses les courses de la recherche que
|
||||
* l'Ouvert connaît, par cleDeCourse (courses.js) : celles que le
|
||||
* compagnon confirme à l'ouverture, et celles des générations de
|
||||
* la séance, même d'une proposition retirée depuis
|
||||
* @property {string[]|null} compagnon les clés des courses que le
|
||||
* compagnon sur le disque porte, null quand une écriture refusée
|
||||
* le laisse inconnu : la prochaine écriture le pose
|
||||
* @property {import('./placements.js').Examen} examen
|
||||
* @property {Array<{code: string, details: Object}>} avertissements dans
|
||||
* cet ordre, chacun au plus une fois : RETENU_FAUTIF {}, un retenu
|
||||
|
|
@ -203,9 +221,16 @@ import { examiner } from './placements.js';
|
|||
* le même identifiant
|
||||
*/
|
||||
|
||||
// Suffixes des fichiers d'un événement : une base que l'un d'eux porte dans
|
||||
// un dossier n'est pas libre.
|
||||
const SUFFIXES_EVENEMENT = [SUFFIXES.etat, SUFFIXES.precedent, SUFFIXES.journal, SUFFIXES.verrou];
|
||||
// Suffixes des fichiers d'un événement, compagnon des courses compris : une
|
||||
// base que l'un d'eux porte dans un dossier n'est pas libre. Un compagnon
|
||||
// resté seul ne passe ainsi jamais à un autre événement.
|
||||
const SUFFIXES_EVENEMENT = [
|
||||
SUFFIXES.etat,
|
||||
SUFFIXES.precedent,
|
||||
SUFFIXES.journal,
|
||||
SUFFIXES.verrou,
|
||||
SUFFIXES.courses,
|
||||
];
|
||||
|
||||
// Deux chaînes, comparées unité UTF-16 par unité.
|
||||
const comparerTextes = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
||||
|
|
@ -334,9 +359,9 @@ function formatPlusRecentDe(texte) {
|
|||
|
||||
/**
|
||||
* Bases que portent les fichiers d'événement — état, .precedent, journal,
|
||||
* verrou — à la racine de racine, dans l'ordre de sa liste, sans rien
|
||||
* écrire : une base que l'un d'eux porte n'est pas libre (deriverBase). Une
|
||||
* base revient une fois par fichier qui la porte.
|
||||
* verrou, compagnon des courses — à la racine de racine, dans l'ordre de sa
|
||||
* liste, sans rien écrire : une base que l'un d'eux porte n'est pas libre
|
||||
* (deriverBase). Une base revient une fois par fichier qui la porte.
|
||||
*
|
||||
* @param {import('./systeme_fichiers.js').SystemeFichiers} fs
|
||||
* @param {import('./systeme_fichiers.js').Racine} racine
|
||||
|
|
@ -382,6 +407,17 @@ function vueDe(fs, racine) {
|
|||
journal: async (chemin) => journalLisible(await fichier(chemin)),
|
||||
// Vrai quand le fichier existe, que ses octets soient de l'UTF-8 ou non.
|
||||
present: async (chemin) => (await fichier(chemin)).present,
|
||||
// Le texte du fichier, null quand il manque, que ses octets ne sont pas
|
||||
// de l'UTF-8 ou que sa lecture est refusée : la lecture qui ne doit
|
||||
// jamais refuser l'ouverture, celle du compagnon des courses.
|
||||
texteSiLisible: async (chemin) => {
|
||||
try {
|
||||
return (await fichier(chemin)).texte;
|
||||
} catch (erreur) {
|
||||
if (erreur instanceof ErreurStockage) return null;
|
||||
throw erreur;
|
||||
}
|
||||
},
|
||||
// { texte, lecture, details } d'un fichier d'état ou d'un .precedent :
|
||||
// les trois null quand il manque ; texte et lecture null, details
|
||||
// {raison: 'ENCODAGE', chemin: null}, quand ses octets ne sont pas de
|
||||
|
|
@ -603,13 +639,17 @@ export function rattraper(etat, instant) {
|
|||
// rattraper. La source d'un format plus récent, ou un journal d'un format
|
||||
// plus récent, gardent l'Ouvert en lecture seule. Un journal dont la lecture
|
||||
// a écarté des lignes, ou d'un format plus ancien, se réécrira en entier à
|
||||
// la prochaine écriture (journalAReecrire).
|
||||
function assembler({ fs, racine, base, source, trouve, instant, avecRattrapage }) {
|
||||
// la prochaine écriture (journalAReecrire). texteCourses, le texte du
|
||||
// compagnon des courses de la base, null quand il ne se lit pas : les
|
||||
// courses qu'il confirme pour la charge examinée (lireCourses) sont celles
|
||||
// de l'Ouvert, et compagnon leurs clés.
|
||||
function assembler({ fs, racine, base, source, trouve, instant, avecRattrapage, texteCourses }) {
|
||||
const lu = avecRattrapage ? rattraper(source, instant) : { ...source, rattrape: false };
|
||||
const { charge, revision, produitVersion, manquants, rattrape } = lu;
|
||||
const journal = instant?.journal ?? null;
|
||||
const ecartees = journal?.ecartees ?? trouve.lu?.ecartees ?? 0;
|
||||
const examen = examiner(charge);
|
||||
const courses = lireCourses(texteCourses, examen.charge);
|
||||
return {
|
||||
fs,
|
||||
racine,
|
||||
|
|
@ -625,6 +665,8 @@ function assembler({ fs, racine, base, source, trouve, instant, avecRattrapage }
|
|||
journalAReecrire: journal !== null && (ecartees > 0 || journal.format < FORMAT),
|
||||
formatPlusRecent: source.formatPlusRecent || trouve.format !== null,
|
||||
etatMalEncode: source.etatMalEncode,
|
||||
courses,
|
||||
compagnon: [...courses.keys()],
|
||||
examen,
|
||||
avertissements: avertir({
|
||||
examen,
|
||||
|
|
@ -776,7 +818,9 @@ export async function ouvrir(fs, racine, base) {
|
|||
if (etat.lecture === null) throw await refuser(vue, base, etat.details ?? { raison: 'ABSENT', chemin: null });
|
||||
const trouve = await journalDe(vue, base, etat.lecture.charge.evenement.id);
|
||||
const source = depuisLecture(etat.lecture, surDisque(etat.texte, etat));
|
||||
return assembler({ fs, racine, base, source, trouve, instant: instantDe(trouve.lu), avecRattrapage: true });
|
||||
const texteCourses = await vue.texteSiLisible(base + SUFFIXES.courses);
|
||||
const instant = instantDe(trouve.lu);
|
||||
return assembler({ fs, racine, base, source, trouve, instant, avecRattrapage: true, texteCourses });
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -829,7 +873,9 @@ export async function ouvrirSecours(fs, racine, base, source) {
|
|||
const { lecture } = precedent;
|
||||
const trouve = await journalDe(vue, base, lecture.charge.evenement.id);
|
||||
const depuis = depuisLecture(lecture, disque);
|
||||
return assembler({ fs, racine, base, source: depuis, trouve, instant: instantDe(trouve.lu), avecRattrapage: false });
|
||||
const texteCourses = await vue.texteSiLisible(base + SUFFIXES.courses);
|
||||
const instant = instantDe(trouve.lu);
|
||||
return assembler({ fs, racine, base, source: depuis, trouve, instant, avecRattrapage: false, texteCourses });
|
||||
}
|
||||
const trouve = await (etat.lecture === null
|
||||
? journalSansEtat(vue, base)
|
||||
|
|
@ -853,7 +899,8 @@ export async function ouvrirSecours(fs, racine, base, source) {
|
|||
manquants: AUCUN_MANQUANT,
|
||||
...disque,
|
||||
};
|
||||
return assembler({ fs, racine, base, source: depuis, trouve, instant, avecRattrapage: false });
|
||||
const texteCourses = await vue.texteSiLisible(base + SUFFIXES.courses);
|
||||
return assembler({ fs, racine, base, source: depuis, trouve, instant, avecRattrapage: false, texteCourses });
|
||||
}
|
||||
|
||||
// Un Ouvert en lecture seule — un fichier qu'il a lu est d'un format plus
|
||||
|
|
@ -1128,6 +1175,35 @@ async function ecrireEtat(avance, texteEtat) {
|
|||
}
|
||||
}
|
||||
|
||||
// Vrai quand deux listes de clés portent les mêmes clés, quel que soit
|
||||
// leur ordre.
|
||||
const memesCles = (a, b) => a.length === b.length && [...a].sort().join('\n') === [...b].sort().join('\n');
|
||||
|
||||
// Pose le compagnon des courses de l'Ouvert écrit, dont l'état est sur le
|
||||
// disque, et rend l'Ouvert qui le dit (compagnon). Il porte les courses
|
||||
// connues des propositions de la charge, dans leur ordre (entreesPour), et
|
||||
// ne s'écrit que quand leurs clés ne sont pas celles que le compagnon sur
|
||||
// le disque porte, ou que celles-ci sont inconnues : une génération, un
|
||||
// effacement, un retour qui rend ou retire des propositions, et l'écriture
|
||||
// qui suit un compagnon refusé. Un geste qui garde les mêmes propositions
|
||||
// ne l'écrit jamais. L'écriture est atomique, comme celle de l'état. Un
|
||||
// refus du système de fichiers ne remonte pas : le compagnon reste ce
|
||||
// qu'il était, l'Ouvert le dit inconnu, et la course reste connue de
|
||||
// l'Ouvert pour la séance. Toute autre erreur remonte.
|
||||
async function poserCompagnon(ouvert) {
|
||||
const entrees = entreesPour(ouvert.courses, ouvert.charge.propositions);
|
||||
const cles = entrees.map(cleDeCourse);
|
||||
if (ouvert.compagnon !== null && memesCles(cles, ouvert.compagnon)) return ouvert;
|
||||
const { fs, racine, base, charge } = ouvert;
|
||||
try {
|
||||
await fs.ecrireAtomique(racine, base + SUFFIXES.courses, texteDesCourses(charge.evenement.id, entrees));
|
||||
} catch (erreur) {
|
||||
if (!(erreur instanceof ErreurStockage)) throw erreur;
|
||||
return { ...ouvert, compagnon: null };
|
||||
}
|
||||
return { ...ouvert, compagnon: cles };
|
||||
}
|
||||
|
||||
// L'Ouvert d'une charge écrite : examinée, ses avertissements ceux de ses
|
||||
// placements, d'un journal qui finit ailleurs que sur elle, et du journal
|
||||
// disparu que l'écriture a réécrit (recree).
|
||||
|
|
@ -1175,12 +1251,15 @@ function ligneDuGeste(preparation, apres, { libelle, horodatage, produitVersion,
|
|||
* Crée l'événement de charge dans racine (§ 8.1, § 8.2) : sa base dérive de
|
||||
* son nom (deriverBase), libre dans le dossier. Le journal d'abord, écrit
|
||||
* d'un bloc — ouverture, entrée 1 en instantané, jalon nomJalon sur elle —,
|
||||
* puis l'état, à la révision 1.
|
||||
* puis l'état, à la révision 1, puis, quand courses en porte pour les
|
||||
* propositions de la charge, le compagnon des courses (poserCompagnon).
|
||||
*
|
||||
* @param {import('./systeme_fichiers.js').SystemeFichiers} fs
|
||||
* @param {import('./systeme_fichiers.js').Racine} racine
|
||||
* @param {import('./types.js').Charge} charge
|
||||
* @param {{libelle: string, nomJalon: string, horodatage: string, produitVersion: string}} options
|
||||
* @param {{libelle: string, nomJalon: string, horodatage: string, produitVersion: string, courses?: Object[]}} options
|
||||
* courses : les entrées du compagnon des courses que l'événement
|
||||
* reçoit — celles de l'événement copié —, aucune par défaut
|
||||
* @returns {Promise<Ouvert>}
|
||||
* @throws {TypeError} une charge, un libellé, un nom ou un horodatage que la
|
||||
* lecture refuserait, avant toute écriture
|
||||
|
|
@ -1188,7 +1267,7 @@ function ligneDuGeste(preparation, apres, { libelle, horodatage, produitVersion,
|
|||
* n'est écrit ; ETAT_NON_ECRIT : le journal est écrit, l'état non ;
|
||||
* CHEMIN_TROP_LONG (deriverBase) ; ABSENT : racine n'existe pas
|
||||
*/
|
||||
export async function creer(fs, racine, charge, { libelle, nomJalon, horodatage, produitVersion }) {
|
||||
export async function creer(fs, racine, charge, { libelle, nomJalon, horodatage, produitVersion, courses = [] }) {
|
||||
const base = await deriver(fs, racine, charge.evenement.nom, []);
|
||||
const entree = ligneEntree({ revision: 1, libelle, horodatage, produitVersion, apres: charge, instantane: true });
|
||||
const jalon = ligneJalon({ revision: 1, nom: nomJalon, horodatage });
|
||||
|
|
@ -1209,8 +1288,10 @@ export async function creer(fs, racine, charge, { libelle, nomJalon, horodatage,
|
|||
journalAReecrire: false,
|
||||
formatPlusRecent: false,
|
||||
etatMalEncode: false,
|
||||
courses: avecCourses(new Map(), courses),
|
||||
compagnon: [],
|
||||
};
|
||||
return ecrireEtat(apresEcriture(cree, { charge, revision: 1, journal }), texteEtat);
|
||||
return poserCompagnon(await ecrireEtat(apresEcriture(cree, { charge, revision: 1, journal }), texteEtat));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1238,6 +1319,13 @@ export async function creer(fs, racine, charge, { libelle, nomJalon, horodatage,
|
|||
* Un geste refusé n'écrit rien : chaque ligne et chaque texte d'état se
|
||||
* calculent, et se relisent, avant la première écriture.
|
||||
*
|
||||
* (5) Après l'état, le compagnon des courses, quand les courses connues des
|
||||
* propositions écrites ne sont plus celles qu'il porte (poserCompagnon) :
|
||||
* une génération, un effacement, un retour qui rend ou retire des
|
||||
* propositions. courses, les entrées que le geste apporte — celles d'une
|
||||
* génération —, s'ajoutent aux courses de l'Ouvert. Un refus d'écrire le
|
||||
* compagnon ne refuse pas le geste.
|
||||
*
|
||||
* @param {Ouvert} ouvert
|
||||
* @param {import('./types.js').Charge} apres
|
||||
* @param {Object} options
|
||||
|
|
@ -1248,6 +1336,9 @@ export async function creer(fs, racine, charge, { libelle, nomJalon, horodatage,
|
|||
* @param {number|null} [options.retour] révision visée par un retour
|
||||
* @param {string} [options.libelleReprise] texte de l'entrée de reprise,
|
||||
* requis quand elle a lieu
|
||||
* @param {Object[]} [options.courses] entrées du compagnon des courses
|
||||
* des propositions que le geste ajoute (courses.js), aucune par
|
||||
* défaut
|
||||
* @returns {Promise<Ouvert>}
|
||||
* @throws {ErreurStockage} LECTURE_SEULE {raison: 'FORMAT_PLUS_RECENT'} ;
|
||||
* ce que lève la mise de côté d'un état aux octets hors UTF-8
|
||||
|
|
@ -1263,7 +1354,7 @@ export async function creer(fs, racine, charge, { libelle, nomJalon, horodatage,
|
|||
* refuserait, avant toute écriture
|
||||
*/
|
||||
export async function enregistrer(ouvert, apres, options) {
|
||||
const { horodatage, produitVersion, libelleReprise } = options;
|
||||
const { horodatage, produitVersion, libelleReprise, courses = [] } = options;
|
||||
exigerEcriture(ouvert);
|
||||
const preparation = preparer(ouvert, { libelleReprise, horodatage });
|
||||
const { ligne, revision } = ligneDuGeste(preparation, apres, options);
|
||||
|
|
@ -1272,8 +1363,10 @@ export async function enregistrer(ouvert, apres, options) {
|
|||
const depart = await mettreDeCoteEtatMalEncode(ouvert, horodatage);
|
||||
const suite = aligne === null ? { depart, preparation } : await aligner(depart, preparation, aligne);
|
||||
const { recree, ...ecrit } = await ecrireJournal(suite.depart, suite.preparation, ligne);
|
||||
const avance = { ...suite.depart, ...ecrit, produitVersion, chargeJournal: canoniser(apres) };
|
||||
return ecrireEtat(apresEcriture(avance, { charge: apres, revision, journal: ecrit.journal, recree }), texteEtat);
|
||||
const memoire = avecCourses(ouvert.courses, courses);
|
||||
const avance = { ...suite.depart, ...ecrit, produitVersion, chargeJournal: canoniser(apres), courses: memoire };
|
||||
const examine = apresEcriture(avance, { charge: apres, revision, journal: ecrit.journal, recree });
|
||||
return poserCompagnon(await ecrireEtat(examine, texteEtat));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1387,8 +1480,11 @@ function paireAilleurs(ouvert, { libelleReprise, horodatage, geste }) {
|
|||
* de la base n'est pas le sien — ne s'apparie le journal neuf (apparier). La
|
||||
* paire va sous une base qu'aucun fichier du dossier ne porte : elle n'y
|
||||
* remplace rien, pas même la paire de l'Ouvert quand ce dossier est le sien.
|
||||
* Les lignes et les textes d'état se calculent, et se relisent, avant la
|
||||
* première écriture, celle de la racine comprise.
|
||||
* Après l'état, la nouvelle base reçoit le compagnon des courses que
|
||||
* l'Ouvert connaît pour les propositions écrites, quand il en connaît
|
||||
* (poserCompagnon) : le renommage et l'écriture ailleurs l'emportent avec
|
||||
* la paire. Les lignes et les textes d'état se calculent, et se relisent,
|
||||
* avant la première écriture, celle de la racine comprise.
|
||||
*
|
||||
* @param {Ouvert} ouvert
|
||||
* @param {import('./systeme_fichiers.js').Racine} racine
|
||||
|
|
@ -1446,6 +1542,7 @@ export async function enregistrerAilleurs(
|
|||
chargeJournal: paire.chargeJournal,
|
||||
journalAReecrire: false,
|
||||
etatMalEncode: false,
|
||||
compagnon: [],
|
||||
};
|
||||
return ecrireEtat(apresEcriture(ailleurs, { charge, revision, journal }), texteEtat);
|
||||
return poserCompagnon(await ecrireEtat(apresEcriture(ailleurs, { charge, revision, journal }), texteEtat));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import {
|
|||
ouvrirSecours,
|
||||
verrouiller,
|
||||
} from './depot.js';
|
||||
import { courseDe, entreeDeCourse, texteDesCourses } from './courses.js';
|
||||
import { FORMAT, analyser, creerCharge } from './document.js';
|
||||
import { ErreurStockage } from './erreurs.js';
|
||||
import { PLAFOND_ENTREES, elaguer, fil, ligneJalon, ligneOuverture, lireJournal, reconstruire } from './journal.js';
|
||||
|
|
@ -47,6 +48,7 @@ const ETAT = `${BASE}${SUFFIXES.etat}`;
|
|||
const PRECEDENT = `${BASE}${SUFFIXES.precedent}`;
|
||||
const JOURNAL = `${BASE}${SUFFIXES.journal}`;
|
||||
const VERROU = `${BASE}${SUFFIXES.verrou}`;
|
||||
const COURSES = `${BASE}${SUFFIXES.courses}`;
|
||||
|
||||
const REPRISE = "Reprise de l'état trouvé sur le disque";
|
||||
|
||||
|
|
@ -2215,6 +2217,162 @@ describe('dépôt : propositions fautives et périmées, retenu fautif (§ 8.9)'
|
|||
});
|
||||
});
|
||||
|
||||
// L'entrée du compagnon des courses du placement p, à evalues mouvements
|
||||
// évalués : un point de relevé à chaque puissance de dix strictement sous
|
||||
// evalues, chacun une copie du placement de p (§ 8.9, B31).
|
||||
function entreeDe(p, evalues = 100) {
|
||||
const releves = [];
|
||||
for (let mouvements = 1; mouvements < evalues; mouvements *= 10) {
|
||||
releves.push({ mouvements, placement: structuredClone(p.placement) });
|
||||
}
|
||||
return entreeDeCourse(p, { evalues, releves });
|
||||
}
|
||||
|
||||
// La charge reçue, les propositions 2 et 3 ajoutées et le compteur relevé,
|
||||
// et les entrées de leurs courses : ce qu'enregistre une génération.
|
||||
function generation(charge) {
|
||||
const apres = structuredClone(charge);
|
||||
const nouvelles = [placement(2, [[1, 2], [3, 4]]), placement(3, [[1, 4], [2, 3]])];
|
||||
apres.propositions.push(...nouvelles);
|
||||
apres.prochainsIds.proposition = 4;
|
||||
return { apres, courses: [entreeDe(nouvelles[0]), entreeDe(nouvelles[1], 1_000)] };
|
||||
}
|
||||
|
||||
// Le texte du compagnon des courses de l'événement d'épreuve.
|
||||
const compagnon = (entrees) => texteDesCourses('evt-lucioles', entrees);
|
||||
|
||||
describe('dépôt : le compagnon des courses (§ 8.6, § 8.9, B31)', () => {
|
||||
test("une génération écrit le compagnon après l'état ; un geste qui garde les mêmes propositions ne l'écrit pas ; rouvert, l'événement retrouve chaque course", async () => {
|
||||
const { disque, fs } = nouveauDisque();
|
||||
const trace = tracer(fs);
|
||||
const ouvert = await creer(fs, DOC, chargeEssai(), CREATION);
|
||||
assert.equal(fs.contenu('documents', COURSES), null, 'une création sans course connue écrit un compagnon');
|
||||
assert.equal(courseDe(ouvert, 1), null);
|
||||
|
||||
trace.length = 0;
|
||||
const { apres, courses } = generation(ouvert.charge);
|
||||
const genere = await enregistrer(ouvert, apres, geste(2, { courses }));
|
||||
assert.deepEqual(trace.slice(-2), [['ecrireAtomique', ETAT], ['ecrireAtomique', COURSES]]);
|
||||
assert.equal(fs.contenu('documents', COURSES), compagnon(courses));
|
||||
assert.equal(courseDe(genere, 3).evalues, 1_000);
|
||||
assert.equal(courseDe(genere, 1), null);
|
||||
// Le fichier d'état et le journal ne portent aucune course.
|
||||
assert.equal(fs.contenu('documents', ETAT).includes('evalues'), false);
|
||||
assert.equal(fs.contenu('documents', JOURNAL).includes('evalues'), false);
|
||||
|
||||
trace.length = 0;
|
||||
let suite = genere;
|
||||
for (const n of [3, 4, 5]) suite = await enregistrer(suite, gesteSur(suite.charge, n), geste(n));
|
||||
suite = await nommer(suite, { nom: 'Après trois gestes', horodatage: heure(6) });
|
||||
assert.deepEqual(trace.filter(([, chemin]) => chemin === COURSES), []);
|
||||
assert.equal(courseDe(suite, 2).evalues, 100);
|
||||
|
||||
const rouvert = await rouvrir(disque, BASE);
|
||||
assert.deepEqual(courseDe(rouvert, 3), courseDe(genere, 3));
|
||||
assert.deepEqual(courseDe(rouvert, 2), courseDe(genere, 2));
|
||||
});
|
||||
|
||||
test("effacer puis défaire : le compagnon se réécrit sans les courses effacées, l'Ouvert les garde en mémoire, et le retour les réécrit ; après une réouverture, elles ne sont plus connues", async () => {
|
||||
const { disque, fs } = nouveauDisque();
|
||||
const ouvert = await creer(fs, DOC, chargeEssai(), CREATION);
|
||||
const { apres, courses } = generation(ouvert.charge);
|
||||
const genere = await enregistrer(ouvert, apres, geste(2, { courses }));
|
||||
|
||||
const vide = structuredClone(genere.charge);
|
||||
vide.propositions = [];
|
||||
const efface = await enregistrer(genere, vide, geste(3));
|
||||
assert.equal(fs.contenu('documents', COURSES), compagnon([]));
|
||||
assert.equal(courseDe(efface, 2), null);
|
||||
|
||||
const restituee = reconstruire(efface.journal, 2);
|
||||
const defait = await enregistrer(efface, restituee, geste(4, { sens: 'defaire', retour: 2 }));
|
||||
assert.equal(fs.contenu('documents', COURSES), compagnon(courses));
|
||||
assert.deepEqual(courseDe(defait, 3), courseDe(genere, 3));
|
||||
|
||||
// Rouvert après l'effacement, l'événement ne connaît plus ces courses :
|
||||
// le retour rend les propositions, sans course, et le compagnon reste
|
||||
// vide.
|
||||
const efface2 = await enregistrer(defait, vide, geste(5));
|
||||
const rouvert = await rouvrir(disque, BASE);
|
||||
const trace = tracer(rouvert.fs);
|
||||
const revient = await enregistrer(rouvert, reconstruire(rouvert.journal, 4), geste(6, { sens: 'revenir', retour: 4 }));
|
||||
assert.equal(efface2.charge.propositions.length, 0);
|
||||
assert.equal(revient.charge.propositions.length, 3);
|
||||
assert.equal(courseDe(revient, 3), null);
|
||||
assert.deepEqual(trace.filter(([, chemin]) => chemin === COURSES), []);
|
||||
assert.equal(fs.contenu('documents', COURSES), compagnon([]));
|
||||
});
|
||||
|
||||
test("un compagnon absent, illisible, hors UTF-8, d'un autre événement ou contradictoire n'empêche ni l'ouverture ni l'écriture : ses courses sont inconnues, et un geste qui garde les mêmes propositions ne le touche pas", async () => {
|
||||
const charge = chargeEssai();
|
||||
const [p] = charge.propositions;
|
||||
const valide = texteDesCourses('evt-lucioles', [entreeDe(p)]);
|
||||
const contredite = entreeDe(p);
|
||||
contredite.course.evalues = p.arret + 1;
|
||||
const cas = [
|
||||
['absent', null],
|
||||
['hors JSON', 'pas du JSON'],
|
||||
["d'un autre événement", texteDesCourses('evt-autre', [entreeDe(p)])],
|
||||
['contradictoire', texteDesCourses('evt-lucioles', [contredite])],
|
||||
['hors UTF-8', malEncode(valide.replace('{"format"', '{"é":0,"format"'))],
|
||||
];
|
||||
for (const [libelle, contenu] of cas) {
|
||||
const { disque, fs } = disqueAvec([[ETAT, etatTexte(charge)]]);
|
||||
if (typeof contenu === 'string') fs.deposer('documents', COURSES, contenu);
|
||||
if (contenu instanceof Uint8Array) fs.deposerOctets('documents', COURSES, contenu);
|
||||
const avant = contenu === null ? null : [...fs.octets('documents', COURSES)];
|
||||
const ouvert = await rouvrir(disque, BASE);
|
||||
assert.deepEqual(ouvert.avertissements, [], libelle);
|
||||
assert.equal(courseDe(ouvert, 1), null, libelle);
|
||||
await enregistrer(ouvert, modifier(ouvert.charge, 2), geste(2, { libelleReprise: REPRISE }));
|
||||
const apres = contenu === null ? null : [...fs.octets('documents', COURSES)];
|
||||
assert.deepEqual(apres, avant, libelle);
|
||||
}
|
||||
const { disque, fs } = disqueAvec([[ETAT, etatTexte(charge)], [COURSES, valide]]);
|
||||
assert.equal(courseDe(await rouvrir(disque, BASE), 1).evalues, 100);
|
||||
assert.equal(fs.contenu('documents', COURSES), valide);
|
||||
});
|
||||
|
||||
test("un refus d'écrire le compagnon n'empêche pas le geste : journal et état écrits, la course connue de l'Ouvert pour la séance ; le geste qui change ensuite les propositions le réécrit", async () => {
|
||||
const { fs } = nouveauDisque();
|
||||
const ouvert = await creer(fs, DOC, chargeEssai(), CREATION);
|
||||
const ecrire = fs.ecrireAtomique;
|
||||
let refuser = true;
|
||||
fs.ecrireAtomique = (racine, chemin, texte) => {
|
||||
if (refuser && chemin.endsWith(SUFFIXES.courses)) {
|
||||
return Promise.reject(new ErreurStockage('ECRITURE', { chemin, dossier: DOCUMENTS, cause: 'EACCES' }));
|
||||
}
|
||||
return ecrire(racine, chemin, texte);
|
||||
};
|
||||
const { apres, courses } = generation(ouvert.charge);
|
||||
const genere = await enregistrer(ouvert, apres, geste(2, { courses }));
|
||||
assert.equal(genere.revision, 2);
|
||||
assert.equal(analyser(fs.contenu('documents', ETAT)).entete.revision, 2);
|
||||
assert.equal(fs.contenu('documents', COURSES), null);
|
||||
assert.equal(courseDe(genere, 3).evalues, 1_000);
|
||||
refuser = false;
|
||||
const vide = structuredClone(genere.charge);
|
||||
vide.propositions = vide.propositions.filter(({ id }) => id !== 2);
|
||||
await enregistrer(genere, vide, geste(3));
|
||||
assert.equal(fs.contenu('documents', COURSES), compagnon([courses[1]]));
|
||||
});
|
||||
|
||||
test("écrit ailleurs, l'événement porte son compagnon sous sa nouvelle base ; un dossier où seul un compagnon porte une base ne la laisse pas libre", async () => {
|
||||
const { fs } = nouveauDisque();
|
||||
const ouvert = await creer(fs, DOC, chargeEssai(), CREATION);
|
||||
const { apres, courses } = generation(ouvert.charge);
|
||||
const genere = await enregistrer(ouvert, apres, geste(2, { courses }));
|
||||
const ailleurs = await enregistrerAilleurs(genere, DOC, { existantes: [] });
|
||||
assert.notEqual(ailleurs.base, BASE);
|
||||
assert.equal(fs.contenu('documents', `${ailleurs.base}${SUFFIXES.courses}`), compagnon(courses));
|
||||
assert.deepEqual(courseDe(ailleurs, 2), courseDe(genere, 2));
|
||||
|
||||
const seul = nouveauDisque();
|
||||
seul.fs.deposer('documents', COURSES, compagnon(courses));
|
||||
assert.deepEqual(await basesPrises(seul.fs, DOC), [BASE]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dépôt : format plus récent, élagage', () => {
|
||||
test("un fichier d'un format plus récent s'ouvre en lecture seule : enregistrer, nommer et enregistrerAilleurs refusent LECTURE_SEULE sans rien écrire", async () => {
|
||||
const texte = etatTexte(chargeEssai()).replace(`"format":${FORMAT}`, `"format":${FORMAT + 1}`);
|
||||
|
|
|
|||
|
|
@ -371,18 +371,21 @@ const PLAN = [
|
|||
['horodatage', HORODATAGE_FACULTATIF],
|
||||
];
|
||||
|
||||
const PROPOSITION = objet(
|
||||
[
|
||||
['id', IDENTIFIANT],
|
||||
['graine', entier(0, 2 ** 32 - 1)],
|
||||
['arret', entier(1)],
|
||||
['historique', entier(1)],
|
||||
['produitVersion', CHAINE],
|
||||
['siegesAttribues', BOOLEEN],
|
||||
...PLAN,
|
||||
],
|
||||
{ mise: 'ouverte' },
|
||||
);
|
||||
// Ce qui identifie une proposition, au-delà de son identifiant : la graine,
|
||||
// le compte d'arrêt et l'historique de la recherche qui l'a produite. Le
|
||||
// compagnon des courses reconnaît une proposition à ces quatre champs, à
|
||||
// l'horodatage de sa génération et à l'empreinte de son placement
|
||||
// (ENTREE_DE_COURSE).
|
||||
const IDENTITE = [
|
||||
['id', IDENTIFIANT],
|
||||
['graine', entier(0, 2 ** 32 - 1)],
|
||||
['arret', entier(1)],
|
||||
['historique', entier(1)],
|
||||
];
|
||||
|
||||
const PROPOSITION = objet([...IDENTITE, ['produitVersion', CHAINE], ['siegesAttribues', BOOLEEN], ...PLAN], {
|
||||
mise: 'ouverte',
|
||||
});
|
||||
|
||||
const RETENU = objet([['proposition', IDENTIFIANT], ['siegesAttribues', BOOLEEN], ...PLAN], {
|
||||
nul: true,
|
||||
|
|
@ -390,6 +393,37 @@ const RETENU = objet([['proposition', IDENTIFIANT], ['siegesAttribues', BOOLEEN]
|
|||
aPart: true,
|
||||
});
|
||||
|
||||
/**
|
||||
* Une entrée du compagnon des courses (§ 5.10, § 8.9), qui n'est pas le
|
||||
* fichier d'état : l'identité d'une proposition, sous les règles mêmes de
|
||||
* la proposition, puis horodatage, celui de la génération qui l'a
|
||||
* produite, facultatif et absent quand la proposition n'en porte pas, puis
|
||||
* empreinte, celle du texte canonique de son placement (courses.js), un
|
||||
* entier de 0 à 2^53 − 1, puis course, ce que la recherche a rapporté
|
||||
* d'elle-même.
|
||||
* course porte evalues, ses mouvements évalués, puis releves, un point de
|
||||
* relevé {mouvements, placement} par puissance de dix strictement
|
||||
* inférieure à evalues, le placement sous la forme de celui d'une
|
||||
* proposition. Aucune autre clé : ni valeur mesurée, ni durée. Les
|
||||
* contraintes qui croisent la course et la proposition — evalues au plus
|
||||
* arret, abscisses 1, 10, 100…, placements cohérents avec la déclaration —
|
||||
* sont l'affaire du contrôle des placements (fauteDeCourse). La règle
|
||||
* contrôle la forme ; elle n'écrit rien : courses.js écrit le compagnon.
|
||||
* @type {import('./types.js').Regle}
|
||||
*/
|
||||
export const ENTREE_DE_COURSE = objet([
|
||||
...IDENTITE,
|
||||
['horodatage', HORODATAGE_FACULTATIF],
|
||||
['empreinte', entier(0, Number.MAX_SAFE_INTEGER)],
|
||||
[
|
||||
'course',
|
||||
objet([
|
||||
['evalues', entier(0)],
|
||||
['releves', liste(objet([['mouvements', entier(1)], ['placement', PLACEMENT]]))],
|
||||
]),
|
||||
],
|
||||
]);
|
||||
|
||||
const CHARGE = objet(
|
||||
[
|
||||
['evenement', EVENEMENT],
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import { VERSION } from '../version.genere.js';
|
|||
import { serialiser, serialiserCharge } from './canonique.js';
|
||||
import {
|
||||
CIBLE_REDONDANCE_PAR_DEFAUT,
|
||||
ENTREE_DE_COURSE,
|
||||
FORMAT,
|
||||
GENERATION_PAR_DEFAUT,
|
||||
SCHEMA,
|
||||
|
|
@ -1298,6 +1299,74 @@ describe('le placement daté : un champ facultatif du retenu et des propositions
|
|||
});
|
||||
});
|
||||
|
||||
describe("la course de la recherche hors du fichier d'état (§ 8.9, B31)", () => {
|
||||
const regleDe = (regle, cle) => regle.champs.find(([nom]) => nom === cle)[1];
|
||||
const CHARGE = regleDe(SCHEMA, 'charge');
|
||||
const RETENU = regleDe(CHARGE, 'retenu');
|
||||
const PROPOSITION = regleDe(CHARGE, 'propositions').element;
|
||||
|
||||
// Une course inventée pour la proposition du document valide, à 120
|
||||
// mouvements évalués : un placement de relevé à 1, 10 et 100 mouvements.
|
||||
const course = () => ({
|
||||
evalues: 120,
|
||||
releves: [
|
||||
{ mouvements: 1, placement: [{ sieges: [[1, 2], [5]], reserve: [] }, { sieges: [[1], [2, 5]], reserve: [] }] },
|
||||
{ mouvements: 10, placement: [{ sieges: [[2], [1, 5]], reserve: [] }, { sieges: [[2, 5], [1]], reserve: [] }] },
|
||||
{ mouvements: 100, placement: [{ sieges: [[2, 5], [1]], reserve: [] }, { sieges: [[2], [1, 5]], reserve: [] }] },
|
||||
],
|
||||
});
|
||||
|
||||
test("le schéma de l'état ne connaît pas la course : la proposition finit par placement puis horodatage, et une clé course y est une faute FORME, nommée", () => {
|
||||
assert.deepEqual(PROPOSITION.champs.map(([cle]) => cle).slice(-2), ['placement', 'horodatage']);
|
||||
assert.equal(PROPOSITION.cles.has('course'), false);
|
||||
assert.equal(RETENU.cles.has('course'), false);
|
||||
const proposition = { ...documentValide().charge.propositions[0], course: course() };
|
||||
assert.equal(premiereFaute(proposition, PROPOSITION), 'course');
|
||||
assert.deepEqual(fautesDePlacement(proposition), [{ code: 'FORME', chemin: 'course' }]);
|
||||
});
|
||||
|
||||
test("un fichier d'un format plus récent qui porte une course la perd à la lecture, réduit aux clés connues", () => {
|
||||
const d = documentValide();
|
||||
d.entete.format = FORMAT + 1;
|
||||
d.charge.propositions[0].course = course();
|
||||
const lecture = analyser(JSON.stringify(d));
|
||||
assert.equal(lecture.formatPlusRecent, true);
|
||||
assert.equal(Object.hasOwn(lecture.charge.propositions[0], 'course'), false);
|
||||
});
|
||||
|
||||
test("ENTREE_DE_COURSE, la règle d'une entrée du compagnon des courses : l'identité de la proposition et son horodatage facultatif sous les règles de la proposition, l'empreinte de son placement, puis sa course, evalues et releves, rien d'autre", () => {
|
||||
assert.deepEqual(ENTREE_DE_COURSE.champs.map(([cle]) => cle), ['id', 'graine', 'arret', 'historique', 'horodatage', 'empreinte', 'course']);
|
||||
for (const cle of ['id', 'graine', 'arret', 'historique', 'horodatage']) {
|
||||
assert.equal(regleDe(ENTREE_DE_COURSE, cle), regleDe(PROPOSITION, cle), cle);
|
||||
}
|
||||
const COURSE = regleDe(ENTREE_DE_COURSE, 'course');
|
||||
assert.equal(COURSE.optionnel, false);
|
||||
assert.deepEqual(COURSE.champs.map(([cle]) => cle), ['evalues', 'releves']);
|
||||
const entree = { id: 1, graine: 7, arret: 200, historique: 1000, empreinte: 2 ** 53 - 1, course: course() };
|
||||
assert.equal(premiereFaute(entree, ENTREE_DE_COURSE), null);
|
||||
const fautive = (modifier) => {
|
||||
const copie = structuredClone(entree);
|
||||
modifier(copie);
|
||||
return premiereFaute(copie, ENTREE_DE_COURSE);
|
||||
};
|
||||
assert.equal(fautive((e) => { e.course.evalues = -1; }), 'course.evalues');
|
||||
assert.equal(fautive((e) => { e.course.releves[1].mouvements = 0; }), 'course.releves[1].mouvements');
|
||||
assert.equal(fautive((e) => { e.course.releves[0].duree = 3; }), 'course.releves[0].duree');
|
||||
assert.equal(fautive((e) => { e.course.cadence = 3; }), 'course.cadence');
|
||||
assert.equal(fautive((e) => { e.course.releves[2].placement[0].sieges[0][0] = 0; }), 'course.releves[2].placement[0].sieges[0][0]');
|
||||
assert.equal(fautive((e) => { delete e.course; }), 'course');
|
||||
assert.equal(fautive((e) => { e.empreinte = 0; }), null);
|
||||
assert.equal(fautive((e) => { e.empreinte = -1; }), 'empreinte');
|
||||
assert.equal(fautive((e) => { e.empreinte = 2 ** 53; }), 'empreinte');
|
||||
assert.equal(fautive((e) => { e.empreinte = 'a1'; }), 'empreinte');
|
||||
assert.equal(fautive((e) => { delete e.empreinte; }), 'empreinte');
|
||||
assert.equal(fautive((e) => { e.horodatage = '2031-05-17T21:40:00+02:00'; }), null);
|
||||
assert.equal(fautive((e) => { e.horodatage = null; }), 'horodatage');
|
||||
assert.equal(fautive((e) => { e.horodatage = '2031-05-17 21:40'; }), 'horodatage');
|
||||
assert.equal(fautive((e) => { e.duree = 3; }), 'duree');
|
||||
});
|
||||
});
|
||||
|
||||
// --- Les champs facultatifs du format 2 (§ 4.5, § 8.9, A16) ---------------
|
||||
|
||||
// Le document valide portant chaque champ facultatif du format 2 hors de son
|
||||
|
|
|
|||
|
|
@ -25,12 +25,16 @@
|
|||
import { PAPIERS } from '../geometrie/papiers.js';
|
||||
import { ErreurStockage } from './erreurs.js';
|
||||
|
||||
// Suffixes des fichiers d'un événement, que précède la base de son nom.
|
||||
// Suffixes des fichiers d'un événement, que précède la base de son nom :
|
||||
// l'état, son .precedent, le journal, le verrou, et le compagnon des
|
||||
// courses, qui garde la course de la recherche de chaque proposition à
|
||||
// côté de l'état (courses.js).
|
||||
export const SUFFIXES = Object.freeze({
|
||||
etat: '.gtt.json',
|
||||
precedent: '.gtt.json.precedent',
|
||||
journal: '.gtt-journal.jsonl',
|
||||
verrou: '.gtt.verrou',
|
||||
courses: '.gtt-courses.json',
|
||||
});
|
||||
|
||||
// Ajouté au nom d'un fichier pendant son écriture atomique, puis renommé
|
||||
|
|
@ -57,7 +61,13 @@ const RANG_LE_PLUS_LONG = `_${RANG_MAX}`;
|
|||
// suffixe ajouté à SUFFIXES s'ajoute aussi à ce maximum.
|
||||
const LONGUEUR_FIXE =
|
||||
`${DOSSIER_CORBEILLE}/${MODELE_DATE}${RANG_LE_PLUS_LONG}/`.length +
|
||||
Math.max(SUFFIXES.etat.length, SUFFIXES.precedent.length, SUFFIXES.journal.length, SUFFIXES.verrou.length) +
|
||||
Math.max(
|
||||
SUFFIXES.etat.length,
|
||||
SUFFIXES.precedent.length,
|
||||
SUFFIXES.journal.length,
|
||||
SUFFIXES.verrou.length,
|
||||
SUFFIXES.courses.length,
|
||||
) +
|
||||
SUFFIXE_ECRITURE.length;
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -41,9 +41,9 @@ const DOSSIERS = [
|
|||
|
||||
// Le dossier daté de la corbeille au rang de collision le plus long, à deux
|
||||
// chiffres, et les suffixes des fichiers d'un événement : le journal en compte
|
||||
// 18, l'état 9.
|
||||
// 18, l'état 9, le compagnon des courses 17.
|
||||
const CORBEILLE_RANG_99 = 'corbeille/2026-10-06_19-02-11_99';
|
||||
const SUFFIXES_ECRITS = ['.gtt.json', '.gtt.json.precedent', '.gtt-journal.jsonl', '.gtt.verrou'];
|
||||
const SUFFIXES_ECRITS = ['.gtt.json', '.gtt.json.precedent', '.gtt-journal.jsonl', '.gtt.verrou', '.gtt-courses.json'];
|
||||
|
||||
// Le caractère de substitution haute d'une paire, et celui de substitution basse.
|
||||
const estHaute = (unite) => unite >= 0xd800 && unite <= 0xdbff;
|
||||
|
|
@ -153,6 +153,7 @@ describe('noms.js : valeurs du contrat', () => {
|
|||
precedent: '.gtt.json.precedent',
|
||||
journal: '.gtt-journal.jsonl',
|
||||
verrou: '.gtt.verrou',
|
||||
courses: '.gtt-courses.json',
|
||||
});
|
||||
assert.ok(Object.isFrozen(SUFFIXES));
|
||||
assert.equal(SUFFIXE_ECRITURE, '.ecriture');
|
||||
|
|
@ -160,10 +161,12 @@ describe('noms.js : valeurs du contrat', () => {
|
|||
assert.equal(LONGUEUR_CHEMIN_MAX, 259);
|
||||
});
|
||||
|
||||
test("longueurs des suffixes : l'état 9, le journal 18, l'état précédent 19, et 28 avec le suffixe d'écriture", () => {
|
||||
test("longueurs des suffixes : l'état 9, le journal 18, l'état précédent 19, le compagnon des courses 17, et 28 avec le suffixe d'écriture", () => {
|
||||
assert.deepEqual(
|
||||
[SUFFIXES.etat, SUFFIXES.precedent, SUFFIXES.journal, SUFFIXES.verrou].map((suffixe) => suffixe.length),
|
||||
[9, 19, 18, 11],
|
||||
[SUFFIXES.etat, SUFFIXES.precedent, SUFFIXES.journal, SUFFIXES.verrou, SUFFIXES.courses].map(
|
||||
(suffixe) => suffixe.length,
|
||||
),
|
||||
[9, 19, 18, 11, 17],
|
||||
);
|
||||
assert.equal(SUFFIXE_ECRITURE.length, 9);
|
||||
assert.equal(`${SUFFIXES.precedent}${SUFFIXE_ECRITURE}`.length, 28);
|
||||
|
|
|
|||
|
|
@ -14,7 +14,14 @@
|
|||
// par creer ; renommer écrit par enregistrer, en place, ou par
|
||||
// enregistrerAilleurs, sous le nouveau nom ; le journal d'un événement est
|
||||
// celui qu'ouvrir lui apparie, ou, faute d'état lisible, celui que lister
|
||||
// lui apparie. Ce module ne fait par lui-même que recopier les octets d'un
|
||||
// lui apparie. Le compagnon des courses (courses.js) suit la base de l'état :
|
||||
// copier et renommer le font écrire par le dépôt sous la base neuve ;
|
||||
// renommer supprime l'ancien, quand il existe, avec l'ancienne paire, ou le
|
||||
// déplace sous la base neuve quand l'écriture du neuf a été refusée ;
|
||||
// supprimer l'emporte dans la corbeille. Importer de l'autre emplacement le
|
||||
// recopie tel quel après l'état, et l'ouverture le revalide (lireCourses) ;
|
||||
// un fichier d'état venu d'ailleurs n'en a pas, et ses courses sont
|
||||
// inconnues. Ce module ne fait par lui-même que recopier les octets d'un
|
||||
// fichier, le déplacer ou le supprimer en entier.
|
||||
//
|
||||
// Un fichier d'un format plus récent que FORMAT ne se réécrit sous aucun nom
|
||||
|
|
@ -30,6 +37,7 @@
|
|||
// bases que portent les fichiers d'événement à la racine du dossier
|
||||
// (basesPrises), comme le dépôt la dérive pour creer.
|
||||
import { canoniser } from './canonique.js';
|
||||
import { entreesPour } from './courses.js';
|
||||
import {
|
||||
basesPrises,
|
||||
creer,
|
||||
|
|
@ -83,6 +91,18 @@ async function texteSiPresent(fs, racine, chemin) {
|
|||
}
|
||||
}
|
||||
|
||||
// Texte du fichier chemin, ou null quand sa lecture échoue par une
|
||||
// ErreurStockage — absent, hors UTF-8, refusé — : la lecture du compagnon
|
||||
// des courses, qui ne refuse jamais rien. Toute autre erreur remonte.
|
||||
async function texteSiLisible(fs, racine, chemin) {
|
||||
try {
|
||||
return await fs.lireTexte(racine, chemin);
|
||||
} catch (erreur) {
|
||||
if (erreur instanceof ErreurStockage) return null;
|
||||
throw erreur;
|
||||
}
|
||||
}
|
||||
|
||||
// Texte du journal chemin tel que l'ouverture le lit : le fichier entier ;
|
||||
// quand ses octets ne sont pas tous de l'UTF-8, les lignes qui précèdent la
|
||||
// première qui ne se décode pas (decoderJournal), celles que la lecture
|
||||
|
|
@ -142,8 +162,10 @@ export function libelleDeLInstant({ journal, revision }, libelleReprise) {
|
|||
* identifiant et son nom, et l'instant copié, la révision de l'Ouvert et le
|
||||
* libellé de son entrée au journal. L'historique ne se copie pas : creer
|
||||
* écrit un journal d'une entrée, en instantané, au libellé libelle, et son
|
||||
* jalon nomJalon, puis l'état ; sa base se dérive du nom. L'original n'est
|
||||
* ni lu ni écrit : la copie part de l'Ouvert, en lecture comme en écriture.
|
||||
* jalon nomJalon, puis l'état, puis le compagnon des courses que l'Ouvert
|
||||
* connaît pour ses propositions, sous l'identifiant de la copie ; sa base se
|
||||
* dérive du nom. L'original n'est ni lu ni écrit : la copie part de
|
||||
* l'Ouvert, en lecture comme en écriture.
|
||||
*
|
||||
* @param {import('./depot.js').Ouvert} ouvert
|
||||
* @param {Object} options
|
||||
|
|
@ -173,7 +195,8 @@ export async function copier(ouvert, { id, nom, libelle, nomJalon, horodatage, p
|
|||
etat: etatDeduit(charge),
|
||||
filiation: { source: { id: source.id, nom: source.nom }, instant },
|
||||
};
|
||||
return creer(ouvert.fs, ouvert.racine, charge, { libelle, nomJalon, horodatage, produitVersion });
|
||||
const courses = entreesPour(ouvert.courses, charge.propositions);
|
||||
return creer(ouvert.fs, ouvert.racine, charge, { libelle, nomJalon, horodatage, produitVersion, courses });
|
||||
}
|
||||
|
||||
// Vrai quand la base que deriver tire du nom, parmi les autres bases, tombe
|
||||
|
|
@ -204,6 +227,20 @@ async function effacerSiPossible(fs, racine, chemin) {
|
|||
}
|
||||
}
|
||||
|
||||
// Déplace le fichier de dans racine vers vers. Rend vrai quand il y est
|
||||
// passé, et faux quand le système de fichiers refuse, par une
|
||||
// ErreurStockage, qui ne lève pas : de reste alors où il est. Toute autre
|
||||
// erreur remonte.
|
||||
async function deplacerSiPossible(fs, racine, de, vers) {
|
||||
try {
|
||||
await fs.deplacer(racine, de, vers);
|
||||
return true;
|
||||
} catch (erreur) {
|
||||
if (!(erreur instanceof ErreurStockage)) throw erreur;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Efface ce que la recopie a écrit sous base avant un refus : le journal
|
||||
// quand journal le dit, puis le .precedent, et lui seulement une fois le
|
||||
// journal parti. Un journal resté garde ainsi son .precedent, qui le tient à
|
||||
|
|
@ -252,18 +289,31 @@ async function ecrireNouvellePaire(ouvert, options) {
|
|||
|
||||
// Supprime l'ancienne paire de l'Ouvert, la nouvelle étant complète : son
|
||||
// état d'abord, sans lequel lister ne la montre plus, la nouvelle portant
|
||||
// son identifiant ; puis son .precedent et le journal de l'événement
|
||||
// (baseJournal), jamais celui d'un autre ; un fichier absent n'est pas une
|
||||
// faute. Une ErreurStockage reçoit dans ses détails ouvert, l'Ouvert de la
|
||||
// nouvelle paire, d'où continuer, puis remonte : celui que tient l'appelant
|
||||
// décrit l'ancienne, et un geste parti de lui ferait diverger deux paires du
|
||||
// même événement.
|
||||
// son identifiant ; puis son .precedent, le journal de l'événement
|
||||
// (baseJournal), jamais celui d'un autre, et son compagnon des courses
|
||||
// quand la liste du dossier le porte, sous son nom sur le disque
|
||||
// (nomSurDisque) ; un fichier absent n'est pas une faute, ni un dossier
|
||||
// disparu, dont la liste est vide (entreesDe). Quand l'écriture du
|
||||
// compagnon neuf a été refusée (nouvelle.compagnon null), l'ancien ne se
|
||||
// supprime pas : il se déplace sous la nouvelle base, qui retrouve ainsi
|
||||
// ses courses à la réouverture, ou reste où il est quand le déplacement
|
||||
// est refusé ; ni l'un ni l'autre ne lève. Une ErreurStockage reçoit
|
||||
// dans ses détails ouvert, l'Ouvert de la nouvelle paire, d'où continuer,
|
||||
// puis remonte : celui que tient l'appelant décrit l'ancienne, et un geste
|
||||
// parti de lui ferait diverger deux paires du même événement.
|
||||
async function supprimerAncienne(ouvert, nouvelle) {
|
||||
const { fs, racine, base, baseJournal } = ouvert;
|
||||
const chemins = [base + SUFFIXES.etat, base + SUFFIXES.precedent];
|
||||
if (baseJournal !== null) chemins.push(baseJournal + SUFFIXES.journal);
|
||||
try {
|
||||
for (const chemin of chemins) await fs.supprimer(racine, chemin);
|
||||
const { insensibleCasse } = await fs.emplacements();
|
||||
const noms = (await entreesDe(fs, racine, '')).map(({ nom }) => nom);
|
||||
const compagnon = nomSurDisque(noms, base + SUFFIXES.courses, insensibleCasse);
|
||||
if (compagnon !== null && nouvelle.compagnon !== null) await fs.supprimer(racine, compagnon);
|
||||
if (compagnon !== null && nouvelle.compagnon === null) {
|
||||
await deplacerSiPossible(fs, racine, compagnon, nouvelle.base + SUFFIXES.courses);
|
||||
}
|
||||
} catch (erreur) {
|
||||
if (erreur instanceof ErreurStockage) erreur.details.ouvert = nouvelle;
|
||||
throw erreur;
|
||||
|
|
@ -278,8 +328,10 @@ async function supprimerAncienne(ouvert, nouvelle) {
|
|||
* refuse, ou par la casse —, le renommage est ce seul geste, en place
|
||||
* (enregistrer). Sinon, le logiciel écrit d'abord la paire complète sous la
|
||||
* nouvelle base — l'état d'avant au .precedent, le journal recopié suivi de
|
||||
* l'entrée du renommage, puis l'état —, puis supprime l'ancienne : son état,
|
||||
* son .precedent et le journal de l'événement, jamais l'inverse. Rien de
|
||||
* l'entrée du renommage, puis l'état, puis le compagnon des courses —, puis
|
||||
* supprime l'ancienne : son état, son .precedent, le journal de l'événement
|
||||
* et son compagnon quand il existe, jamais l'inverse ; un compagnon neuf
|
||||
* refusé laisse l'ancien se déplacer sous la nouvelle base. Rien de
|
||||
* l'ancienne paire ne s'écrit, et le .precedent neuf, posé avant le journal
|
||||
* neuf, tient ce journal à la nouvelle base : quel que soit le journal de
|
||||
* l'ancienne base — le sien, aucun pour un état importé, un illisible, celui
|
||||
|
|
@ -367,10 +419,12 @@ function decoder(octets) {
|
|||
// Écrit dans racine l'événement id, nom : ses fichiers, tels quels, chacun
|
||||
// quand il est donné, sous une base libre que nom dérive — le .precedent
|
||||
// d'abord, qui tient la base à l'événement (apparier, dans le dépôt), puis
|
||||
// le journal, puis l'état. Rend cette base. IDENTIFIANT_PRESENT quand lister
|
||||
// nomme id dans le dossier, que son état se lise ou non : l'événement y est
|
||||
// déjà, et une seconde copie de son identifiant dériverait (§ 8.6).
|
||||
async function deposer(fs, racine, { id, nom, texteEtat, textePrecedent = null, texteJournal }) {
|
||||
// le journal, puis l'état, puis le compagnon des courses. Rend cette base.
|
||||
// IDENTIFIANT_PRESENT quand lister nomme id dans le dossier, que son état
|
||||
// se lise ou non : l'événement y est déjà, et une seconde copie de son
|
||||
// identifiant dériverait (§ 8.6). Le refus d'écrire le compagnon, par une
|
||||
// ErreurStockage, ne lève pas : l'événement est là, ses courses inconnues.
|
||||
async function deposer(fs, racine, { id, nom, texteEtat, textePrecedent = null, texteJournal, texteCourses = null }) {
|
||||
const present = (await lister(fs, racine)).find((resume) => resume.id === id);
|
||||
if (present !== undefined) throw new ErreurStockage('IDENTIFIANT_PRESENT', { id, base: present.base });
|
||||
const { separateur } = await fs.emplacements();
|
||||
|
|
@ -379,6 +433,13 @@ async function deposer(fs, racine, { id, nom, texteEtat, textePrecedent = null,
|
|||
if (textePrecedent !== null) await fs.ecrireAtomique(racine, base + SUFFIXES.precedent, textePrecedent);
|
||||
if (texteJournal !== null) await fs.ecrireAtomique(racine, base + SUFFIXES.journal, texteJournal);
|
||||
if (texteEtat !== null) await fs.ecrireAtomique(racine, base + SUFFIXES.etat, texteEtat);
|
||||
if (texteCourses !== null) {
|
||||
try {
|
||||
await fs.ecrireAtomique(racine, base + SUFFIXES.courses, texteCourses);
|
||||
} catch (erreur) {
|
||||
if (!(erreur instanceof ErreurStockage)) throw erreur;
|
||||
}
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
|
|
@ -412,7 +473,8 @@ export async function importer(fs, racine, octets) {
|
|||
// Les fichiers de l'événement de base dans depuis, dont l'état ne se lit
|
||||
// pas, recopiés tels quels dans vers (deposer) : le .precedent et l'état,
|
||||
// chacun quand il existe et que ses octets sont de l'UTF-8, et le journal
|
||||
// que le secours source apparie, par ses lignes lisibles (journalSiPresent). Le secours source — 'precedent' ou 'journal'
|
||||
// que le secours source apparie, par ses lignes lisibles
|
||||
// (journalSiPresent). Le secours source — 'precedent' ou 'journal'
|
||||
// (ouvrirSecours) — donne l'identifiant et le nom. Rend la base dans vers.
|
||||
async function importerSecours(fs, depuis, vers, base, source) {
|
||||
const { charge, baseJournal } = await ouvrirSecours(fs, depuis, base, source);
|
||||
|
|
@ -429,9 +491,12 @@ async function importerSecours(fs, depuis, vers, base, source) {
|
|||
}
|
||||
|
||||
// Importe l'événement de base de depuis dans vers (importerDepuis) et rend
|
||||
// sa base là-bas. Un état illisible qui a un secours lisible s'importe par
|
||||
// ses fichiers (importerSecours), le .precedent pour source quand il se lit ;
|
||||
// sans secours, son refus remonte.
|
||||
// sa base là-bas. Un état lisible s'importe avec son journal et son
|
||||
// compagnon des courses, tel quel, quand ses octets se lisent
|
||||
// (texteSiLisible) : l'ouverture là-bas le revalide entrée par entrée. Un
|
||||
// état illisible qui a un secours lisible s'importe par ses fichiers
|
||||
// (importerSecours), le .precedent pour source quand il se lit ; sans
|
||||
// secours, son refus remonte.
|
||||
async function importerUn(fs, depuis, vers, base) {
|
||||
let ouvert;
|
||||
try {
|
||||
|
|
@ -445,25 +510,27 @@ async function importerUn(fs, depuis, vers, base) {
|
|||
const { texteEtat, baseJournal } = ouvert;
|
||||
const { id, nom } = analyser(texteEtat).charge.evenement;
|
||||
const texteJournal = baseJournal === null ? null : await journalSiPresent(fs, depuis, baseJournal + SUFFIXES.journal);
|
||||
return deposer(fs, vers, { id, nom, texteEtat, texteJournal });
|
||||
const texteCourses = await texteSiLisible(fs, depuis, base + SUFFIXES.courses);
|
||||
return deposer(fs, vers, { id, nom, texteEtat, texteJournal, texteCourses });
|
||||
}
|
||||
|
||||
/**
|
||||
* Importe des événements de l'autre emplacement (§ 8.6) : pour chaque base
|
||||
* de depuis, dans l'ordre reçu, l'état et le journal qu'ouvrir lui apparie
|
||||
* se copient tels quels dans vers, le journal d'abord, sous une base libre
|
||||
* que dérive le nom que porte l'état. Le .precedent ne se copie pas. Un
|
||||
* événement dont l'état ne se lit pas, mais dont le .precedent ou le journal
|
||||
* se lisent (§ 8.8), se copie par ses fichiers, tels quels : le .precedent,
|
||||
* le journal que son secours apparie, et l'état, chacun quand ses octets
|
||||
* sont de l'UTF-8 ; il s'ouvre là-bas en secours. Un journal dont une ligne
|
||||
* n'est pas de l'UTF-8 se copie par les lignes qui la précèdent, celles que
|
||||
* l'ouverture lit (journalSiPresent). Les originaux restent où
|
||||
* ils sont, sans qu'aucune écriture ne touche depuis. Une base qui ne
|
||||
* s'importe pas se refuse par son code — ETAT_ILLISIBLE sans secours
|
||||
* lisible, ABSENT, IDENTIFIANT_PRESENT, ECRITURE… —, sans arrêter les
|
||||
* suivantes ; l'écriture refusée d'un fichier laisse dans vers ceux déjà
|
||||
* écrits.
|
||||
* de depuis, dans l'ordre reçu, le journal qu'ouvrir lui apparie, son état,
|
||||
* puis son compagnon des courses quand il se lit, se copient tels quels
|
||||
* dans vers, sous une base libre que dérive le nom que porte l'état ; le
|
||||
* refus d'écrire le compagnon ne refuse pas l'import. Le .precedent ne se
|
||||
* copie pas. Un événement dont l'état ne se lit pas, mais dont le
|
||||
* .precedent ou le journal se lisent (§ 8.8), se copie par ses fichiers,
|
||||
* tels quels : le .precedent, le journal que son secours apparie, et
|
||||
* l'état, chacun quand ses octets sont de l'UTF-8 ; il s'ouvre là-bas en
|
||||
* secours. Un journal dont une ligne n'est pas de l'UTF-8 se copie par les
|
||||
* lignes qui la précèdent, celles que l'ouverture lit (journalSiPresent).
|
||||
* Les originaux restent où ils sont, sans qu'aucune écriture ne touche
|
||||
* depuis. Une base qui ne s'importe pas se refuse par son code —
|
||||
* ETAT_ILLISIBLE sans secours lisible, ABSENT, IDENTIFIANT_PRESENT,
|
||||
* ECRITURE… —, sans arrêter les suivantes ; l'écriture refusée d'un
|
||||
* fichier laisse dans vers ceux déjà écrits.
|
||||
*
|
||||
* @param {import('./systeme_fichiers.js').SystemeFichiers} fs
|
||||
* @param {import('./systeme_fichiers.js').Racine} depuis
|
||||
|
|
@ -513,12 +580,13 @@ async function baseDuJournal(fs, racine, base, insensibleCasse) {
|
|||
* dossier neuf de la corbeille, daté de l'horodatage (dossierCorbeille),
|
||||
* créé d'abord — _2, _3… quand la seconde a déjà le sien. Partent l'état, le
|
||||
* .precedent, le journal de l'événement (baseDuJournal), jamais celui d'un
|
||||
* autre, le verrou, et chacun de leurs fichiers d'écriture atomique restés ;
|
||||
* ceux-ci d'abord, puis le verrou, le journal, le .precedent, et l'état en
|
||||
* dernier. Sur un système de fichiers qui ignore la casse, un fichier se
|
||||
* reconnaît à la clé de son nom, la majuscule de sa NFC, comme deriverBase
|
||||
* compare les bases, et part sous son nom sur le disque : un journal, un
|
||||
* .precedent ou un verrou d'une autre casse est le même fichier. Sur un
|
||||
* autre, le verrou, le compagnon des courses, et chacun de leurs fichiers
|
||||
* d'écriture atomique restés ; ceux-ci d'abord, puis le verrou, le
|
||||
* compagnon, le journal, le .precedent, et l'état en dernier. Sur un
|
||||
* système de fichiers qui ignore la casse, un fichier se reconnaît à la clé
|
||||
* de son nom, la majuscule de sa NFC, comme deriverBase compare les bases,
|
||||
* et part sous son nom sur le disque : un journal, un .precedent, un
|
||||
* compagnon ou un verrou d'une autre casse est le même fichier. Sur un
|
||||
* système sensible à la casse, seul le nom exact compte. Chaque déplacement
|
||||
* porte un fichier entier : une coupure laisse
|
||||
* chacun à sa place ou dans la corbeille, jamais perdu, et l'événement se
|
||||
|
|
@ -561,6 +629,7 @@ export async function supprimer(fs, racine, base, { horodatage, seance }) {
|
|||
const verrou = base + SUFFIXES.verrou;
|
||||
const fichiers = [
|
||||
verrou,
|
||||
base + SUFFIXES.courses,
|
||||
...(journal === null ? [] : [journal + SUFFIXES.journal]),
|
||||
base + SUFFIXES.precedent,
|
||||
base + SUFFIXES.etat,
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { describe, test } from '../../test/lanceur.js';
|
|||
import { texteLivre } from '../demo/livrees.js';
|
||||
import { VERSION } from '../version.genere.js';
|
||||
import { serialiser, serialiserCharge } from './canonique.js';
|
||||
import { courseDe, entreeDeCourse, texteDesCourses } from './courses.js';
|
||||
import { creer, enregistrer, lister, ouvrir, ouvrirSecours, verrouiller } from './depot.js';
|
||||
import { FORMAT, analyser, creerCharge } from './document.js';
|
||||
import { ErreurStockage } from './erreurs.js';
|
||||
|
|
@ -42,6 +43,7 @@ const ETAT = `${BASE}${SUFFIXES.etat}`;
|
|||
const PRECEDENT = `${BASE}${SUFFIXES.precedent}`;
|
||||
const JOURNAL = `${BASE}${SUFFIXES.journal}`;
|
||||
const VERROU = `${BASE}${SUFFIXES.verrou}`;
|
||||
const COURSES = `${BASE}${SUFFIXES.courses}`;
|
||||
|
||||
const REPRISE = "Reprise de l'état trouvé sur le disque";
|
||||
const UTF8 = new TextEncoder();
|
||||
|
|
@ -1921,3 +1923,162 @@ describe('opérations : un état aux octets hors UTF-8 (§ 8.8)', () => {
|
|||
}
|
||||
});
|
||||
});
|
||||
|
||||
// --- Le compagnon des courses (§ 8.6, § 8.7, § 8.9, B31) --------------------
|
||||
|
||||
// Un événement de deux révisions dont la seconde est une génération : la
|
||||
// proposition 2 ajoutée, avec sa course à 100 mouvements évalués — un point
|
||||
// de relevé à 1 et à 10, chacun une copie de son placement —, et le
|
||||
// compagnon qui la porte.
|
||||
async function avecUneCourse() {
|
||||
const { disque, fs } = nouveauDisque();
|
||||
const ouvert = await creer(fs, DOC, chargeEssai(), CREATION);
|
||||
const apres = structuredClone(ouvert.charge);
|
||||
const neuve = proposition(2);
|
||||
apres.propositions.push(neuve);
|
||||
apres.prochainsIds.proposition = 3;
|
||||
const releves = [1, 10].map((mouvements) => ({ mouvements, placement: structuredClone(neuve.placement) }));
|
||||
const entree = entreeDeCourse(neuve, { evalues: 100, releves });
|
||||
const genere = await enregistrer(ouvert, apres, geste(2, { courses: [entree] }));
|
||||
assert.equal(fs.contenu('documents', COURSES), texteDesCourses('evt-lucioles', [entree]));
|
||||
return { disque, fs, ouvert: genere, entree };
|
||||
}
|
||||
|
||||
describe("opérations : le compagnon des courses suit l'événement (§ 8.7, B31)", () => {
|
||||
test("renommer écrit le compagnon sous la nouvelle base, après l'état, et supprime l'ancien avec l'ancienne paire ; rouvert sous son nouveau nom, l'événement retrouve sa course", async () => {
|
||||
const { disque, fs, ouvert, entree } = await avecUneCourse();
|
||||
const nouvelle = 'Gala des Saules';
|
||||
const trace = tracer(fs);
|
||||
const renomme = await renommer(ouvert, renommage(nouvelle, 3));
|
||||
assert.deepEqual(trace, [
|
||||
['ecrireAtomique', `${nouvelle}${SUFFIXES.precedent}`],
|
||||
['ecrireAtomique', `${nouvelle}${SUFFIXES.journal}`],
|
||||
['ecrireAtomique', `${nouvelle}${SUFFIXES.etat}`],
|
||||
['ecrireAtomique', `${nouvelle}${SUFFIXES.courses}`],
|
||||
['supprimer', ETAT],
|
||||
['supprimer', PRECEDENT],
|
||||
['supprimer', JOURNAL],
|
||||
['supprimer', COURSES],
|
||||
]);
|
||||
assert.equal(fs.contenu('documents', `${nouvelle}${SUFFIXES.courses}`), texteDesCourses('evt-lucioles', [entree]));
|
||||
assert.equal(fs.contenu('documents', COURSES), null);
|
||||
assert.equal(courseDe(renomme, 2).evalues, 100);
|
||||
const rouvert = await ouvrir(creerFichiersSimules({ disque }), DOC, nouvelle);
|
||||
assert.deepEqual(courseDe(rouvert, 2), courseDe(ouvert, 2));
|
||||
});
|
||||
|
||||
test("renommer en place, le nom ne changeant que par la casse, garde le compagnon tel quel : les propositions ne changent pas", async () => {
|
||||
const { fs, ouvert, entree } = await avecUneCourse();
|
||||
const trace = tracer(fs);
|
||||
const renomme = await renommer(ouvert, renommage(BASE.toUpperCase(), 3));
|
||||
assert.equal(renomme.base, BASE);
|
||||
assert.deepEqual(trace.filter(([, chemin]) => chemin === COURSES), []);
|
||||
assert.equal(fs.contenu('documents', COURSES), texteDesCourses('evt-lucioles', [entree]));
|
||||
});
|
||||
|
||||
test("copier écrit le compagnon de la copie sous son identifiant, après son état ; l'original et son compagnon restent octet pour octet", async () => {
|
||||
const { disque, fs, ouvert, entree } = await avecUneCourse();
|
||||
const avant = await arbre(disque);
|
||||
const variante = 'Variante des Lucioles';
|
||||
const trace = tracer(fs);
|
||||
const copie = await copier(ouvert, optionsCopie(variante));
|
||||
assert.deepEqual(trace, [
|
||||
['ecrireAtomique', `${variante}${SUFFIXES.journal}`],
|
||||
['ecrireAtomique', `${variante}${SUFFIXES.etat}`],
|
||||
['ecrireAtomique', `${variante}${SUFFIXES.courses}`],
|
||||
]);
|
||||
assert.equal(fs.contenu('documents', `${variante}${SUFFIXES.courses}`), texteDesCourses('evt-variante', [entree]));
|
||||
assert.equal(courseDe(copie, 2).evalues, 100);
|
||||
const apres = await arbre(disque);
|
||||
assert.deepEqual(apres.filter(([chemin]) => !chemin.startsWith(variante)), avant);
|
||||
});
|
||||
|
||||
// L'écriture du compagnon sous la nouvelle base refusée, et elle seule.
|
||||
function refuserLeCompagnon(fs, base) {
|
||||
const ecrire = fs.ecrireAtomique;
|
||||
fs.ecrireAtomique = (racine, chemin, ...reste) => {
|
||||
if (chemin !== `${base}${SUFFIXES.courses}`) return ecrire(racine, chemin, ...reste);
|
||||
return Promise.reject(new ErreurStockage('ECRITURE', { chemin, dossier: racine.chemin, cause: 'EACCES' }));
|
||||
};
|
||||
}
|
||||
|
||||
test("renommer dont le compagnon neuf est refusé y déplace l'ancien au lieu de le supprimer : rouvert sous son nouveau nom, l'événement retrouve sa course", async () => {
|
||||
const { disque, fs, ouvert, entree } = await avecUneCourse();
|
||||
const nouvelle = 'Gala des Saules';
|
||||
refuserLeCompagnon(fs, nouvelle);
|
||||
const trace = tracer(fs);
|
||||
const renomme = await renommer(ouvert, renommage(nouvelle, 3));
|
||||
assert.equal(renomme.compagnon, null);
|
||||
assert.deepEqual(trace.slice(-4), [
|
||||
['supprimer', ETAT],
|
||||
['supprimer', PRECEDENT],
|
||||
['supprimer', JOURNAL],
|
||||
['deplacer', COURSES, `${nouvelle}${SUFFIXES.courses}`],
|
||||
]);
|
||||
assert.equal(fs.contenu('documents', COURSES), null);
|
||||
assert.equal(fs.contenu('documents', `${nouvelle}${SUFFIXES.courses}`), texteDesCourses('evt-lucioles', [entree]));
|
||||
assert.equal(courseDe(renomme, 2).evalues, 100);
|
||||
const rouvert = await rouvrir(disque, nouvelle);
|
||||
assert.deepEqual(courseDe(rouvert, 2), courseDe(ouvert, 2));
|
||||
});
|
||||
|
||||
test("renommer dont le compagnon neuf est refusé, et le déplacement de l'ancien aussi, garde l'ancien sous l'ancienne base sans lever", async () => {
|
||||
const { fs, ouvert, entree } = await avecUneCourse();
|
||||
const nouvelle = 'Gala des Saules';
|
||||
refuserLeCompagnon(fs, nouvelle);
|
||||
fs.deplacer = (racine, de) =>
|
||||
Promise.reject(new ErreurStockage('ECRITURE', { chemin: de, dossier: racine.chemin, cause: 'EBUSY' }));
|
||||
const renomme = await renommer(ouvert, renommage(nouvelle, 3));
|
||||
assert.equal(renomme.base, nouvelle);
|
||||
assert.equal(fs.contenu('documents', COURSES), texteDesCourses('evt-lucioles', [entree]));
|
||||
assert.equal(fs.contenu('documents', ETAT), null);
|
||||
});
|
||||
|
||||
test("importerDepuis recopie le compagnon tel quel, après l'état ; là-bas, l'événement retrouve sa course. Sans compagnon, illisible ou refusé à l'écriture, l'import passe, sans course", async () => {
|
||||
const PORTABLE = { executable: 'E:\\soirees' };
|
||||
const { disque, fs, ouvert, entree } = await avecUneCourse();
|
||||
const ailleurs = creerFichiersSimules({ disque, ...PORTABLE });
|
||||
ailleurs.deposerDossier('portable');
|
||||
const { portable, documents } = await ailleurs.racines();
|
||||
const texte = texteDesCourses('evt-lucioles', [entree]);
|
||||
|
||||
const trace = tracer(ailleurs);
|
||||
assert.deepEqual(await importerDepuis(ailleurs, documents, portable, [BASE]), {
|
||||
importes: [{ base: BASE, nouvelle: BASE }],
|
||||
refuses: [],
|
||||
});
|
||||
assert.deepEqual(trace.map(([, chemin]) => chemin).slice(-2), [ETAT, COURSES]);
|
||||
assert.equal(ailleurs.contenu('portable', COURSES), texte);
|
||||
const importe = await ouvrir(creerFichiersSimules({ disque, ...PORTABLE }), portable, BASE);
|
||||
assert.deepEqual(courseDe(importe, 2), courseDe(ouvert, 2));
|
||||
|
||||
for (const [libelle, preparer] of [
|
||||
['sans compagnon', (f) => f.supprimer(documents, COURSES)],
|
||||
['compagnon hors UTF-8', (f) => f.deposerOctets('documents', COURSES, Uint8Array.of(0xff, 0xfe, 0x00))],
|
||||
['écriture refusée', (f) => refuserLeCompagnon(f, BASE)],
|
||||
]) {
|
||||
const cas = await avecUneCourse();
|
||||
const autre = creerFichiersSimules({ disque: cas.disque, ...PORTABLE });
|
||||
autre.deposerDossier('portable');
|
||||
const racines = await autre.racines();
|
||||
await preparer(autre);
|
||||
const rendu = await importerDepuis(autre, racines.documents, racines.portable, [BASE]);
|
||||
assert.deepEqual(rendu, { importes: [{ base: BASE, nouvelle: BASE }], refuses: [] }, libelle);
|
||||
const relu = await ouvrir(creerFichiersSimules({ disque: cas.disque, ...PORTABLE }), racines.portable, BASE);
|
||||
assert.equal(courseDe(relu, 2), null, libelle);
|
||||
}
|
||||
});
|
||||
|
||||
test("supprimer emporte le compagnon et son écriture restée dans le dossier daté, après le verrou et avant le journal", async () => {
|
||||
const { disque, fs } = await avecUneCourse();
|
||||
fs.deposer('documents', `${COURSES}.ecriture`, 'partiel');
|
||||
const trace = tracer(fs);
|
||||
assert.deepEqual(await supprimer(fs, DOC, BASE, { horodatage: HEURE_SUPPRESSION, seance: SEANCE }), { dossier: DATE });
|
||||
const deplaces = [`${COURSES}.ecriture`, VERROU, COURSES, JOURNAL, PRECEDENT, ETAT];
|
||||
assert.deepEqual(trace.filter(([primitive]) => primitive === 'deplacer'), deplaces.map((nom) => ['deplacer', nom, `${DATE}/${nom}`]));
|
||||
assert.deepEqual(
|
||||
(await arbre(disque)).map(([chemin]) => chemin).filter((chemin) => !chemin.startsWith(DATE)),
|
||||
[],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,8 +5,12 @@
|
|||
// la proposition du moteur mise en forme de fichier, et le plan du moteur
|
||||
// qu'on en tire ; le contrôle de cohérence d'une proposition ou du retenu ;
|
||||
// leur dérive contre la charge courante ; leur forme nommée ; le partage
|
||||
// d'une charge lue entre propositions gardées et écartées. Les formes sont
|
||||
// décrites dans types.js et dans ce module.
|
||||
// d'une charge lue entre propositions gardées et écartées. La course de la
|
||||
// recherche d'une proposition (§ 5.10) n'est pas dans le fichier d'état :
|
||||
// ce module la met en forme de fichier, contrôle qu'elle ne contredit pas
|
||||
// sa proposition et en tire les plans, pour le compagnon des courses
|
||||
// (courses.js), qui la garde à côté. Les formes sont décrites dans
|
||||
// types.js, dans la règle ENTREE_DE_COURSE du schéma et dans ce module.
|
||||
//
|
||||
// Fautif et périmé sont deux choses (§ 8.9, point 3). Un placement fautif se
|
||||
// contredit lui-même : fautes nomme chaque contradiction, et examiner écarte
|
||||
|
|
@ -28,7 +32,7 @@
|
|||
// des sièges attribués, avant le dernier occupant, siège 1 compris. Aucune
|
||||
// fonction ne modifie ce qu'elle reçoit ; aucune ne lit l'horloge ni un
|
||||
// aléa.
|
||||
import { SCHEMA, capacite, premiereFaute } from './document.js';
|
||||
import { ENTREE_DE_COURSE, SCHEMA, capacite, premiereFaute } from './document.js';
|
||||
import { ErreurStockage } from './erreurs.js';
|
||||
|
||||
/**
|
||||
|
|
@ -104,6 +108,8 @@ const CHARGE = regleDuChamp(SCHEMA, 'charge');
|
|||
const PROPOSITION = regleDuChamp(CHARGE, 'propositions').element;
|
||||
const RETENU = regleDuChamp(CHARGE, 'retenu');
|
||||
const IDENTIFIANT = regleDuChamp(PROPOSITION, 'id');
|
||||
// Règle de la course d'une entrée du compagnon des courses.
|
||||
const COURSE = regleDuChamp(ENTREE_DE_COURSE, 'course');
|
||||
|
||||
const croissant = (a, b) => a - b;
|
||||
// Deux chaînes, comparées unité UTF-16 par unité.
|
||||
|
|
@ -208,6 +214,86 @@ function fautesDeCoherence({ tables, capacites, tours, participants, placement }
|
|||
return liste;
|
||||
}
|
||||
|
||||
// Chemin, sous chemin, de la n-ième occurrence de id (n à partir de 1) dans
|
||||
// un tour de placement, ses listes de table dans leur ordre puis sa
|
||||
// réserve, chacune dans l'ordre de ses rangs ; le tour en porte au moins n.
|
||||
function cheminDOccurrence({ sieges, reserve }, id, n, chemin) {
|
||||
const places = [
|
||||
...sieges.flatMap((liste, table) =>
|
||||
liste.map((occupant, rang) => [occupant, `${chemin}.sieges[${table}][${rang}]`]),
|
||||
),
|
||||
...reserve.map((occupant, rang) => [occupant, `${chemin}.reserve[${rang}]`]),
|
||||
];
|
||||
return places.filter(([occupant]) => occupant === id)[n - 1][1];
|
||||
}
|
||||
|
||||
// Chemin, sous chemin — celui d'un placement —, de ce que désigne une faute
|
||||
// de cohérence de ce placement (fautesDeCoherence), sous la déclaration
|
||||
// qui l'accompagne : le placement pour son nombre de tours ; la liste des
|
||||
// tables d'un tour pour leur nombre ; la liste d'une table au-delà de sa
|
||||
// capacité ; le second exemplaire d'un doublon, le premier d'un inconnu ;
|
||||
// le tour d'un manquant, qu'aucune place ne porte.
|
||||
function cheminDeFaute(faute, { tables, placement }, chemin) {
|
||||
const { code, tour } = faute;
|
||||
if (tour === null) return chemin;
|
||||
const cheminDuTour = `${chemin}[${tour - 1}]`;
|
||||
if (code === 'LONGUEUR') {
|
||||
return faute.table === null ? `${cheminDuTour}.sieges` : `${cheminDuTour}.sieges[${tables.indexOf(faute.table)}]`;
|
||||
}
|
||||
if (code === 'MANQUANT') return cheminDuTour;
|
||||
return cheminDOccurrence(placement[tour - 1], faute.participant, code === 'DOUBLON' ? 2 : 1, cheminDuTour);
|
||||
}
|
||||
|
||||
// Les abscisses des points de relevé d'une course à evalues mouvements
|
||||
// évalués (§ 5.10) : chaque puissance de dix strictement inférieure à
|
||||
// evalues, croissantes ; aucune quand evalues vaut 0 ou 1.
|
||||
function abscisses(evalues) {
|
||||
const liste = [];
|
||||
for (let mouvements = 1; mouvements < evalues; mouvements *= 10) liste.push(mouvements);
|
||||
return liste;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chemin de la première contradiction de course, la course de la recherche
|
||||
* de la proposition de fichier p (§ 5.10, § 8.9), ou null quand il n'y en
|
||||
* a aucune. Le chemin part de « course » : la course elle-même quand elle
|
||||
* n'est pas un objet, sinon la première valeur hors de sa règle
|
||||
* (ENTREE_DE_COURSE) — aucune valeur mesurée, aucune durée, aucune clé
|
||||
* inconnue ; puis evalues au-delà de l'arret de p ; puis, point après
|
||||
* point, un point en trop, une abscisse qui n'est pas la puissance de dix
|
||||
* de son rang, et le placement de relevé, contrôlé sous la déclaration de
|
||||
* p comme son propre placement — chaises vides, puis longueurs et
|
||||
* multiensemble, réserve comprise, nommés comme fautes les nomme sous
|
||||
* course.releves[i].placement ; enfin un point qui manque, nommé au rang
|
||||
* qui le porterait. Une abscisse égale à evalues est en trop : ce point est
|
||||
* le placement de p, qui ne s'écrit pas deux fois. Ne modifie rien ; lève
|
||||
* TypeError quand p n'est pas une proposition cohérente (fautes), qui ne
|
||||
* déclare rien sous quoi lire la course.
|
||||
*
|
||||
* @param {import('./types.js').PropositionFichier} p
|
||||
* @param {*} course
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function fauteDeCourse(p, course) {
|
||||
exigerProposition(p, 'fauteDeCourse');
|
||||
const forme = premiereFaute(course, COURSE, 'course');
|
||||
if (forme !== null) return forme;
|
||||
if (course.evalues > p.arret) return 'course.evalues';
|
||||
const attendues = abscisses(course.evalues);
|
||||
for (let i = 0; i < course.releves.length; i += 1) {
|
||||
const chemin = `course.releves[${i}]`;
|
||||
if (i >= attendues.length) return chemin;
|
||||
const { mouvements, placement } = course.releves[i];
|
||||
if (mouvements !== attendues[i]) return `${chemin}.mouvements`;
|
||||
const releve = { ...p, placement };
|
||||
const chaise = fauteDeChaiseVide(releve);
|
||||
if (chaise !== null) return `${chemin}.${chaise}`;
|
||||
const [premiere] = fautesDeCoherence(releve);
|
||||
if (premiere !== undefined) return cheminDeFaute(premiere, releve, `${chemin}.placement`);
|
||||
}
|
||||
return course.releves.length < attendues.length ? `course.releves[${course.releves.length}]` : null;
|
||||
}
|
||||
|
||||
// Fautes de valeur sous regle : la première faute de forme, de
|
||||
// déclaration ou de chaise vide seule, qui rend la cohérence illisible ;
|
||||
// sinon les fautes de cohérence.
|
||||
|
|
@ -231,7 +317,8 @@ function fautesSelon(valeur, regle) {
|
|||
* d'un tour ; une table pour une liste plus longue que sa capacité — puis,
|
||||
* dans chaque tour, { code: 'DOUBLON'|'INCONNU'|'MANQUANT', tour,
|
||||
* participant } par identifiant croissant. Rend [] pour un placement
|
||||
* cohérent ; ne lève jamais.
|
||||
* cohérent ; ne lève jamais. Une clé course, que le fichier d'état ne
|
||||
* connaît pas, est une faute FORME comme toute clé inconnue.
|
||||
*
|
||||
* Une permutation dans une table, ou l'échange du contenu de deux tables de
|
||||
* même capacité, gardent longueurs et multiensemble : rien dans le fichier
|
||||
|
|
@ -252,6 +339,29 @@ function exigerCoherent(p, fonction) {
|
|||
if (premiere !== undefined) throw new TypeError(`${fonction} : placement fautif ${JSON.stringify(premiere)}`);
|
||||
}
|
||||
|
||||
// Lève TypeError, au nom de fonction, quand p n'est pas une proposition de
|
||||
// fichier cohérente : un retenu, qui n'a pas de course, ou un placement
|
||||
// fautif (exigerCoherent).
|
||||
function exigerProposition(p, fonction) {
|
||||
if (typeof p === 'object' && p !== null && Object.hasOwn(p, 'proposition')) {
|
||||
throw new TypeError(`${fonction} : proposition attendue, reçu le retenu`);
|
||||
}
|
||||
exigerCoherent(p, fonction);
|
||||
}
|
||||
|
||||
// Le placement de fichier d'un plan du moteur (§ 8.9), tour par tour : sans
|
||||
// attribution de sièges, chaque liste de table triée par identifiant
|
||||
// croissant ; avec, dans son ordre, celui des sièges, chaises vides
|
||||
// comprises. La réserve, un ensemble, se trie dans les deux cas. Rien n'est
|
||||
// partagé avec le plan.
|
||||
function placementDeFichier({ tours, reserves }, attribuerSieges) {
|
||||
const ordonner = (liste) => (attribuerSieges ? [...liste] : [...liste].sort(croissant));
|
||||
return tours.map((listes, rang) => ({
|
||||
sieges: listes.map((liste) => ordonner(liste)),
|
||||
reserve: [...reserves[rang]].sort(croissant),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Forme de fichier d'une proposition du moteur (§ 8.9). Son identifiant est
|
||||
* decalage + proposition.id : la place de la proposition dans la suite des
|
||||
|
|
@ -277,9 +387,10 @@ function exigerCoherent(p, fonction) {
|
|||
* par tour : sans attribution de sièges, chaque liste de table se trie par
|
||||
* identifiant croissant ; avec, elle garde son ordre, celui des sièges, et
|
||||
* ses chaises vides, null. La réserve, un ensemble, se trie dans les deux
|
||||
* cas. Les clés suivent l'ordre
|
||||
* du schéma ; rien n'est partagé avec les arguments, qui ne sont pas
|
||||
* modifiés.
|
||||
* cas. La course que la proposition du moteur rapporte — evalues, releves
|
||||
* — n'y entre pas : courseVersFichier la met en forme pour le compagnon des
|
||||
* courses. Les clés suivent l'ordre du schéma ; rien n'est partagé avec les
|
||||
* arguments, qui ne sont pas modifiés.
|
||||
*
|
||||
* Lève ErreurStockage('COMPTEUR_SATURE', { compteur: 'proposition' }), sans
|
||||
* rien rendre, quand l'identifiant atteint 2^31 − 1, le plus grand
|
||||
|
|
@ -312,7 +423,6 @@ export function versFichier(proposition, configuration, { produitVersion, attrib
|
|||
if (identifiant >= IDENTIFIANT.max) throw new ErreurStockage('COMPTEUR_SATURE', { compteur: 'proposition' });
|
||||
const { plan } = proposition;
|
||||
const capaciteDe = new Map(configuration.tables.map(({ id, capacite: places }) => [id, places]));
|
||||
const ordonner = (liste) => (attribuerSieges ? [...liste] : [...liste].sort(croissant));
|
||||
const fichier = {
|
||||
id: identifiant,
|
||||
graine: proposition.graine,
|
||||
|
|
@ -327,15 +437,52 @@ export function versFichier(proposition, configuration, { produitVersion, attrib
|
|||
.filter(({ exclu }) => exclu !== true)
|
||||
.map(({ id }) => id)
|
||||
.sort(croissant),
|
||||
placement: plan.tours.map((listes, rang) => ({
|
||||
sieges: listes.map((liste) => ordonner(liste)),
|
||||
reserve: [...plan.reserves[rang]].sort(croissant),
|
||||
})),
|
||||
placement: placementDeFichier(plan, attribuerSieges),
|
||||
};
|
||||
exigerCoherent(fichier, 'versFichier');
|
||||
return fichier;
|
||||
}
|
||||
|
||||
/**
|
||||
* La course de la recherche d'une proposition du moteur mise en forme de
|
||||
* fichier (§ 5.10, § 8.9), pour le compagnon des courses : { evalues,
|
||||
* releves }, chaque point de relevé { mouvements, placement }, dans l'ordre
|
||||
* des points reçus, son placement mis en forme du plan du point comme
|
||||
* versFichier met en forme celui de la proposition, sous le même drapeau
|
||||
* attribuerSieges. fichier est la proposition de fichier que versFichier a
|
||||
* rendue pour elle, sous laquelle la course se contrôle (fauteDeCourse).
|
||||
* Rend null quand proposition.evalues n'est pas un nombre : un appelant qui
|
||||
* ne rapporte pas la course. Rien n'est partagé avec les arguments, qui ne
|
||||
* sont pas modifiés.
|
||||
*
|
||||
* Lève TypeError, sans rien rendre, quand la course contredit fichier,
|
||||
* nommée par son chemin (fauteDeCourse) — des points de relevé absents
|
||||
* laissent releves absente, que la faute nomme —, et quand le plan d'un
|
||||
* point de relevé déclare d'autres tables que celui de la proposition,
|
||||
* dans un autre ordre compris : ses listes ne s'apparieraient pas aux
|
||||
* tables déclarées, et deux tables de même capacité échangées ne se voient
|
||||
* pas dans le placement.
|
||||
*
|
||||
* @param {import('../moteur/recherche.js').Proposition} proposition
|
||||
* @param {import('./types.js').PropositionFichier} fichier
|
||||
* @param {{attribuerSieges: boolean}} options
|
||||
* @returns {{evalues: number, releves: {mouvements: number, placement: import('./types.js').TourDePlacement[]}[]}|null}
|
||||
*/
|
||||
export function courseVersFichier(proposition, fichier, { attribuerSieges }) {
|
||||
if (typeof proposition.evalues !== 'number') return null;
|
||||
const { tables } = proposition.plan;
|
||||
const releves = proposition.releves?.map(({ mouvements, plan }, i) => {
|
||||
if (plan.tables.length !== tables.length || plan.tables.some((id, rang) => id !== tables[rang])) {
|
||||
throw new TypeError(`courseVersFichier : le point de relevé ${i} déclare d'autres tables que le plan`);
|
||||
}
|
||||
return { mouvements, placement: placementDeFichier(plan, attribuerSieges) };
|
||||
});
|
||||
const course = { evalues: proposition.evalues, releves };
|
||||
const faute = fauteDeCourse(fichier, course);
|
||||
if (faute !== null) throw new TypeError(`courseVersFichier : course fautive ${JSON.stringify({ chemin: faute })}`);
|
||||
return course;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plan du moteur d'un placement de fichier, proposition ou retenu : les
|
||||
* tables dans l'ordre déclaré, chaque liste de table sans ses chaises
|
||||
|
|
@ -369,6 +516,30 @@ export function planSansRevalider(p) {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Les plans de course, la course de la recherche de la proposition de
|
||||
* fichier p (§ 5.10, § 8.9) : { evalues, releves }, chaque point de relevé
|
||||
* { mouvements, plan }, son plan rendu comme planDepuisFichier rend celui
|
||||
* de p — tables déclarées, listes par identifiant croissant sans chaise
|
||||
* vide, réserves rangées —, dans l'ordre des points. Aucune liste n'est
|
||||
* partagée avec p ni avec course. Lève TypeError quand p n'est pas une
|
||||
* proposition cohérente, et quand course la contredit (fauteDeCourse) :
|
||||
* une course contradictoire ne se lit jamais.
|
||||
*
|
||||
* @param {import('./types.js').PropositionFichier} p
|
||||
* @param {{evalues: number, releves: Object[]}} course
|
||||
* @returns {{evalues: number, releves: {mouvements: number, plan: import('../moteur/types.js').Plan}[]}}
|
||||
*/
|
||||
export function courseDepuisFichier(p, course) {
|
||||
const faute = fauteDeCourse(p, course);
|
||||
if (faute !== null) throw new TypeError(`courseDepuisFichier : course fautive ${JSON.stringify({ chemin: faute })}`);
|
||||
const { evalues, releves } = course;
|
||||
return {
|
||||
evalues,
|
||||
releves: releves.map(({ mouvements, placement }) => ({ mouvements, plan: planSansRevalider({ ...p, placement }) })),
|
||||
};
|
||||
}
|
||||
|
||||
// Code, comparé unité UTF-16 par unité, puis identifiant du participant ou
|
||||
// de la table que la raison désigne ; TOURS_CHANGES n'en désigne aucun et
|
||||
// n'apparaît qu'une fois.
|
||||
|
|
|
|||
|
|
@ -21,7 +21,17 @@ import { VERSION } from '../version.genere.js';
|
|||
import { serialiser } from './canonique.js';
|
||||
import { analyser, configurationDepuisCharge, creerCharge } from './document.js';
|
||||
import { ErreurStockage } from './erreurs.js';
|
||||
import { derive, examiner, fautes, formeNommee, planDepuisFichier, versFichier } from './placements.js';
|
||||
import {
|
||||
courseDepuisFichier,
|
||||
courseVersFichier,
|
||||
derive,
|
||||
examiner,
|
||||
fauteDeCourse,
|
||||
fautes,
|
||||
formeNommee,
|
||||
planDepuisFichier,
|
||||
versFichier,
|
||||
} from './placements.js';
|
||||
|
||||
const ENTETE = Object.freeze({ revision: 1, produitVersion: VERSION.affichee });
|
||||
|
||||
|
|
@ -1636,3 +1646,243 @@ describe('le marqueur de chaise vide : null avant le dernier occupant, sièges a
|
|||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// --- La course de la recherche, hors du fichier d'état (§ 8.9, B31) ---------
|
||||
|
||||
// Une course inventée sur la petite démonstration, telle que le moteur la
|
||||
// rend, à evalues mouvements évalués : un point de relevé à chaque
|
||||
// puissance de dix strictement sous evalues. Le point de rang k reprend les
|
||||
// tours du plan parfait décalés de k, chaque liste de table à rebours, hors
|
||||
// de l'ordre croissant ; au premier point, la personne 12 attend en réserve
|
||||
// au tour 1. Aucun appel à la recherche.
|
||||
function courseInventee(evalues) {
|
||||
const releves = [];
|
||||
for (let k = 0, mouvements = 1; mouvements < evalues; k += 1, mouvements *= 10) {
|
||||
const { tours } = PLAN_PARFAIT_PETITE;
|
||||
const decales = tours.map((_, r) => tours[(r + k) % tours.length].map((liste) => [...liste].reverse()));
|
||||
const reserves = decales.map(() => []);
|
||||
if (k === 0) {
|
||||
decales[0][3] = decales[0][3].filter((id) => id !== 12);
|
||||
reserves[0] = [12];
|
||||
}
|
||||
releves.push({ mouvements, plan: { tables: [1, 2, 3, 4], tours: decales, reserves } });
|
||||
}
|
||||
return { evalues, releves };
|
||||
}
|
||||
|
||||
// PARFAITE portant la course inventée à evalues mouvements, 1 234 par
|
||||
// défaut : quatre points de relevé, à 1, 10, 100 et 1 000.
|
||||
const parfaiteAvecCourse = (evalues = 1_234) => ({ ...PARFAITE, ...courseInventee(evalues) });
|
||||
|
||||
// Le plan reçu, chaque liste de table et chaque réserve par identifiant
|
||||
// croissant, sans chaise vide : la forme que rend planDepuisFichier.
|
||||
const planRange = ({ tables, tours, reserves }) => ({
|
||||
tables: [...tables],
|
||||
tours: tours.map((listes) => listes.map((liste) => liste.filter((id) => id !== null).sort((a, b) => a - b))),
|
||||
reserves: reserves.map((reserve) => [...reserve].sort((a, b) => a - b)),
|
||||
});
|
||||
|
||||
// Un plan de configuration forgé sans moteur : à chaque tour, les
|
||||
// participants non exclus, par identifiant croissant décalé de 37 rangs par
|
||||
// tour, remplissent les tables dans l'ordre de la configuration, chacune
|
||||
// jusqu'à sa capacité, chaque liste triée ; ceux qui restent vont à la
|
||||
// réserve. Il porte chaque participant une fois par tour, et sa forme de
|
||||
// fichier pèse ce que pèse celle d'un plan du moteur.
|
||||
function planForge(configuration) {
|
||||
const ids = configuration.participants
|
||||
.filter(({ exclu }) => exclu !== true)
|
||||
.map(({ id }) => id)
|
||||
.sort((a, b) => a - b);
|
||||
const tours = [];
|
||||
const reserves = [];
|
||||
for (let r = 0; r < configuration.tours; r += 1) {
|
||||
const decalage = (37 * r) % ids.length;
|
||||
const ordre = [...ids.slice(decalage), ...ids.slice(0, decalage)];
|
||||
let rang = 0;
|
||||
tours.push(
|
||||
configuration.tables.map(({ capacite }) => {
|
||||
const liste = ordre.slice(rang, rang + capacite).sort((a, b) => a - b);
|
||||
rang += liste.length;
|
||||
return liste;
|
||||
}),
|
||||
);
|
||||
reserves.push(ordre.slice(rang).sort((a, b) => a - b));
|
||||
}
|
||||
return { tables: configuration.tables.map(({ id }) => id), tours, reserves };
|
||||
}
|
||||
|
||||
// La proposition de fichier de PARFAITE, sous attribuerSieges, et sa course
|
||||
// mise en forme de fichier, à evalues mouvements, 1 234 par défaut.
|
||||
function avecSaCourse(attribuerSieges = false, evalues = 1_234) {
|
||||
const proposition = parfaiteAvecCourse(evalues);
|
||||
const options = { produitVersion: VERSION.affichee, attribuerSieges, decalage: 0 };
|
||||
const fichier = versFichier(proposition, petite(), options);
|
||||
return { fichier, course: courseVersFichier(proposition, fichier, { attribuerSieges }) };
|
||||
}
|
||||
|
||||
describe("la course de la recherche hors du fichier d'état (§ 8.9, B31, D53)", () => {
|
||||
test("versFichier n'écrit aucune course, même quand la proposition du moteur en porte une : la proposition de fichier est celle d'une proposition sans course", () => {
|
||||
const options = { produitVersion: VERSION.affichee, attribuerSieges: false, decalage: 0 };
|
||||
const avec = versFichier(parfaiteAvecCourse(), petite(), options);
|
||||
assert.equal(Object.hasOwn(avec, 'course'), false);
|
||||
assert.deepStrictEqual(avec, fichierParfait());
|
||||
assert.deepStrictEqual(versFichier({ ...PARFAITE, evalues: 0, releves: [] }, petite(), options), fichierParfait());
|
||||
});
|
||||
|
||||
test('courseVersFichier met la course en forme de fichier : sans attribution, chaque placement de relevé rangé comme celui de la proposition ; avec, dans l’ordre reçu ; la réserve rangée dans les deux cas', () => {
|
||||
const { releves } = courseInventee(1_234);
|
||||
const nonAttribues = avecSaCourse(false).course;
|
||||
const attribues = avecSaCourse(true).course;
|
||||
assert.equal(nonAttribues.evalues, 1_234);
|
||||
assert.deepStrictEqual(nonAttribues.releves.map(({ mouvements }) => mouvements), [1, 10, 100, 1_000]);
|
||||
nonAttribues.releves.forEach(({ placement }, i) => {
|
||||
const { tours, reserves } = planRange(releves[i].plan);
|
||||
assert.deepStrictEqual(placement, tours.map((sieges, r) => ({ sieges, reserve: reserves[r] })), `relevé ${i}`);
|
||||
});
|
||||
attribues.releves.forEach(({ placement }, i) => {
|
||||
const { tours, reserves } = releves[i].plan;
|
||||
assert.deepStrictEqual(placement, tours.map((sieges, r) => ({ sieges, reserve: reserves[r] })), `relevé ${i}`);
|
||||
});
|
||||
assert.deepStrictEqual(Object.keys(nonAttribues), ['evalues', 'releves']);
|
||||
});
|
||||
|
||||
test('sans evalues, courseVersFichier rend null ; à zéro mouvement évalué, {evalues: 0, releves: []}', () => {
|
||||
const fichier = fichierParfait();
|
||||
assert.equal(courseVersFichier(PARFAITE, fichier, { attribuerSieges: false }), null);
|
||||
const nulle = courseVersFichier({ ...PARFAITE, evalues: 0, releves: [] }, fichier, { attribuerSieges: false });
|
||||
assert.deepStrictEqual(nulle, { evalues: 0, releves: [] });
|
||||
assert.equal(fauteDeCourse(fichier, nulle), null);
|
||||
});
|
||||
|
||||
test('aller-retour courseVersFichier → JSON → courseDepuisFichier, sièges attribués ou non : la course reçue, en plans par identifiants rangés', () => {
|
||||
for (const attribuerSieges of [false, true]) {
|
||||
const { fichier, course } = avecSaCourse(attribuerSieges);
|
||||
const relue = JSON.parse(JSON.stringify(course));
|
||||
assert.equal(fauteDeCourse(fichier, relue), null, `attribuerSieges ${attribuerSieges}`);
|
||||
const { evalues, releves } = courseInventee(1_234);
|
||||
assert.deepStrictEqual(courseDepuisFichier(fichier, relue), {
|
||||
evalues,
|
||||
releves: releves.map(({ mouvements, plan }) => ({ mouvements, plan: planRange(plan) })),
|
||||
});
|
||||
}
|
||||
const { course } = avecSaCourse(false, 0);
|
||||
assert.deepStrictEqual(courseDepuisFichier(fichierParfait(), course), { evalues: 0, releves: [] });
|
||||
leveTypeError(() => courseDepuisFichier({ ...fichierParfait(), tours: 5 }, course), /placement fautif/);
|
||||
const contredite = { ...avecSaCourse(false).course, evalues: 20_001 };
|
||||
leveTypeError(() => courseDepuisFichier(fichierParfait(), contredite), /course\.evalues/);
|
||||
});
|
||||
|
||||
test('courseDepuisFichier ne partage aucune liste avec la proposition ni avec la course', () => {
|
||||
const { fichier, course } = avecSaCourse(true);
|
||||
geler(fichier);
|
||||
geler(course);
|
||||
const { releves } = courseDepuisFichier(fichier, course);
|
||||
releves.forEach(({ plan }, i) => {
|
||||
const { placement } = course.releves[i];
|
||||
assert.notEqual(plan.tables, fichier.tables);
|
||||
plan.tours.forEach((listes, r) => {
|
||||
assert.notEqual(plan.reserves[r], placement[r].reserve);
|
||||
listes.forEach((liste, t) => assert.notEqual(liste, placement[r].sieges[t]));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('fauteDeCourse nomme par son chemin la première contradiction : forme, evalues au-delà de arret, abscisse manquante, en trop, hors d’ordre ou au dernier point, placement de relevé qui contredit la déclaration', () => {
|
||||
// Chaque cas abîme une copie de la course, sous le drapeau donné, et
|
||||
// nomme le chemin attendu.
|
||||
const cas = [
|
||||
['une durée dans un point', false, (c) => { c.releves[1].duree = 3; }, 'course.releves[1].duree'],
|
||||
['une valeur mesurée', false, (c) => { c.valeurs = { rencontres: 3 }; }, 'course.valeurs'],
|
||||
['evalues au-delà de arret', false, (c) => { c.evalues = 20_001; }, 'course.evalues'],
|
||||
['abscisse manquante au milieu', false, (c) => { c.releves.splice(1, 1); }, 'course.releves[1].mouvements'],
|
||||
['abscisse manquante à la fin', false, (c) => { c.releves.pop(); }, 'course.releves[3]'],
|
||||
['abscisse en trop', false, (c) => {
|
||||
c.releves.push({ ...structuredClone(c.releves[3]), mouvements: 10_000 });
|
||||
}, 'course.releves[4]'],
|
||||
['abscisses hors d’ordre', false, (c) => {
|
||||
const [a, b] = [c.releves[1], c.releves[2]];
|
||||
c.releves[1] = b;
|
||||
c.releves[2] = a;
|
||||
}, 'course.releves[1].mouvements'],
|
||||
['abscisse qui n’est pas une puissance de dix', false, (c) => { c.releves[0].mouvements = 5; }, 'course.releves[0].mouvements'],
|
||||
['abscisse 1 000 sous evalues 1 000 : elle est le placement', false, (c) => { c.evalues = 1_000; }, 'course.releves[3]'],
|
||||
['abscisse 1 sous evalues 1', false, (c) => {
|
||||
c.evalues = 1;
|
||||
c.releves.splice(1);
|
||||
}, 'course.releves[0]'],
|
||||
['un tour de moins', false, (c) => { c.releves[1].placement.pop(); }, 'course.releves[1].placement'],
|
||||
['une liste de table de moins', false, (c) => { c.releves[2].placement[1].sieges.pop(); }, 'course.releves[2].placement[1].sieges'],
|
||||
['une table au-delà de sa capacité', false, (c) => {
|
||||
const { sieges } = c.releves[1].placement[0];
|
||||
sieges[2].push(sieges[3].shift());
|
||||
}, 'course.releves[1].placement[0].sieges[2]'],
|
||||
['un identifiant dupliqué : le second nommé', false, (c) => {
|
||||
const tour = c.releves[2].placement[3];
|
||||
tour.reserve.push(tour.sieges[1][0]);
|
||||
}, 'course.releves[2].placement[3].reserve[0]'],
|
||||
['un identifiant inconnu', false, (c) => { c.releves[3].placement[2].sieges[0][1] = 13; }, 'course.releves[3].placement[2].sieges[0][1]'],
|
||||
['un identifiant manquant sans entrée de réserve', false, (c) => {
|
||||
c.releves[0].placement[0].reserve = [];
|
||||
}, 'course.releves[0].placement[0]'],
|
||||
['une chaise vide sans attribution', false, (c) => {
|
||||
c.releves[0].placement[0].sieges[3] = [null, 10, 11];
|
||||
c.releves[0].placement[0].reserve = [12];
|
||||
}, 'course.releves[0].placement[0].sieges[3][0]'],
|
||||
['une chaise vide en fin de liste, sièges attribués', true, (c) => {
|
||||
const tour = c.releves[1].placement[2];
|
||||
const [premier] = tour.sieges[0].splice(0, 1);
|
||||
tour.sieges[0].push(null);
|
||||
tour.reserve = [premier];
|
||||
}, 'course.releves[1].placement[2].sieges[0][2]'],
|
||||
];
|
||||
assert.equal(cas.length, 18);
|
||||
const ecarts = cas.flatMap(([libelle, attribuerSieges, abimer, chemin]) => {
|
||||
const { fichier, course } = avecSaCourse(attribuerSieges);
|
||||
const abimee = structuredClone(course);
|
||||
abimer(abimee);
|
||||
const rendu = fauteDeCourse(fichier, abimee);
|
||||
return rendu === chemin ? [] : [`${libelle} : ${rendu}`];
|
||||
});
|
||||
assert.deepEqual(ecarts, []);
|
||||
for (const valeur of [undefined, null, 3, 'x', []]) assert.equal(fauteDeCourse(fichierParfait(), valeur), 'course');
|
||||
});
|
||||
|
||||
test('les bornes admises : evalues égal à arret, et ses points de relevé exactement les puissances de dix sous lui', () => {
|
||||
for (const evalues of [0, 1, 2, 10, 11, 1_000, 1_001, 20_000]) {
|
||||
const { fichier, course } = avecSaCourse(false, evalues);
|
||||
assert.equal(course.evalues, evalues);
|
||||
assert.equal(fauteDeCourse(fichier, course), null, `evalues ${evalues}`);
|
||||
}
|
||||
assert.deepStrictEqual(avecSaCourse(false, 20_000).course.releves.map(({ mouvements }) => mouvements), [1, 10, 100, 1_000, 10_000]);
|
||||
});
|
||||
|
||||
test('courseVersFichier lève TypeError pour une course qui se contredit, et pour un point de relevé sur d’autres tables que le plan', () => {
|
||||
const fichier = fichierParfait();
|
||||
const options = { attribuerSieges: false };
|
||||
leveTypeError(() => courseVersFichier({ ...parfaiteAvecCourse(), evalues: 20_001 }, fichier, options), /course\.evalues/);
|
||||
// Sans points de relevé, la course n'a pas de releves : la faute nomme
|
||||
// la clé, et non un point.
|
||||
for (const evalues of [0, 1, 12]) {
|
||||
leveTypeError(() => courseVersFichier({ ...PARFAITE, evalues }, fichier, options), /course\.releves\b(?!\[)/);
|
||||
}
|
||||
const autresTables = parfaiteAvecCourse();
|
||||
autresTables.releves[1] = { ...autresTables.releves[1], plan: { ...autresTables.releves[1].plan, tables: [2, 1, 3, 4] } };
|
||||
leveTypeError(() => courseVersFichier(autresTables, fichier, options), /relevé/);
|
||||
});
|
||||
|
||||
test('la taille d’une course sur la grande démonstration, à la forme réelle : six placements de relevé de 1 040 places, chacun du poids du placement de la proposition', () => {
|
||||
const configuration = CATALOGUE.find(({ cle }) => cle === 'grande').construire();
|
||||
const plan = planForge(configuration);
|
||||
const releves = [1, 10, 100, 1_000, 10_000, 100_000].map((mouvements) => ({ mouvements, plan }));
|
||||
const proposition = { id: 1, graine: 1, arret: 200_000, historique: HISTORIQUE_PAR_DEFAUT, plan, evalues: 200_000, releves };
|
||||
const fichier = versFichier(proposition, configuration, { produitVersion: VERSION.affichee, attribuerSieges: false, decalage: 0 });
|
||||
const course = courseVersFichier(proposition, fichier, { attribuerSieges: false });
|
||||
assert.equal(fichier.participants.length * fichier.tours, 1_040);
|
||||
const placement = JSON.stringify(fichier.placement).length;
|
||||
const octets = JSON.stringify(course).length;
|
||||
// Six placements et leurs abscisses : la forme positionnelle, sans
|
||||
// rien qui nomme une place.
|
||||
assert.ok(octets > 6 * placement && octets < 6.1 * placement, `course ${octets} o, placement ${placement} o`);
|
||||
assert.ok(octets > 15_000 && octets < 40_000, `course ${octets} o`);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2091,6 +2091,7 @@ const MODULES_A_SEUIL = [
|
|||
'src/moteur/indicateurs.js',
|
||||
'src/moteur/paires.js',
|
||||
'src/moteur/plafond.js',
|
||||
'src/moteur/qualite.js',
|
||||
'src/stockage/depot.js',
|
||||
'src/stockage/journal.js',
|
||||
];
|
||||
|
|
@ -2237,6 +2238,7 @@ describe('arborescence : la couverture (§ 14.13)', () => {
|
|||
'src/moteur/indicateurs.js': { branches: 100 },
|
||||
'src/moteur/paires.js': { branches: 100 },
|
||||
'src/moteur/plafond.js': { branches: 95 },
|
||||
'src/moteur/qualite.js': { branches: 100 },
|
||||
'src/stockage/journal.js': { branches: 100, lines: 100 },
|
||||
'src/csv/**': { branches: 100 },
|
||||
},
|
||||
|
|
@ -2249,6 +2251,7 @@ describe('arborescence : la couverture (§ 14.13)', () => {
|
|||
'src/moteur/diversite.js': '',
|
||||
'src/moteur/indicateurs.js': '',
|
||||
'src/moteur/paires.js': '',
|
||||
'src/moteur/qualite.js': '',
|
||||
'src/stockage/depot.js': '',
|
||||
'src/stockage/journal.js': '',
|
||||
};
|
||||
|
|
@ -2271,6 +2274,7 @@ describe('arborescence : la couverture (§ 14.13)', () => {
|
|||
'src/moteur/diversite.js : hors de la mesure',
|
||||
'src/moteur/indicateurs.js : hors de la mesure',
|
||||
'src/moteur/paires.js : hors de la mesure',
|
||||
'src/moteur/qualite.js : hors de la mesure',
|
||||
'src/stockage/depot.js : hors de la mesure',
|
||||
'src/stockage/journal.js : hors de la mesure',
|
||||
],
|
||||
|
|
|
|||
|
|
@ -31,10 +31,12 @@ const ENTREES = [
|
|||
['data-bandeau-plan', 'plan : le bandeau au-dessus de la surface', ['']],
|
||||
['data-bloc', 'à propos : le bloc de version à copier', ['']],
|
||||
['data-cartouche', 'aperçu : le cartouche de repérage d’une page du plan imprimé', ['']],
|
||||
['data-case', 'qualité : une case d’une grille de graphique, par « ligne:colonne »', null],
|
||||
['data-cellule', 'import : une cellule de l’aperçu, par son champ', null],
|
||||
['data-champ', 'un champ de saisie ou un sélecteur, par son nom', null],
|
||||
['data-chargement', 'accueil : le signe d’un calcul en cours', ['']],
|
||||
['data-chargement-liste', 'accueil : l’annonce du calcul des colonnes de la liste', ['']],
|
||||
['data-chargement-section', 'événement : l’attente d’une section chargée à part, par son nom', null],
|
||||
['data-chemin-planche', 'badges : le chemin de la planche produite, ou son nom téléchargé', ['']],
|
||||
['data-choix-siege', 'plan : un siège offert par le panneau d’une table, par son rang', null],
|
||||
['data-cible', 'la cible d’une commande : section, groupe, colonne, vue imprimée ou papier', null],
|
||||
|
|
@ -48,6 +50,7 @@ const ENTREES = [
|
|||
['data-contenu', 'aperçu : la zone de contenu d’une feuille imprimée', ['']],
|
||||
['data-commande', 'un bouton de commande, par son nom', null],
|
||||
['data-compte', 'import : un compte du rapport, par son nom', null],
|
||||
['data-compte-pire', 'qualité : le compte du pire, ou de la ligne, sous un pourcentage, dans le même composant', ['']],
|
||||
['data-conflit', 'participants : une ligne de conflit d’un groupe', ['']],
|
||||
['data-conflit-personne', 'participants : les règles d’une personne en conflit', ['']],
|
||||
['data-conflits', 'participants : les conflits d’un groupe, par sa clé', null],
|
||||
|
|
@ -65,6 +68,7 @@ const ENTREES = [
|
|||
['data-doublon', 'un doublon : base de la liste, ou ligne de l’aperçu', null],
|
||||
['data-doublons', 'import : les doublons de l’aperçu', ['']],
|
||||
['data-duree', 'génération : la durée estimée', ['']],
|
||||
['data-echec-section', 'événement : la phrase d’une section chargée à part qui ne s’est pas chargée, par son nom', null],
|
||||
['data-ecran', 'un écran de l’application', ['accueil', 'apercu', 'apropos', 'evenement', 'recuperation']],
|
||||
['data-encodage', 'import : l’encodage nommé', ['']],
|
||||
['data-enonce', 'un énoncé : diagnostic, ou défaut des tables', null],
|
||||
|
|
@ -88,6 +92,7 @@ const ENTREES = [
|
|||
['data-fusion', 'import : un groupe d’orthographes fondu', ['']],
|
||||
['data-fusions', 'import : les groupes d’orthographes', ['']],
|
||||
['data-fusions-etiquettes', 'import : les étiquettes fondues, à part des appartenances', ['']],
|
||||
['data-graphique', 'qualité : le <svg> d’un graphique, par son nom', null],
|
||||
['data-groupe', 'participants : l’en-tête d’un groupe, par sa clé', null],
|
||||
['data-groupe-compte', 'participants : le compte d’un groupe', ['']],
|
||||
['data-groupe-conflits', 'participants : la marque de conflit d’un groupe', ['']],
|
||||
|
|
@ -99,11 +104,13 @@ const ENTREES = [
|
|||
['data-impression', 'aperçu : le document imprimé', ['']],
|
||||
['data-hors-classement', 'propositions : les lignes hors classement', ['']],
|
||||
['data-issue', 'génération : l’issue de la dernière génération', ['']],
|
||||
['data-jumeau', 'qualité : un tableau jumeau ouvert, par son rang dans le jumeau', null],
|
||||
['data-legende', 'plan : la légende des conflits', ['']],
|
||||
['data-legende-etiquettes', 'participants : la légende des étiquettes', ['']],
|
||||
['data-legende-pastille', 'plan : la pastille de la légende', ['']],
|
||||
['data-legende-regle', 'plan : une entrée de la légende, par sa règle', null],
|
||||
['data-ligne', 'import : une ligne de l’aperçu, par son rang', null],
|
||||
['data-ligne-graphique', 'qualité : une ligne de barres, par l’identifiant de ce qu’elle porte', null],
|
||||
['data-ligne-imprimee', 'aperçu : une ligne d’une liste imprimée, par son rang dans la liste', null],
|
||||
['data-lignes', 'import : le tableau des lignes', ['']],
|
||||
['data-liste', 'plan : la liste de noms d’une table', null],
|
||||
|
|
@ -138,17 +145,21 @@ const ENTREES = [
|
|||
['data-partie', 'plan : une partie d’une ligne de liste ou du fantôme', null],
|
||||
['data-pastille', 'plan : la pastille d’une table', null],
|
||||
['data-phrase', 'une phrase : diagnostic ou défaut des tables', ['']],
|
||||
['data-phrase-qualite', 'qualité : la phrase d’un cas de la page, par son code', null],
|
||||
['data-pied', 'aperçu : le pied d’une feuille imprimée', ['']],
|
||||
['data-place-fixe', 'événement : la place fixe de la barre de l’événement', ['']],
|
||||
['data-place-vide', 'aperçu : une place vide de la feuille par table', ['']],
|
||||
['data-plan', 'plan : le <svg> du plan', ['']],
|
||||
['data-plancher-objectifs', 'diagnostic : la phrase d’un plancher des objectifs, par la valeur que le mode compare', null],
|
||||
['data-poignee', 'plan : la poignée d’une table', null],
|
||||
['data-point-releve', 'qualité : un point de relevé d’un petit multiple, par ses mouvements évalués', null],
|
||||
['data-portee', 'tables : la portée d’un objectif, tous pour la soirée, tour pour un tour désigné', ['tous', 'tour']],
|
||||
['data-pourcentage', 'qualité : un pourcentage rendu, que suit son compte', ['']],
|
||||
['data-priorite', 'génération : un critère de l’éditeur des priorités, par son nom', null],
|
||||
['data-priorites', 'génération : la liste des paliers de l’éditeur des priorités', ['']],
|
||||
['data-proposition', 'propositions : une ligne, par son identifiant', null],
|
||||
['data-question', 'une question posée : dossier de travail ou durée longue', null],
|
||||
['data-rang', 'qualité : la désignation de la cible pointée d’un graphique, par son rang à partir de 1', null],
|
||||
['data-rapport', 'import : le rapport', ['']],
|
||||
['data-refus', 'un refus, par son code ou sa place', null],
|
||||
['data-refus-realigner', 'tables : le refus du réalignement', ['']],
|
||||
|
|
@ -156,16 +167,20 @@ const ENTREES = [
|
|||
['data-reglages', 'génération : les réglages', ['']],
|
||||
['data-regle-objectifs', 'tables : la règle par défaut des objectifs', ['']],
|
||||
['data-remede', 'diagnostic : un remède', ['']],
|
||||
['data-repere', 'qualité : le tracé d’un repère — plafond, borne ou règle à 100 % (cent) —, par son genre', null],
|
||||
['data-reserve', 'plan : la réserve d’un tour', null],
|
||||
['data-retenu', 'propositions : la ligne du retenu', null],
|
||||
['data-secours', 'récupération : une source de secours', null],
|
||||
['data-section', 'une section de l’événement', ['badges', 'diagnostic', 'generation', 'import', 'participants', 'plan', 'propositions', 'tables']],
|
||||
['data-segment', 'qualité : un segment de barre, par son genre', null],
|
||||
['data-section', 'une section de l’événement', ['badges', 'diagnostic', 'generation', 'import', 'participants', 'plan', 'propositions', 'qualite', 'tables']],
|
||||
['data-separateur', 'import : le séparateur nommé', ['']],
|
||||
['data-serie', 'qualité : le tracé d’une série, serie1 ou serie2', null],
|
||||
['data-siege', 'plan : un siège, par sa table et son numéro', null],
|
||||
['data-sieges-retires', 'tables : les sièges retirés', ['']],
|
||||
['data-signalement', 'accueil : un signalement d’une ligne', null],
|
||||
['data-socle', 'plan : le socle d’une pastille', ['']],
|
||||
['data-source', 'plan : le placement montré, dans la barre de vue', ['']],
|
||||
['data-source-qualite', 'qualité : la ligne de source, le placement que la page mesure', ['']],
|
||||
['data-support-rompu', 'événement : le message du support rompu', ['']],
|
||||
['data-surface', 'plan : la surface du plan', ['']],
|
||||
['data-table', 'une table, par son identifiant', null],
|
||||
|
|
@ -183,6 +198,11 @@ const ENTREES = [
|
|||
['data-version', 'accueil : la version affichée', ['']],
|
||||
['data-vue', 'plan : le groupe de la vue', ['']],
|
||||
['data-vue-impression', 'aperçu : la vue du document imprimé — plan, accueil ou tables', null],
|
||||
[
|
||||
'data-vue-qualite',
|
||||
'qualité : le <div> qui enveloppe la carte d’une vue',
|
||||
['entete', 'profil', 'collisions', 'tables', 'paires', 'objectifs', 'animateurs', 'diversite', 'performance'],
|
||||
],
|
||||
];
|
||||
|
||||
// La Map des entrées, gelée : Object.freeze ne gèle pas le contenu d'une
|
||||
|
|
|
|||
|
|
@ -26,11 +26,18 @@
|
|||
// de la police d'interface (attendrePolices) avant de monter App : un
|
||||
// mesureur de texte créé au premier rendu lit la police embarquée, non son
|
||||
// repli. Une face qui ne se charge pas fait échouer le montage.
|
||||
//
|
||||
// La section de la qualité, que src/main.js charge par son propre morceau,
|
||||
// est ici importée d'emblée : la fonction qu'App reçoit rend le composant
|
||||
// tel quel, et la section se monte sans état de chargement, au rendu même
|
||||
// qui l'ouvre. Une épreuve de ce chargement passe sa propre fonction, qui
|
||||
// rend une promesse.
|
||||
import { mount, unmount } from 'svelte';
|
||||
import { creerExecuteurDirect } from '../../src/application/generation.js';
|
||||
import { creerHorlogePilotee } from '../../src/application/horloge.js';
|
||||
import { demonstrationsLivrees } from '../../src/demo/livrees.js';
|
||||
import App from '../../src/interface/App.svelte';
|
||||
import SectionQualite from '../../src/interface/qualite/SectionQualite.svelte';
|
||||
import { attendrePolices, chargerPolices } from '../../src/polices/chargement.js';
|
||||
import { FORMAT } from '../../src/stockage/document.js';
|
||||
import { VERSION } from '../../src/version.genere.js';
|
||||
|
|
@ -118,6 +125,9 @@ export function tracer(fs) {
|
|||
* sinon chargerPolices, celui de src/main.js
|
||||
* @param {Object} [options.imprimante] l'imprimante qu'App reçoit ; sinon
|
||||
* une imprimante d'épreuve neuve, dont chaque impression est envoyée
|
||||
* @param {() => Function | PromiseLike<Function>} [options.sectionQualite]
|
||||
* le chargement de la section de la qualité qu'App reçoit ; sinon une
|
||||
* fonction qui rend le composant importé d'emblée
|
||||
* @returns {Promise<Object>} { app, racine, fs, disque, trace, horloge,
|
||||
* presse, polices, imprimante, seance, facade, ui, erreurs(), demonter() }
|
||||
* — polices est le chargement de la police qu'App a reçu, imprimante
|
||||
|
|
@ -139,6 +149,7 @@ export async function monterApp({
|
|||
demonstrations,
|
||||
polices = chargerPolices,
|
||||
imprimante = creerImprimanteSimulee(),
|
||||
sectionQualite = () => SectionQualite,
|
||||
} = {}) {
|
||||
await installerCollecteur();
|
||||
await attendrePolices(document);
|
||||
|
|
@ -189,6 +200,7 @@ export async function monterApp({
|
|||
versionWindows: VERSION.windows,
|
||||
polices,
|
||||
imprimante,
|
||||
sectionQualite,
|
||||
...(theme === null ? {} : { themeInitial: theme }),
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,9 +8,10 @@
|
|||
// la règle porte sur ce que le code fait, et un commentaire qui nomme un
|
||||
// interdit le documente.
|
||||
//
|
||||
// Neuf gardes : les chaînes visibles en dur (§ 14.6), les clés littérales de
|
||||
// Dix gardes : les chaînes visibles en dur (§ 14.6), les clés littérales de
|
||||
// t qui n'existent pas, le contenu des tables de traduction (§ 5.5, § 7.6,
|
||||
// § 7.7, § 12.8, § 12.10.8), les couleurs (§ 12.7, § 13.3, § 13.5), le
|
||||
// § 7.7, § 12.8, § 12.10.8), le signe « % » écrit par le code (contrainte
|
||||
// 44), les couleurs (§ 12.7, § 13.3, § 13.5), le
|
||||
// balisage et les fenêtres du navigateur (§ 13.1, § 2.1), le texte comprimé et
|
||||
// les animations (§ 7.4, § 19.2), le câblage des composants (§ 12.9, § 13.3),
|
||||
// les sélecteurs que le compilateur élague (§ 13.3), les touches nommées hors
|
||||
|
|
@ -192,7 +193,10 @@ const dansUnAttribut = (ancetres) => ancetres.some((a) => DANS_UN_ATTRIBUT.has(a
|
|||
|
||||
// --- Garde 1 : les chaînes visibles en dur (§ 14.6) ---------------------------
|
||||
|
||||
const LETTRE = /\p{L}/u;
|
||||
// Une chaîne visible : une lettre, ou le signe « % », qui n'écrit qu'un
|
||||
// pourcentage de la page de qualité, lu sous qualite.pourcentage.*
|
||||
// (contrainte 44) ; seul, sans lettre, il se relève comme elle.
|
||||
const VISIBLE = /[\p{L}%]/u;
|
||||
// Attributs qu'une technologie d'assistance annonce, ou que la page montre.
|
||||
const ATTRIBUTS_ANNONCES = new Set(['aria-label', 'aria-roledescription', 'title', 'placeholder', 'alt']);
|
||||
// Les types d'<input> dont la page montre la value comme libellé.
|
||||
|
|
@ -213,16 +217,18 @@ const TEXTES_SVG = new Set(['title', 'desc', 'text', 'tspan']);
|
|||
const CHAINE_CSS = /(["'])((?:\\.|(?!\1).)*)\1/g;
|
||||
|
||||
// Vrai quand un texte placé dans cet élément est une chaîne visible : une
|
||||
// lettre partout, tout caractère non blanc dans un texte du SVG.
|
||||
// lettre ou le signe « % » partout, tout caractère non blanc dans un texte
|
||||
// du SVG.
|
||||
function texteVisible(texte, element) {
|
||||
if (element !== null && element.type === 'RegularElement' && TEXTES_SVG.has(element.name)) {
|
||||
return texte.trim() !== '';
|
||||
}
|
||||
return LETTRE.test(texte);
|
||||
return VISIBLE.test(texte);
|
||||
}
|
||||
|
||||
// Vrai quand une valeur de « content » porte une lettre dans une chaîne.
|
||||
const contenuLettre = (valeur) => [...valeur.matchAll(CHAINE_CSS)].some(([, , chaine]) => LETTRE.test(chaine));
|
||||
// Vrai quand une valeur de « content » porte une lettre ou le signe « % »
|
||||
// dans une chaîne.
|
||||
const contenuLettre = (valeur) => [...valeur.matchAll(CHAINE_CSS)].some(([, , chaine]) => VISIBLE.test(chaine));
|
||||
|
||||
// Relevé des chaînes visibles écrites dans les composants de src/interface :
|
||||
// texte statique, attribut annoncé ou value d'un <input> bouton, contenu
|
||||
|
|
@ -250,7 +256,7 @@ function releverChainesEnDur(racine) {
|
|||
? [{ valeur: morceau.statique, debut: morceau.debut }]
|
||||
: litterauxRendus(morceau.expression);
|
||||
for (const { valeur, debut } of valeurs) {
|
||||
if (LETTRE.test(valeur)) relever(debut, `attribut ${noeud.name}`, valeur);
|
||||
if (VISIBLE.test(valeur)) relever(debut, `attribut ${noeud.name}`, valeur);
|
||||
}
|
||||
}
|
||||
} else if (noeud.type === 'Attribute' && noeud.name === 'style') {
|
||||
|
|
@ -270,6 +276,42 @@ function releverChainesEnDur(racine) {
|
|||
return trier(releves);
|
||||
}
|
||||
|
||||
// Un pourcentage écrit : le signe « % » après un blanc, espace fine
|
||||
// insécable comprise, comme le français l'écrit — « 88 % ». Une largeur ou
|
||||
// une position de style, « 100% » ou `${x}%`, colle le signe au nombre.
|
||||
const POURCENTAGE_ECRIT = /\s%/u;
|
||||
// Les tables de traduction : la garde de leur contenu les lit, avec ses
|
||||
// exceptions nommées (qualite.pourcentage.*).
|
||||
const TABLES_DE_TRADUCTION = /^src\/interface\/libelles\//;
|
||||
|
||||
// Relevé des pourcentages que le code de src/interface écrit hors des tables
|
||||
// de traduction (contrainte 44) : chaque chaîne littérale, et chaque morceau
|
||||
// fixe d'un gabarit de chaîne, des modules et de chaque partie JavaScript
|
||||
// d'un composant, qui porte POURCENTAGE_ECRIT. Un morceau se lit seul : le
|
||||
// signe qui suit une expression, « ${a} ${b}% », n'est pas précédé d'un blanc.
|
||||
// Le gabarit d'un composant est l'affaire de releverChainesEnDur, qui y relève
|
||||
// le signe comme une lettre.
|
||||
function releverPourcentagesDesScripts(racine) {
|
||||
const chemins = exigerNonVide(
|
||||
sources(racine, ['src/interface'], ['.svelte', '.js']).filter((chemin) => !TABLES_DE_TRADUCTION.test(chemin)),
|
||||
'pourcentages des scripts',
|
||||
);
|
||||
const releves = [];
|
||||
for (const chemin of chemins) {
|
||||
const composant = chemin.endsWith('.svelte');
|
||||
const { texte, ast } = composant ? analyserComposant(racine, chemin) : analyserModule(racine, chemin);
|
||||
parcourir(composant ? partiesCode(ast) : [ast], (noeud) => {
|
||||
let valeur = null;
|
||||
if (noeud.type === 'Literal' && typeof noeud.value === 'string') valeur = noeud.value;
|
||||
else if (noeud.type === 'TemplateElement') valeur = noeud.value.cooked ?? noeud.value.raw;
|
||||
if (valeur !== null && POURCENTAGE_ECRIT.test(valeur)) {
|
||||
releves.push({ chemin, ligne: ligneDe(texte, noeud.start), texte: `pourcentage ${extrait(valeur)}` });
|
||||
}
|
||||
});
|
||||
}
|
||||
return trier(releves);
|
||||
}
|
||||
|
||||
// Relevé trié par chemin, puis par ligne, puis par texte ; rendu en chaînes.
|
||||
function trier(releves) {
|
||||
return releves
|
||||
|
|
@ -344,6 +386,7 @@ const MOTS_INTERDITS = [
|
|||
['manque minimal', /\bmanques?\s+minima/],
|
||||
['récupérable', /recuperable/],
|
||||
['évitable', /(?<!in)evitable/],
|
||||
['pourcentage', /%/],
|
||||
];
|
||||
const PICTOGRAMME = /\p{Extended_Pictographic}/u;
|
||||
|
||||
|
|
@ -363,6 +406,13 @@ const PICTOGRAMME = /\p{Extended_Pictographic}/u;
|
|||
// jamais un indice d'équité (§ 12.8). La forme exacte s'admet sous la seule
|
||||
// clé de ce libellé ; son explication, et tout autre texte, ne l'emploient
|
||||
// pas.
|
||||
//
|
||||
// Le signe « % » n'écrit que les pourcentages de la page de qualité (§ 12.8,
|
||||
// D34, D52) : la valeur « N % », les graduations de l'axe en pourcentages et
|
||||
// le libellé de sa règle, sous les seules clés qualite.pourcentage.*. La
|
||||
// consigne d'impression de la planche, « à 100 % », est une échelle, non un
|
||||
// mélange, et s'admet sous sa seule clé. Partout ailleurs, au tableau des
|
||||
// propositions compris, le signe se relève.
|
||||
const EXCEPTIONS_DE_CONTENU = [
|
||||
{
|
||||
mot: 'évitable',
|
||||
|
|
@ -376,11 +426,31 @@ const EXCEPTIONS_DE_CONTENU = [
|
|||
forme: /(?<![\p{L}\p{N}])l'equite d'abord(?![\p{L}\p{N}])/gu,
|
||||
nom: "generation.reglage.modeObjectifs.minimum « l'équité d'abord » (D22)",
|
||||
},
|
||||
{
|
||||
mot: 'pourcentage',
|
||||
cles: /^qualite\.pourcentage\./,
|
||||
forme: /%/g,
|
||||
nom: 'qualite.pourcentage.* « % » (D34, D52)',
|
||||
},
|
||||
{
|
||||
mot: 'pourcentage',
|
||||
cles: /^planche\.controle\.consigne$/,
|
||||
forme: /(?<![\p{L}\p{N}])a\s100\s%/gu,
|
||||
nom: 'planche.controle.consigne « à 100 % »',
|
||||
},
|
||||
];
|
||||
|
||||
// Les exceptions d'un seul mot, pour une épreuve qui n'éprouve que lui.
|
||||
const exceptionsDu = (mot) => EXCEPTIONS_DE_CONTENU.filter((exception) => exception.mot === mot);
|
||||
|
||||
// Deux entrées qui servent les exceptions du signe « % », pour une épreuve
|
||||
// qui en éprouve une autre sous toute la liste : sans elles, chacune se
|
||||
// relèverait sans objet.
|
||||
const SERVENT_LE_POURCENTAGE = [
|
||||
['qualite.pourcentage.valeur', '88 %'],
|
||||
['planche.controle.consigne', 'Imprimer en recto seul, à 100 %'],
|
||||
];
|
||||
|
||||
// Une chaîne de forme clé — un code, LECTURE_SEULE, ou un libellé,
|
||||
// plan.refus.fermer — : une fonction la passe à texte, ou la compare à un
|
||||
// détail, et ne l'affiche pas.
|
||||
|
|
@ -1216,6 +1286,66 @@ describe('sources de l’interface : les chaînes visibles en dur (§ 14.6)', ()
|
|||
/chaînes en dur : aucun fichier examiné/,
|
||||
);
|
||||
});
|
||||
|
||||
test('rouge : un « % » écrit dans un gabarit se relève comme une lettre — texte, expression rendue, attribut annoncé, content —, et pas une largeur de style ni sa directive', () => {
|
||||
const fichiers = {
|
||||
'src/interface/qualite/T.svelte': composant({
|
||||
script: " const t = (cle) => cle;\n let x = $state(1);",
|
||||
gabarit: [
|
||||
'<span>{x} %</span>',
|
||||
"<span>{x ? '50 %' : t('qualite.pourcentage.valeur')}</span>",
|
||||
'<span title="{x} %" style="width: 50%" style:height="{x}%">.</span>',
|
||||
'<table><colgroup><col style:width="{x}%" /></colgroup></table>',
|
||||
].join('\n'),
|
||||
style: ' span::after { content: "%"; }\n span { width: 100%; }',
|
||||
}),
|
||||
};
|
||||
assert.deepEqual(avecArbre(fichiers, releverChainesEnDur), [
|
||||
'src/interface/qualite/T.svelte:5 texte « % »',
|
||||
'src/interface/qualite/T.svelte:6 expression littérale « 50 % »',
|
||||
'src/interface/qualite/T.svelte:7 attribut title « % »',
|
||||
'src/interface/qualite/T.svelte:10 content « "%" »',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sources de l’interface : le signe « % » écrit par le code (contrainte 44)', () => {
|
||||
test('aucun script de src/interface, hors des tables de traduction, n’écrit un pourcentage', () => {
|
||||
exigerVide(releverPourcentagesDesScripts(RACINE), 'pourcentages des scripts');
|
||||
});
|
||||
|
||||
test('rouge : la garde relève un « % » précédé d’un blanc dans un module ou le script d’un composant, et pas une largeur, un échappement, une clé, un commentaire, une table ni une épreuve', () => {
|
||||
const fichiers = {
|
||||
'src/interface/qualite/a.js': [
|
||||
'export const a = (n) => `${n} %`;',
|
||||
"export const b = (n) => n + '\\u{202F}%';",
|
||||
'export const c = (n) => `${n}%`;',
|
||||
"export const d = '100%';",
|
||||
"export const e = (s) => s.replaceAll('%', '%25');",
|
||||
'// 50 % dans un commentaire',
|
||||
"export const f = (t) => t('qualite.pourcentage.valeur');",
|
||||
'',
|
||||
].join('\n'),
|
||||
'src/interface/qualite/B.svelte': composant({
|
||||
script: " const libelle = (n) => n + ' %';\n const largeur = (n) => `${n}%`;",
|
||||
gabarit: '<p style:width={largeur(1)}>{libelle(1)}</p>',
|
||||
}),
|
||||
'src/interface/libelles/q.js': "export const T = new Map([['qualite.pourcentage.valeur', ({ n }) => `${n}\\u{202F}%`]]);\n",
|
||||
'src/interface/qualite/a.test.js': "export const x = '5 %';\n",
|
||||
};
|
||||
assert.deepEqual(avecArbre(fichiers, releverPourcentagesDesScripts), [
|
||||
'src/interface/qualite/B.svelte:2 pourcentage « % »',
|
||||
'src/interface/qualite/a.js:1 pourcentage « % »',
|
||||
'src/interface/qualite/a.js:2 pourcentage « % »',
|
||||
]);
|
||||
});
|
||||
|
||||
test('un arbre sans source d’interface hors des tables fait échouer la garde', () => {
|
||||
assert.throws(
|
||||
() => avecArbre({ 'src/interface/libelles/q.js': 'export const a = 1;\n' }, releverPourcentagesDesScripts),
|
||||
/pourcentages des scripts : aucun fichier examiné/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sources de l’interface : les clés littérales de t existent (§ 14.6, § 19.11)', () => {
|
||||
|
|
@ -1360,6 +1490,7 @@ describe('sources de l’interface : le contenu des tables de traduction (§ 5.5
|
|||
['generation.reglage.modeObjectifs.chacun', "L'ÉQUITÉ D'ABORD"],
|
||||
['propositions.phrase', "l'équité"],
|
||||
['diagnostic.remede.un', '2 chaises de plus la rend évitable'],
|
||||
...SERVENT_LE_POURCENTAGE,
|
||||
]);
|
||||
assert.deepEqual(releverContenuTables([tables]), [
|
||||
"generation.reglage.modeObjectifs.chacun : équité « L'ÉQUITÉ D'ABORD »",
|
||||
|
|
@ -1370,15 +1501,47 @@ describe('sources de l’interface : le contenu des tables de traduction (§ 5.5
|
|||
const autre = new Map([
|
||||
['generation.reglage.modeObjectifs.minimum', "l'équité d'abord, une équité"],
|
||||
['diagnostic.remede.un', 'la rend évitable'],
|
||||
...SERVENT_LE_POURCENTAGE,
|
||||
]);
|
||||
assert.deepEqual(releverContenuTables([autre]), [
|
||||
"generation.reglage.modeObjectifs.minimum : équité « l'équité d'abord, une équité »",
|
||||
]);
|
||||
const sansMode = new Map([['diagnostic.remede.un', 'la rend évitable']]);
|
||||
const sansMode = new Map([['diagnostic.remede.un', 'la rend évitable'], ...SERVENT_LE_POURCENTAGE]);
|
||||
assert.deepEqual(releverContenuTables([sansMode]), [
|
||||
"exception sans objet : generation.reglage.modeObjectifs.minimum « l'équité d'abord » (D22)",
|
||||
]);
|
||||
});
|
||||
|
||||
test('rouge : le signe « % » ne s’écrit que sous qualite.pourcentage.*, et « à 100 % » sous la seule consigne de la planche ; tout autre « % » se relève, et une exception que rien ne sert est sans objet', () => {
|
||||
const tables = new Map([
|
||||
['qualite.tuile.x', '88 %'],
|
||||
['qualite.pourcentage.valeur', '88 %'],
|
||||
['qualite.pourcentage.servis', ({ pourcent }) => `${pourcent}\u{202F}% des tours possibles servis`],
|
||||
['qualite.pourcentageX', '88 %'],
|
||||
['planche.controle.consigne', 'Imprimer en recto seul, à 100 %'],
|
||||
['planche.controle.autre', 'à 100 %'],
|
||||
['propositions.phrase', ({ n }) => `${n} %`],
|
||||
]);
|
||||
assert.deepEqual(releverContenuTables([tables], () => null, exceptionsDu('pourcentage')), [
|
||||
'planche.controle.autre : pourcentage « à 100 % »',
|
||||
'propositions.phrase : pourcentage « % »',
|
||||
'qualite.pourcentageX : pourcentage « 88 % »',
|
||||
'qualite.tuile.x : pourcentage « 88 % »',
|
||||
]);
|
||||
// Sous la consigne, un autre « % » se relève encore.
|
||||
const autre = new Map([
|
||||
['planche.controle.consigne', 'à 100 %, puis 50 %'],
|
||||
['qualite.pourcentage.valeur', '1 %'],
|
||||
]);
|
||||
assert.deepEqual(releverContenuTables([autre], () => null, exceptionsDu('pourcentage')), [
|
||||
'planche.controle.consigne : pourcentage « à 100 %, puis 50 % »',
|
||||
]);
|
||||
const sansPourcentage = new Map([['qualite.tuile.x', 'minimum 24 sur 27']]);
|
||||
assert.deepEqual(releverContenuTables([sansPourcentage], () => null, exceptionsDu('pourcentage')), [
|
||||
'exception sans objet : qualite.pourcentage.* « % » (D34, D52)',
|
||||
'exception sans objet : planche.controle.consigne « à 100 % »',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Garde : l'état des critères se lit, il ne se recalcule pas ---------------
|
||||
|
|
|
|||
|
|
@ -6,11 +6,15 @@
|
|||
// la coquille charge la page par file://, où un chemin absolu « /assets/… »
|
||||
// désigne la racine du disque.
|
||||
//
|
||||
// Le seuil d'avertissement d'un morceau vaut 1 000 ko, et non les 500 ko de
|
||||
// Vite : la page se lit sur le disque local, hors ligne, une fois au
|
||||
// lancement, et un seul morceau porte l'application entière, travailleur de
|
||||
// génération incrusté compris. Le seuil n'avertit donc que d'une croissance
|
||||
// anormale du paquet ; test/construction.long.test.js tombe au-delà.
|
||||
// Le lancement charge le point d'entrée, travailleur de génération incrusté
|
||||
// compris, et le morceau commun que la construction nomme d'après l'un de
|
||||
// ses modules, vue_plan : les modules que le point d'entrée partage avec
|
||||
// un morceau différé. La section de la qualité forme son propre morceau,
|
||||
// différé, que la page ne charge qu'à la première ouverture de la section
|
||||
// (src/main.js). Le seuil d'avertissement d'un morceau vaut 1 000 ko, et
|
||||
// non les 500 ko de Vite : la page se lit sur le disque local, hors ligne,
|
||||
// une fois au lancement. Le seuil n'avertit donc que d'une croissance
|
||||
// anormale d'un morceau ; test/construction.long.test.js tombe au-delà.
|
||||
//
|
||||
// La production de la planche se charge à part, par un import différé, et
|
||||
// jamais au lancement. pdf-lib et fontkit y pèsent ensemble plus d'un
|
||||
|
|
|
|||
|
|
@ -126,6 +126,7 @@ export default defineConfig({
|
|||
'src/moteur/indicateurs.js': { branches: 100 },
|
||||
'src/moteur/paires.js': { branches: 100 },
|
||||
'src/moteur/plafond.js': { branches: 100 },
|
||||
'src/moteur/qualite.js': { branches: 100 },
|
||||
'src/stockage/depot.js': { branches: 100 },
|
||||
'src/stockage/journal.js': { branches: 100 },
|
||||
},
|
||||
|
|
|
|||