[ADD] badges: event section to prepare, produce and reissue the sheet
The Badges section follows Propositions. It offers the retained plan by default, or the proposal chosen in Propositions, which always prints as BROUILLON; the announcement names the source, the imposition and the page count before anything is written. The paper question is asked once per machine, refusals sit beside « Produire » with their remedy, and a new production clears the previous sheet's path. Section buttons narrow so eight still fit one line at 1 366 px. Checked: node 3769, browser 745, node-long 101 from the index alone. --- FR --- [ADD] badges : section pour préparer, produire et rééditer la planche La section Badges suit Propositions. Elle offre le retenu par défaut, ou la proposition choisie dans Propositions, toujours BROUILLON ; l'annonce nomme la source, l'imposition et le nombre de pages avant toute écriture. La question du papier se pose une fois par machine, les refus se posent à côté de « Produire » avec leur remède, et une nouvelle production efface le chemin de la précédente. Les boutons de section s'amincissent : huit tiennent sur une ligne à 1 366 px. Vérifié : node 3769, navigateur 745, node-long 101 depuis l'index seul. Assisted-by: Claude Opus 5.5
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 114 KiB After Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 133 KiB After Width: | Height: | Size: 134 KiB |
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 119 KiB After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 141 KiB After Width: | Height: | Size: 149 KiB |
|
Before Width: | Height: | Size: 145 KiB After Width: | Height: | Size: 153 KiB |
472
src/interface/badges/Badges.navigateur.test.js
Normal file
|
|
@ -0,0 +1,472 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// La section des badges dans un vrai moteur de rendu (§ 2.1 étape 12,
|
||||
// § 11.2, § 11.6, § 11.8, § 8.4, § 8.5, § 14.6, Q1, Q3) : la question du
|
||||
// papier à la première production, et jamais ensuite, sur cette machine ;
|
||||
// l'annonce avant de produire — planches des deux papiers, dérive nommée,
|
||||
// « Produire » désactivé et expliqué tant que le refus tient ; la
|
||||
// production en lecture et sur un plan bloqué sans écriture de l'état ni du
|
||||
// journal ; le chemin produit, « Ouvrir le dossier », la réédition ; le
|
||||
// refus d'écriture et son remède « Enregistrer sous… » ; le choix de la
|
||||
// source, de l'imposition et de l'ordre, et le retrait de l'annonce quand
|
||||
// un réglage ou le papier change ; le téléchargement sous web ; la langue
|
||||
// témoin.
|
||||
//
|
||||
// App est montée par test/navigateur/ecrans.js sur le système de fichiers
|
||||
// simulé ; le texte attendu vient de la même traduction que celle de
|
||||
// l'application, les comptes du témoin de test/fixtures/badges/temoin.js.
|
||||
//
|
||||
// Le projet navigateur emploie expect, et test et describe de Vitest
|
||||
// (§ 14.8).
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
|
||||
import { appliquerCommande } from '../../application/commandes.js';
|
||||
import { TABLES_APPLICATION } from '../../application/libelles.js';
|
||||
import { MARQUE_TEMOIN, creerTraduction } from '../../application/traduction.js';
|
||||
import { PAPIERS } from '../../geometrie/papiers.js';
|
||||
import { SUFFIXES } from '../../stockage/noms.js';
|
||||
import { FICHIER_REGLAGES } from '../../stockage/reglages_locaux.js';
|
||||
import { fixerCadre } from '../../../test/navigateur/entrees.js';
|
||||
import { BASE, attendre, chargeBloquee, chargePrincipale, cliquer, creerContexte, fichierReglages, ouvrirEvenement } from '../../../test/navigateur/ecrans.js';
|
||||
import { FORME_TEMOIN, chargeTemoin } from '../../../test/fixtures/badges/temoin.js';
|
||||
import { clesPapier } from '../impression/modele.js';
|
||||
import { TABLES_INTERFACE } from '../libelles.js';
|
||||
import { personnesReeditables } from './modele.js';
|
||||
|
||||
const TABLES = [...TABLES_APPLICATION, ...TABLES_INTERFACE];
|
||||
const { t } = creerTraduction(TABLES);
|
||||
|
||||
// Images attendues au plus pour une préparation ou une production : la
|
||||
// police et pdf-lib se chargent à la première, et le témoin met en page
|
||||
// 257 badges.
|
||||
const IMAGES = 1800;
|
||||
|
||||
let contexte = null;
|
||||
beforeEach(async () => {
|
||||
await fixerCadre(1366, 700);
|
||||
contexte = creerContexte();
|
||||
});
|
||||
afterEach(async () => {
|
||||
const erreurs = contexte.erreurs();
|
||||
await contexte.defaire();
|
||||
expect(erreurs, 'erreurs reçues par le collecteur').toEqual([]);
|
||||
});
|
||||
|
||||
const dans = (m, selecteur) => m.racine.querySelector(selecteur);
|
||||
const plat = (chaine) => chaine.replace(/\s+/g, ' ').trim();
|
||||
const texte = (element) => plat(element.textContent);
|
||||
const section = (m) => dans(m, '[data-section="badges"]');
|
||||
const annonce = (m) => dans(m, '[data-section="badges"] [data-annonce-planche]');
|
||||
const question = (m) => dans(m, '[data-section="badges"] [data-papier="question"]');
|
||||
const produire = (m) => dans(m, '[data-section="badges"] [data-commande="produirePlanche"]');
|
||||
|
||||
// Les écritures relevées depuis le rang donné : celles qui touchent l'état,
|
||||
// le journal ou la version précédente d'un événement, et toutes les autres.
|
||||
const ecrituresEvenement = (m, depuis) =>
|
||||
m.trace.slice(depuis).filter(([, , chemin]) => [SUFFIXES.etat, SUFFIXES.journal, SUFFIXES.precedent].some((suffixe) => chemin.endsWith(suffixe)));
|
||||
const planchesEcrites = (m, depuis) => m.trace.slice(depuis).filter(([primitive]) => primitive === 'ecrireOctetsAtomique');
|
||||
|
||||
// La principale, sa proposition retenue, sur un plan retenu.
|
||||
function chargeRetenue() {
|
||||
const principale = chargePrincipale();
|
||||
return appliquerCommande('retenirProposition', principale, { id: principale.propositions[0].id }).charge;
|
||||
}
|
||||
|
||||
// Ouvre l'événement de la charge sur la section des badges, le papier de
|
||||
// la machine posé dans les réglages locaux ; null n'en pose aucun.
|
||||
function ouvrirBadges(charge, { papier = 'A4', ecriture = false, options = {} } = {}) {
|
||||
const fichiers = [...(papier === null ? [] : [fichierReglages(papier)]), ...(options.fichiers ?? [])];
|
||||
return ouvrirEvenement(contexte, charge, { ecriture, section: 'badges', options: { ...options, fichiers } });
|
||||
}
|
||||
|
||||
// Choisit la valeur d'un sélecteur de la section, comme l'opérateur.
|
||||
function choisirReglage(m, champ, valeur) {
|
||||
const choix = dans(m, `[data-section="badges"] [data-champ="${champ}"]`);
|
||||
choix.value = valeur;
|
||||
choix.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
|
||||
// Relève chaque demande que la section passe à seance.produirePlanche, et
|
||||
// la transmet telle quelle.
|
||||
function releverDemandes(m) {
|
||||
const demandes = [];
|
||||
const originale = m.seance.produirePlanche;
|
||||
m.seance.produirePlanche = (demande) => {
|
||||
demandes.push(demande);
|
||||
return originale(demande);
|
||||
};
|
||||
return demandes;
|
||||
}
|
||||
|
||||
// Clique « Produire » et attend l'issue que condition rend.
|
||||
async function cliquerProduire(m, quoi, condition) {
|
||||
produire(m).click();
|
||||
return attendre(quoi, condition, IMAGES);
|
||||
}
|
||||
|
||||
// Le texte attendu des planches sur chaque papier, le papier choisi marqué.
|
||||
const textePlanches = (planches, choisi) =>
|
||||
t('badges.annonce.planches', {
|
||||
parPapier: Object.keys(PAPIERS).map((papier) => ({ papier, nom: clesPapier(papier).nom, planches: planches[papier], choisi: papier === choisi })),
|
||||
});
|
||||
|
||||
// Le chemin attendu d'un fichier du dossier de travail des épreuves.
|
||||
async function cheminDocuments(m, nom) {
|
||||
const { separateur } = await m.fs.emplacements();
|
||||
const racine = m.seance.etat().racine;
|
||||
return `${racine.chemin}${separateur}${nom}`;
|
||||
}
|
||||
|
||||
describe('la question du papier : à la première production, jamais ensuite (§ 8.5, § 11.2, Q1)', () => {
|
||||
test('le témoin : « Produire » sans papier annonce 65 planches A4 et 129 Lettre et pose la question ; le choix fait, la planche se produit', async () => {
|
||||
const m = await ouvrirBadges(chargeTemoin(), { papier: null });
|
||||
// Avant toute production, la section ne pose pas la question.
|
||||
expect(dans(m, '[data-section="badges"] [data-papier]')).toBeNull();
|
||||
await cliquerProduire(m, 'la question du papier', () => question(m));
|
||||
const lue = texte(annonce(m));
|
||||
const { planchesA4: A4, planchesLettre } = FORME_TEMOIN;
|
||||
const planches = Object.fromEntries(Object.keys(PAPIERS).map((papier, rang) => [papier, [A4, planchesLettre][rang]]));
|
||||
expect(lue).toContain(plat(textePlanches(planches, null)));
|
||||
expect(lue).toContain(plat(t('badges.annonce.comptes', { produits: FORME_TEMOIN.produits, participants: FORME_TEMOIN.participants, exclus: FORME_TEMOIN.exclus })));
|
||||
expect(dans(m, '[data-annonce-planche] [data-refus="PAPIER_REQUIS"]')).not.toBeNull();
|
||||
expect(planchesEcrites(m, 0)).toEqual([]);
|
||||
const depuis = m.trace.length;
|
||||
dans(m, '[data-papier="question"] [data-cible="A4"] [data-commande="choisirPapier"]').click();
|
||||
const chemin = await attendre('la planche produite', () => dans(m, '[data-chemin-planche]'), IMAGES);
|
||||
expect(m.seance.etat().papier).toBe('A4');
|
||||
const ecrites = planchesEcrites(m, depuis);
|
||||
expect(ecrites).toHaveLength(1);
|
||||
expect(ecrites[0][2]).toMatch(/_A4_feuille_/);
|
||||
expect(texte(chemin)).toBe(await cheminDocuments(m, ecrites[0][2]));
|
||||
expect(question(m)).toBeNull();
|
||||
expect(texte(annonce(m))).toContain(plat(textePlanches(planches, 'A4')));
|
||||
});
|
||||
|
||||
test('une seconde production, puis un nouvel événement, puis App remontée sur le même système : jamais redemandée ; « Changer » la modifie', async () => {
|
||||
const m = await ouvrirBadges(chargeBloquee(), { papier: null });
|
||||
await cliquerProduire(m, 'la question du papier', () => question(m));
|
||||
dans(m, '[data-papier="question"] [data-cible="A4"] [data-commande="choisirPapier"]').click();
|
||||
await attendre('la première planche', () => dans(m, '[data-chemin-planche]'), IMAGES);
|
||||
// Les réglages locaux portent le papier : la seule écriture hors du PDF.
|
||||
expect(JSON.parse(m.fs.contenu('documents', FICHIER_REGLAGES)).papier).toBe('A4');
|
||||
|
||||
// Une seconde production : aucune question, la planche se réécrit.
|
||||
const depuis = m.trace.length;
|
||||
await cliquerProduire(m, 'la seconde planche', () => planchesEcrites(m, depuis).length === 1);
|
||||
expect(question(m)).toBeNull();
|
||||
expect(texte(dans(m, '[data-papier="choisi"]'))).toContain(t('impression.papier.nom.a4'));
|
||||
|
||||
// Un nouvel événement, sans placement : la section montre le papier, et
|
||||
// « Produire » dit le refus du placement, jamais la question.
|
||||
await m.seance.fermer();
|
||||
await m.seance.creer({ nom: 'Bal des Pervenches', date: null, siegesParDefaut: 8, tours: 2 });
|
||||
m.ui.allerA('badges');
|
||||
await attendre('la section du nouvel événement', () => section(m) && dans(m, '[data-papier="choisi"]'), IMAGES);
|
||||
await cliquerProduire(m, 'le refus du placement', () => dans(m, '[data-annonce-planche] [data-refus="PLANCHE_SANS_PLACEMENT"]'));
|
||||
expect(question(m)).toBeNull();
|
||||
|
||||
// App remontée sur le même disque : le papier se relit, la question ne
|
||||
// vient pas, la planche se produit.
|
||||
const second = await contexte.monter({ systeme: { disque: m.disque } });
|
||||
await cliquer(second, `[data-evenement="${BASE}"] [data-commande="ouvrir"]`);
|
||||
await cliquer(second, '[data-commande="section"][data-cible="badges"]');
|
||||
await attendre('le papier relu', () => dans(second, '[data-section="badges"] [data-papier="choisi"]'), IMAGES);
|
||||
const avant = second.trace.length;
|
||||
await cliquerProduire(second, 'la planche du second montage', () => dans(second, '[data-chemin-planche]'));
|
||||
expect(question(second)).toBeNull();
|
||||
expect(planchesEcrites(second, avant)).toHaveLength(1);
|
||||
|
||||
// « Changer » rouvre la question ; le nouveau papier s'écrit, et la
|
||||
// planche suivante le porte.
|
||||
dans(second, '[data-papier="choisi"] [data-commande="changerPapier"]').click();
|
||||
await attendre('la question rouverte', () => question(second), IMAGES);
|
||||
const lettre = Object.keys(PAPIERS)[1];
|
||||
dans(second, `[data-papier="question"] [data-cible="${lettre}"] [data-commande="choisirPapier"]`).click();
|
||||
await attendre('le papier changé', () => second.seance.etat().papier === lettre, IMAGES);
|
||||
expect(JSON.parse(second.fs.contenu('documents', FICHIER_REGLAGES)).papier).toBe(lettre);
|
||||
const ensuite = second.trace.length;
|
||||
await cliquerProduire(second, 'la planche sur le nouveau papier', () => planchesEcrites(second, ensuite).length === 1);
|
||||
expect(planchesEcrites(second, ensuite)[0][2]).toContain(`_${lettre}_`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sortir n’est pas modifier (§ 8.4, § 11.6, contrainte 24)', () => {
|
||||
test('en lecture, sur une proposition : préparer et produire passent, BROUILLON annoncé ; seuls le PDF et les réglages s’écrivent', async () => {
|
||||
// La principale n'a pas de retenu : l'ouverture demande au plan sa
|
||||
// première proposition, la source de la planche.
|
||||
const charge = chargePrincipale();
|
||||
const m = await ouvrirBadges(charge, { papier: null });
|
||||
expect(m.seance.etat().mode).toBe('lecture');
|
||||
const depuis = m.trace.length;
|
||||
dans(m, '[data-section="badges"] [data-commande="preparerPlanche"]').click();
|
||||
await attendre('l’annonce', () => annonce(m), IMAGES);
|
||||
const lue = texte(annonce(m));
|
||||
expect(lue).toContain(plat(t('badges.annonce.source.proposition', { id: charge.propositions[0].id })));
|
||||
expect(lue).toContain(plat(t('badges.annonce.brouillon.proposition')));
|
||||
dans(m, '[data-annonce-planche] [data-refus="PAPIER_REQUIS"] [data-commande="choisirPapier"]').click();
|
||||
await attendre('la question du papier', () => question(m), IMAGES);
|
||||
dans(m, '[data-papier="question"] [data-cible="A4"] [data-commande="choisirPapier"]').click();
|
||||
await attendre('l’annonce sans refus', () => annonce(m) && dans(m, '[data-annonce-planche] [data-refus]') === null, IMAGES);
|
||||
await cliquerProduire(m, 'la planche', () => dans(m, '[data-chemin-planche]'));
|
||||
expect(ecrituresEvenement(m, depuis)).toEqual([]);
|
||||
expect(m.trace.slice(depuis).map(([primitive, , chemin]) => [primitive, chemin.endsWith('.pdf') ? 'pdf' : chemin])).toEqual([
|
||||
['ecrireAtomique', FICHIER_REGLAGES],
|
||||
['ecrireOctetsAtomique', 'pdf'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('sur un plan bloqué : la planche se produit, sans BROUILLON ; seule l’écriture du PDF', async () => {
|
||||
const m = await ouvrirBadges(chargeBloquee());
|
||||
expect(m.seance.etat().charge.evenement.etat).toBe('bloque');
|
||||
const depuis = m.trace.length;
|
||||
await cliquerProduire(m, 'la planche', () => dans(m, '[data-chemin-planche]'));
|
||||
expect(texte(annonce(m))).not.toContain('BROUILLON');
|
||||
expect(texte(annonce(m))).toContain(plat(t('badges.annonce.source.retenu')));
|
||||
expect(m.trace.slice(depuis).map(([primitive]) => primitive)).toEqual(['ecrireOctetsAtomique']);
|
||||
expect(ecrituresEvenement(m, depuis)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('la dérive, nommée avant toute production (§ 11.8)', () => {
|
||||
test('« Produire » sur un retenu en dérive : le refus nomme la personne ajoutée, rien ne s’écrit ; « Produire » reste désactivé et expliqué tant que le refus tient', async () => {
|
||||
const charge = appliquerCommande('ajouterParticipant', chargeRetenue(), { champs: { nom: 'Ombrelle', prenom: 'Iris' } }).charge;
|
||||
const ajoutee = charge.participants.at(-1).id;
|
||||
const m = await ouvrirBadges(charge);
|
||||
const depuis = m.trace.length;
|
||||
await cliquerProduire(m, 'le refus de dérive', () => dans(m, '[data-annonce-planche] [data-refus="PLANCHE_DERIVE"]'));
|
||||
const refus = dans(m, '[data-annonce-planche] [data-refus="PLANCHE_DERIVE"]');
|
||||
expect(texte(refus)).toContain('Iris Ombrelle');
|
||||
expect(refus.querySelector('[data-commande="ouvrirSection"]')).not.toBeNull();
|
||||
expect(planchesEcrites(m, depuis)).toEqual([]);
|
||||
|
||||
const bouton = produire(m);
|
||||
expect(bouton.getAttribute('aria-disabled')).toBe('true');
|
||||
const explication = m.racine.querySelector(`#${CSS.escape(bouton.getAttribute('aria-describedby'))}`);
|
||||
expect(texte(explication)).toBe(plat(t('badges.produire.bloque')));
|
||||
bouton.click();
|
||||
await attendre('une image', () => true);
|
||||
expect(planchesEcrites(m, depuis)).toEqual([]);
|
||||
expect(dans(m, '[data-chemin-planche]')).toBeNull();
|
||||
|
||||
// La personne retirée, la dérive tombe : l'annonce d'avant se retire,
|
||||
// et « Produire » produit.
|
||||
await m.seance.passerEnEcriture();
|
||||
await m.seance.executer('supprimerParticipant', { id: ajoutee });
|
||||
await attendre('« Produire » rendu', () => produire(m)?.getAttribute('aria-disabled') === null && annonce(m) === null, IMAGES);
|
||||
await cliquerProduire(m, 'la planche', () => dans(m, '[data-chemin-planche]'));
|
||||
expect(planchesEcrites(m, depuis)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('la planche produite, le dossier, la réédition (§ 11.6, § 11.8)', () => {
|
||||
test('le chemin s’affiche ; « Ouvrir le dossier » appelle la façade sur le dossier de travail ; trois badges se rééditent', async () => {
|
||||
const charge = chargeBloquee();
|
||||
const m = await ouvrirBadges(charge);
|
||||
const depuis = m.trace.length;
|
||||
await cliquerProduire(m, 'la planche', () => dans(m, '[data-chemin-planche]'));
|
||||
const [[, , nom]] = planchesEcrites(m, depuis);
|
||||
expect(texte(dans(m, '[data-chemin-planche]'))).toBe(await cheminDocuments(m, nom));
|
||||
expect(new TextDecoder().decode(m.fs.octets('documents', nom).slice(0, 5))).toBe('%PDF-');
|
||||
|
||||
const ouvertes = [];
|
||||
const originale = m.fs.ouvrirDansExplorateur;
|
||||
m.fs.ouvrirDansExplorateur = async (racine) => {
|
||||
ouvertes.push(racine);
|
||||
return originale(racine);
|
||||
};
|
||||
dans(m, '[data-section="badges"] [data-commande="ouvrirDossier"]').click();
|
||||
await attendre('l’explorateur appelé', () => ouvertes.length === 1, IMAGES);
|
||||
expect(ouvertes[0].id).toBe(m.seance.etat().racine.id);
|
||||
|
||||
// Trois personnes cochées, dans le désordre de la liste.
|
||||
const personnes = personnesReeditables(charge);
|
||||
const choisies = [personnes[4].id, personnes[0].id, personnes[2].id];
|
||||
for (const id of choisies) {
|
||||
dans(m, `[data-section="badges"] li[data-participant="${id}"] input[type="checkbox"]`).click();
|
||||
}
|
||||
const reediter = await attendre('« Rééditer 3 badges »', () => dans(m, '[data-section="badges"] [data-commande="reediter"]'), IMAGES);
|
||||
expect(texte(reediter)).toBe(plat(t('badges.reedition.produire', { n: 3 })));
|
||||
const avant = m.trace.length;
|
||||
reediter.click();
|
||||
await attendre('la réédition', () => planchesEcrites(m, avant).length === 1, IMAGES);
|
||||
const [[, , nomReedition]] = planchesEcrites(m, avant);
|
||||
expect(nomReedition).toMatch(/\.badges-3_A4_feuille_/);
|
||||
await attendre('son chemin', () => texte(dans(m, '[data-chemin-planche]')).endsWith(nomReedition), IMAGES);
|
||||
expect(texte(annonce(m))).toContain(plat(t('badges.annonce.reedition', { n: 3 })));
|
||||
expect(ecrituresEvenement(m, depuis)).toEqual([]);
|
||||
});
|
||||
|
||||
test('le filtre restreint la liste ; une personne cochée le reste sous le filtre', async () => {
|
||||
const charge = chargeBloquee();
|
||||
const m = await ouvrirBadges(charge);
|
||||
const [premiere, seconde] = personnesReeditables(charge);
|
||||
dans(m, `li[data-participant="${premiere.id}"] input[type="checkbox"]`).click();
|
||||
const champ = dans(m, '[data-section="badges"] [data-champ="reedition"]');
|
||||
champ.value = seconde.nom;
|
||||
champ.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await attendre('la liste filtrée', () => dans(m, `li[data-participant="${premiere.id}"]`) === null, IMAGES);
|
||||
expect(dans(m, `li[data-participant="${seconde.id}"]`)).not.toBeNull();
|
||||
expect(texte(dans(m, '[data-commande="reediter"]'))).toBe(plat(t('badges.reedition.produire', { n: 1 })));
|
||||
champ.value = 'zzzz';
|
||||
champ.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await attendre('aucune personne', () => texte(section(m)).includes(plat(t('badges.reedition.aucune'))), IMAGES);
|
||||
});
|
||||
});
|
||||
|
||||
describe('l’imposition et l’ordre (§ 11.6)', () => {
|
||||
test('« En pile » puis « Par table du premier tour » passent à la demande : la planche porte _pile_, l’annonce nomme son fichier', async () => {
|
||||
const m = await ouvrirBadges(chargeBloquee());
|
||||
const demandes = releverDemandes(m);
|
||||
choisirReglage(m, 'imposition', 'pile');
|
||||
const depuis = m.trace.length;
|
||||
await cliquerProduire(m, 'la planche en pile', () => planchesEcrites(m, depuis).length === 1);
|
||||
const [[, , nom]] = planchesEcrites(m, depuis);
|
||||
expect(nom).toMatch(/_A4_pile_/);
|
||||
expect(texte(annonce(m))).toContain(plat(t('badges.annonce.fichier', { nom })));
|
||||
|
||||
choisirReglage(m, 'ordre', 'table');
|
||||
const ensuite = m.trace.length;
|
||||
await cliquerProduire(m, 'la planche par table', () => planchesEcrites(m, ensuite).length === 1);
|
||||
expect(demandes.map(({ imposition, ordre }) => ({ imposition, ordre }))).toEqual([
|
||||
{ imposition: 'pile', ordre: 'alphabetique' },
|
||||
{ imposition: 'pile', ordre: 'table' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('l’annonce montrée se retire quand l’imposition change, puis quand le papier change', async () => {
|
||||
const m = await ouvrirBadges(chargeBloquee());
|
||||
const preparer = () => dans(m, '[data-section="badges"] [data-commande="preparerPlanche"]').click();
|
||||
preparer();
|
||||
await attendre('l’annonce feuille par feuille', () => annonce(m), IMAGES);
|
||||
expect(texte(annonce(m))).toMatch(/_A4_feuille_/);
|
||||
choisirReglage(m, 'imposition', 'pile');
|
||||
await attendre('une image', () => true);
|
||||
expect(annonce(m)).toBeNull();
|
||||
|
||||
preparer();
|
||||
await attendre('l’annonce en pile', () => annonce(m), IMAGES);
|
||||
expect(texte(annonce(m))).toMatch(/_A4_pile_/);
|
||||
// Le papier changé hors de la section : l'annonce sur A4 se retire, et
|
||||
// la suivante porte le nouveau papier.
|
||||
const lettre = Object.keys(PAPIERS)[1];
|
||||
await m.seance.choisirPapier(lettre);
|
||||
await attendre('une image', () => true);
|
||||
expect(annonce(m)).toBeNull();
|
||||
preparer();
|
||||
await attendre('l’annonce sur le nouveau papier', () => annonce(m), IMAGES);
|
||||
expect(texte(annonce(m))).toContain(`_${lettre}_pile_`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sous web, la planche se télécharge (§ 11.6)', () => {
|
||||
test('la section dit le nom téléchargé ; ni « Ouvrir le dossier » ni écriture du PDF', async () => {
|
||||
const m = await ouvrirBadges(chargeBloquee(), { options: { systeme: { nature: 'web' } } });
|
||||
const telechargees = [];
|
||||
m.fs.enregistrerSous = async (nom, octets) => {
|
||||
telechargees.push({ nom, debut: new TextDecoder().decode(octets.slice(0, 5)) });
|
||||
return null;
|
||||
};
|
||||
const depuis = m.trace.length;
|
||||
await cliquerProduire(m, 'la planche téléchargée', () => dans(m, '[data-chemin-planche]'));
|
||||
expect(telechargees).toHaveLength(1);
|
||||
const [{ nom, debut }] = telechargees;
|
||||
expect(nom).toMatch(/_A4_feuille_.*\.pdf$/);
|
||||
expect(debut).toBe('%PDF-');
|
||||
expect(texte(dans(m, '[data-chemin-planche]'))).toBe(plat(t('badges.produite.telechargee', { nom })));
|
||||
expect(dans(m, '[data-section="badges"] [data-commande="ouvrirDossier"]')).toBeNull();
|
||||
expect(planchesEcrites(m, depuis)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('une écriture refusée, et son remède (§ 8.5, § 11.6)', () => {
|
||||
test('PLANCHE_NON_ECRITE s’affiche à côté de « Produire », remède « Enregistrer sous… », dont le chemin choisi s’affiche', async () => {
|
||||
const m = await ouvrirBadges(chargeBloquee());
|
||||
m.fs.pannes.ecriture('documents');
|
||||
const proposes = [];
|
||||
m.fs.enregistrerSous = async (nom, octets) => {
|
||||
proposes.push({ nom, debut: new TextDecoder().decode(octets.slice(0, 5)) });
|
||||
return 'D:\\ailleurs\\planche.pdf';
|
||||
};
|
||||
await cliquerProduire(m, 'le refus d’écriture', () => dans(m, '[data-section="badges"] [data-refus="PLANCHE_NON_ECRITE"]'));
|
||||
const remede = dans(m, '[data-refus="PLANCHE_NON_ECRITE"] [data-commande="enregistrerSous"]');
|
||||
expect(texte(remede)).toBe(plat(t('communs.remede.enregistrerSous')));
|
||||
remede.click();
|
||||
const chemin = await attendre('le chemin choisi', () => dans(m, '[data-chemin-planche]'), IMAGES);
|
||||
expect(texte(chemin)).toBe('D:\\ailleurs\\planche.pdf');
|
||||
expect(proposes).toHaveLength(1);
|
||||
expect(proposes[0].nom).toMatch(/_A4_feuille_.*\.pdf$/);
|
||||
expect(proposes[0].debut).toBe('%PDF-');
|
||||
// Le refus levé ; « Ouvrir le dossier » ne montre que le dossier de
|
||||
// travail, pas celui choisi ailleurs.
|
||||
expect(dans(m, '[data-refus="PLANCHE_NON_ECRITE"]')).toBeNull();
|
||||
expect(dans(m, '[data-section="badges"] [data-commande="ouvrirDossier"]')).toBeNull();
|
||||
});
|
||||
|
||||
test('une production refusée après une réussie ne montre plus le chemin de la précédente', async () => {
|
||||
const m = await ouvrirBadges(chargeBloquee());
|
||||
await cliquerProduire(m, 'la première planche', () => dans(m, '[data-chemin-planche]'));
|
||||
m.fs.pannes.ecriture('documents');
|
||||
await cliquerProduire(m, 'le refus d’écriture', () => dans(m, '[data-section="badges"] [data-refus="PLANCHE_NON_ECRITE"]'));
|
||||
expect(dans(m, '[data-chemin-planche]')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('la source de la planche (Q3)', () => {
|
||||
test('le retenu et la proposition demandée au plan : le retenu par défaut ; la proposition choisie s’annonce, BROUILLON', async () => {
|
||||
const charge = chargeRetenue();
|
||||
const m = await ouvrirBadges(charge);
|
||||
expect(dans(m, '[data-section="badges"] [data-champ="source"]')).toBeNull();
|
||||
m.ui.montrerProposition(charge.propositions[0].id);
|
||||
const choix = await attendre('le choix de la source', () => dans(m, '[data-section="badges"] [data-champ="source"]'), IMAGES);
|
||||
expect([...choix.options].map((option) => option.value)).toEqual(['retenu', `proposition:${charge.propositions[0].id}`]);
|
||||
expect(choix.value).toBe('retenu');
|
||||
choix.value = `proposition:${charge.propositions[0].id}`;
|
||||
choix.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
dans(m, '[data-section="badges"] [data-commande="preparerPlanche"]').click();
|
||||
await attendre('l’annonce', () => annonce(m), IMAGES);
|
||||
expect(texte(annonce(m))).toContain(plat(t('badges.annonce.source.proposition', { id: charge.propositions[0].id })));
|
||||
expect(texte(annonce(m))).toContain(plat(t('badges.annonce.brouillon.proposition')));
|
||||
});
|
||||
|
||||
test('sans retenu ni proposition demandée : le refus du placement, remède vers les propositions', async () => {
|
||||
const charge = chargePrincipale();
|
||||
const m = await ouvrirBadges(charge);
|
||||
m.ui.montrerProposition(null);
|
||||
await cliquerProduire(m, 'le refus', () => dans(m, '[data-annonce-planche] [data-refus="PLANCHE_SANS_PLACEMENT"]'));
|
||||
dans(m, '[data-refus="PLANCHE_SANS_PLACEMENT"] [data-commande="ouvrirSection"]').click();
|
||||
await attendre('les propositions', () => dans(m, '[data-section="propositions"]'), IMAGES);
|
||||
});
|
||||
});
|
||||
|
||||
describe('la langue témoin (§ 14.6)', () => {
|
||||
test('chaque texte de la section, annonce, refus, question et réédition compris, est marqué, hors des données', async () => {
|
||||
const temoin = creerContexte({ langue: 'temoin' });
|
||||
try {
|
||||
const charge = chargeBloquee();
|
||||
const m = await ouvrirEvenement(temoin, charge, { section: 'badges' });
|
||||
produire(m).click();
|
||||
await attendre('la question et l’annonce', () => question(m) && annonce(m), IMAGES);
|
||||
dans(m, '[data-papier="question"] [data-cible="A4"] [data-commande="choisirPapier"]').click();
|
||||
await attendre('la planche', () => dans(m, '[data-chemin-planche]'), IMAGES);
|
||||
const racine = m.seance.etat().racine.chemin;
|
||||
const noms = new Set([...personnesReeditables(charge).flatMap(({ nom, appartenance }) => [nom, appartenance]), charge.evenement.nom]);
|
||||
const { ouverture, fermeture } = MARQUE_TEMOIN;
|
||||
const fautes = [];
|
||||
const marchant = document.createTreeWalker(section(m), NodeFilter.SHOW_TEXT);
|
||||
let lus = 0;
|
||||
for (let noeud = marchant.nextNode(); noeud !== null; noeud = marchant.nextNode()) {
|
||||
const lu = plat(noeud.textContent);
|
||||
if (lu === '') continue;
|
||||
lus += 1;
|
||||
const marque = lu.startsWith(ouverture) && lu.endsWith(fermeture);
|
||||
if (!marque && !noms.has(lu) && !lu.startsWith(racine)) fautes.push(lu);
|
||||
}
|
||||
expect(lus).toBeGreaterThan(10);
|
||||
expect(fautes).toEqual([]);
|
||||
} finally {
|
||||
const erreurs = temoin.erreurs();
|
||||
await temoin.defaire();
|
||||
expect(erreurs).toEqual([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
129
src/interface/badges/Reedition.svelte
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||
|
||||
<script>
|
||||
// La réédition de badges (§ 11.8) : une commande nommée, qui produit une
|
||||
// planche des seules personnes cochées, annoncée comme telle sur sa
|
||||
// feuille de contrôle. La liste porte les personnes non exclues, par ordre
|
||||
// alphabétique de leur nom affiché (personnesReeditables) ; la saisie du
|
||||
// filtre la restreint aux noms et appartenances qui la contiennent, et une
|
||||
// personne cochée le reste quand le filtre la cache. « Rééditer n badges »
|
||||
// paraît dès qu'une personne cochée reste rééditable, et remet la
|
||||
// sélection — dans l'ordre de la liste — à executer, la production de la
|
||||
// section, qui annonce puis produit. Un refus s'affiche à côté de la
|
||||
// commande, avec son remède, que gestes exécute quand l'écran le fournit.
|
||||
import { getContext } from 'svelte';
|
||||
import Commande from '../primitives/Commande.svelte';
|
||||
import { filtrerPersonnes, personnesReeditables, selectionRetenue } from './modele.js';
|
||||
|
||||
// charge : celle de l'événement ouvert ; executer(selection) : la
|
||||
// production de la section ; gestes : les remèdes que l'écran exécute.
|
||||
let { charge, executer, gestes = {} } = $props();
|
||||
|
||||
const t = getContext('t');
|
||||
const uid = $props.id();
|
||||
|
||||
let saisie = $state('');
|
||||
let cochees = $state([]);
|
||||
|
||||
const personnes = $derived(personnesReeditables(charge));
|
||||
const montrees = $derived(filtrerPersonnes(personnes, saisie));
|
||||
const selection = $derived(selectionRetenue(cochees, personnes));
|
||||
|
||||
function cocher(id, coche) {
|
||||
cochees = coche ? [...cochees, id] : cochees.filter((autre) => autre !== id);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="reedition" role="group" aria-labelledby={`${uid}-titre`}>
|
||||
<h3 id={`${uid}-titre`}>{t('badges.reedition.titre')}</h3>
|
||||
<p>{t('badges.reedition.explication')}</p>
|
||||
<label class="filtre">
|
||||
<span>{t('badges.reedition.filtre')}</span>
|
||||
<input type="search" data-champ="reedition" value={saisie} oninput={(evenement) => (saisie = evenement.currentTarget.value)} />
|
||||
</label>
|
||||
{#if montrees.length === 0}
|
||||
<p>{t('badges.reedition.aucune')}</p>
|
||||
{:else}
|
||||
<ul aria-label={t('badges.reedition.personnes')}>
|
||||
{#each montrees as personne (personne.id)}
|
||||
<li data-participant={personne.id}>
|
||||
<label>
|
||||
<input type="checkbox" checked={cochees.includes(personne.id)} onchange={(evenement) => cocher(personne.id, evenement.currentTarget.checked)} />
|
||||
<span>{personne.nom}</span>
|
||||
{#if personne.appartenance !== null}
|
||||
<span class="appartenance">{personne.appartenance}</span>
|
||||
{/if}
|
||||
</label>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{#if selection !== null}
|
||||
<Commande cle="badges.reedition.produire" details={{ n: selection.length }} nom="reediter" executer={() => executer(selection)} {gestes} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.reedition {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--espace-2);
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: var(--taille-texte);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.filtre {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: var(--espace-2);
|
||||
}
|
||||
|
||||
input[type='search'] {
|
||||
padding: var(--espace-1) var(--espace-2);
|
||||
border: 1px solid var(--couleur-page-filet);
|
||||
border-radius: var(--rayon-1);
|
||||
background-color: var(--couleur-page-fond);
|
||||
color: var(--couleur-page-texte);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
input:focus-visible {
|
||||
outline: 2px solid var(--couleur-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* La liste défile dans sa propre hauteur : une soirée de plusieurs
|
||||
centaines de personnes ne repousse pas la commande hors de vue. */
|
||||
ul {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-1);
|
||||
max-height: 16rem;
|
||||
margin: 0;
|
||||
padding: var(--espace-1) var(--espace-2);
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--couleur-page-filet);
|
||||
border-radius: var(--rayon-1);
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
label {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: var(--espace-2);
|
||||
}
|
||||
|
||||
.appartenance {
|
||||
color: var(--couleur-page-texte-secondaire);
|
||||
}
|
||||
</style>
|
||||
345
src/interface/badges/SectionBadges.svelte
Normal file
|
|
@ -0,0 +1,345 @@
|
|||
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||
|
||||
<script>
|
||||
// La section des badges (§ 2.1 étape 12, § 11.2, § 11.6, § 11.8, Q1, Q3) :
|
||||
// la planche de badges, un PDF à imprimer et à couper. Produire n'est pas
|
||||
// modifier (§ 8.4, § 11.6) : la section vit en lecture comme sur un plan
|
||||
// bloqué, et la séance n'écrit que le PDF et, pour le papier,
|
||||
// reglages_locaux.json — ni l'état ni le journal.
|
||||
//
|
||||
// Les réglages d'une demande : la source — le retenu s'il existe, sinon la
|
||||
// proposition demandée au plan depuis les propositions, choisie quand les
|
||||
// deux sont offertes (sourcesOffertes) —, l'imposition, feuille par
|
||||
// feuille par défaut, et l'ordre, alphabétique par défaut. Aucun n'est un
|
||||
// réglage local : ils valent pour la section ouverte.
|
||||
//
|
||||
// « Préparer » demande l'annonce à la séance (preparerPlanche) et la
|
||||
// montre, phrase par phrase (phrasesAnnonce), avec son refus et le remède
|
||||
// de celui-ci. L'annonce montrée est celle des réglages, du papier et de la
|
||||
// charge courants : qu'un des trois change, elle se retire, et la suivante
|
||||
// se prépare. « Produire » prépare d'abord : une dérive, et tout refus du
|
||||
// placement ou de la mise en page, se nomme avant toute production
|
||||
// (§ 11.8), et « Produire » reste désactivé, expliqué, tant que
|
||||
// l'annonce montrée le porte. Sans papier — PAPIER_REQUIS —, la production
|
||||
// pose la question du papier (Papier.svelte), une fois par machine (§ 8.5,
|
||||
// Q1), et reprend au choix ; le remède « Choisir le papier » de l'annonce
|
||||
// pose la même question. Le papier choisi se montre et se change.
|
||||
//
|
||||
// La planche produite nomme son chemin — à côté des fichiers d'événement
|
||||
// —, et « Ouvrir le dossier » montre le dossier de travail par la façade,
|
||||
// quand la plateforme a un explorateur ; sous web, la planche se
|
||||
// télécharge, et la section dit son nom. Une écriture refusée se dit
|
||||
// PLANCHE_NON_ECRITE, remède « Enregistrer sous… » (enregistrerPlancheSous),
|
||||
// dont le chemin choisi remplace celui de la planche.
|
||||
//
|
||||
// La réédition (Reedition.svelte) passe par la même production, sa
|
||||
// sélection dans la demande.
|
||||
import { getContext } from 'svelte';
|
||||
import Commande from '../primitives/Commande.svelte';
|
||||
import Refus from '../primitives/Refus.svelte';
|
||||
import Papier from '../sorties/Papier.svelte';
|
||||
import Reedition from './Reedition.svelte';
|
||||
import {
|
||||
IMPOSITIONS_OFFERTES,
|
||||
ORDRES_OFFERTS,
|
||||
cleImposition,
|
||||
cleOrdre,
|
||||
cleSource,
|
||||
libelleSource,
|
||||
memesReglages,
|
||||
phrasesAnnonce,
|
||||
produireBloque,
|
||||
sourceChoisie,
|
||||
sourcesOffertes,
|
||||
} from './modele.js';
|
||||
|
||||
const t = getContext('t');
|
||||
const seance = getContext('seance');
|
||||
const ui = getContext('ui');
|
||||
const facade = getContext('facade');
|
||||
const uid = $props.id();
|
||||
|
||||
const charge = $derived($seance.charge);
|
||||
const offertes = $derived(charge === null ? [] : sourcesOffertes(charge, $ui.proposition));
|
||||
|
||||
// La clé de la source que l'opérateur a choisie, null avant tout choix.
|
||||
let cleChoisie = $state(null);
|
||||
let imposition = $state(IMPOSITIONS_OFFERTES[0]);
|
||||
let ordre = $state(ORDRES_OFFERTS[0]);
|
||||
const source = $derived(sourceChoisie(offertes, cleChoisie));
|
||||
|
||||
// La demande des réglages courants, avec la sélection d'une réédition,
|
||||
// null pour toute la soirée.
|
||||
const demandeDe = (selection) => ({ source: source.source, proposition: source.proposition, imposition, ordre, selection });
|
||||
|
||||
// La dernière annonce préparée : {annonce, demande, charge}, la charge lue
|
||||
// au départ de la préparation. Montrée tant que réglages, papier et
|
||||
// charge sont ceux qu'elle a lus. Gardée telle quelle, sans mandataire
|
||||
// réactif : la charge se compare par identité à celle de la séance.
|
||||
let preparee = $state.raw(null);
|
||||
const montree = $derived(
|
||||
preparee !== null &&
|
||||
preparee.charge === charge &&
|
||||
preparee.annonce.papier === $seance.papier &&
|
||||
memesReglages(preparee.demande, demandeDe(null))
|
||||
? preparee
|
||||
: null,
|
||||
);
|
||||
const phrases = $derived(montree === null ? [] : phrasesAnnonce(montree.annonce, montree.demande));
|
||||
const bloque = $derived(montree !== null && produireBloque(montree.annonce));
|
||||
|
||||
// La production qui attend le papier : {selection}, et vrai pour reprendre
|
||||
// la production au choix ; null sans question posée par la section.
|
||||
let attente = $state(null);
|
||||
// La planche produite : {chemin, nom}, chemin null sous web.
|
||||
let produite = $state(null);
|
||||
// La demande de la dernière production, que « Enregistrer sous… » reprend.
|
||||
let derniere = null;
|
||||
// Le refus d'une production reprise après le choix du papier, que nulle
|
||||
// commande ne porte.
|
||||
let refusRepris = $state(null);
|
||||
|
||||
async function preparer(selection) {
|
||||
const demande = demandeDe(selection);
|
||||
const lue = seance.etat().charge;
|
||||
const annonce = await seance.preparerPlanche(demande);
|
||||
preparee = { annonce, demande, charge: lue };
|
||||
return { annonce, demande };
|
||||
}
|
||||
|
||||
async function produire(selection) {
|
||||
// Une nouvelle production efface la planche montrée : un refus ne se
|
||||
// pose jamais à côté du chemin d'une production précédente.
|
||||
refusRepris = null;
|
||||
produite = null;
|
||||
const { annonce, demande } = await preparer(selection);
|
||||
if (produireBloque(annonce)) return;
|
||||
if (annonce.refus !== null) {
|
||||
attente = { selection, reprendre: true };
|
||||
return;
|
||||
}
|
||||
derniere = demande;
|
||||
produite = await seance.produirePlanche(demande);
|
||||
}
|
||||
|
||||
// Le papier choisi par la question de la section : la production qui
|
||||
// l'attendait reprend ; sinon, l'annonce montrée se prépare de nouveau.
|
||||
// Un refus de la reprise se pose sous les commandes.
|
||||
async function papierChoisi() {
|
||||
const reprise = attente;
|
||||
attente = null;
|
||||
try {
|
||||
if (reprise?.reprendre) await produire(reprise.selection);
|
||||
else if (preparee !== null) await preparer(preparee.demande.selection);
|
||||
} catch (erreur) {
|
||||
if (typeof erreur?.code !== 'string') throw erreur;
|
||||
refusRepris = { code: erreur.code, details: erreur.details ?? {} };
|
||||
}
|
||||
}
|
||||
|
||||
// Les remèdes que la section exécute : la question du papier, et
|
||||
// l'enregistrement ailleurs de la dernière planche demandée. Le chemin
|
||||
// choisi remplace celui de la planche ; null quand l'opérateur annule.
|
||||
const gestes = {
|
||||
choisirPapier: () => {
|
||||
attente = { selection: preparee?.demande.selection ?? null, reprendre: false };
|
||||
return true;
|
||||
},
|
||||
enregistrerSous: async () => {
|
||||
const chemin = await seance.enregistrerPlancheSous(derniere);
|
||||
if (chemin !== null) produite = { chemin, nom: null, ailleurs: true };
|
||||
return chemin;
|
||||
},
|
||||
};
|
||||
|
||||
const explorateur = facade.explorateur();
|
||||
</script>
|
||||
|
||||
<section data-section="badges">
|
||||
<h2>{t('coquille.section.badges')}</h2>
|
||||
<p>{t('badges.introduction')}</p>
|
||||
|
||||
{#if charge !== null}
|
||||
<div class="reglages">
|
||||
{#if offertes.length > 1}
|
||||
<label class="reglage">
|
||||
<span>{t('badges.source.titre')}</span>
|
||||
<select data-champ="source" value={cleSource(source)} onchange={(evenement) => (cleChoisie = evenement.currentTarget.value)}>
|
||||
{#each offertes as offerte (cleSource(offerte))}
|
||||
<option value={cleSource(offerte)}>{t(libelleSource(offerte).cle, libelleSource(offerte).details)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
{/if}
|
||||
<label class="reglage">
|
||||
<span>{t('badges.imposition.titre')}</span>
|
||||
<select data-champ="imposition" value={imposition} onchange={(evenement) => (imposition = evenement.currentTarget.value)}>
|
||||
{#each IMPOSITIONS_OFFERTES as valeur (valeur)}
|
||||
<option value={valeur}>{t(cleImposition(valeur))}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<label class="reglage">
|
||||
<span>{t('badges.ordre.titre')}</span>
|
||||
<select data-champ="ordre" value={ordre} onchange={(evenement) => (ordre = evenement.currentTarget.value)}>
|
||||
{#each ORDRES_OFFERTS as valeur (valeur)}
|
||||
<option value={valeur}>{t(cleOrdre(valeur))}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#if $seance.papier !== null || attente !== null}
|
||||
<Papier onChoisi={papierChoisi} />
|
||||
{/if}
|
||||
|
||||
<div class="commandes">
|
||||
<Commande cle="badges.commande.preparer" nom="preparerPlanche" executer={() => preparer(null)} {gestes} />
|
||||
{#if bloque}
|
||||
<button type="button" data-commande="produirePlanche" aria-disabled="true" aria-describedby={`${uid}-bloque`}
|
||||
>{t('badges.commande.produire')}</button
|
||||
>
|
||||
<p class="explication" id={`${uid}-bloque`}>{t('badges.produire.bloque')}</p>
|
||||
{:else}
|
||||
<Commande cle="badges.commande.produire" nom="produirePlanche" executer={() => produire(null)} {gestes} />
|
||||
{/if}
|
||||
</div>
|
||||
{#if refusRepris !== null}
|
||||
<Refus refus={refusRepris} lever={() => (refusRepris = null)} {gestes} />
|
||||
{/if}
|
||||
|
||||
{#if montree !== null}
|
||||
<div class="annonce" data-annonce-planche role="group" aria-labelledby={`${uid}-annonce`}>
|
||||
<h3 id={`${uid}-annonce`}>{t('badges.annonce.titre')}</h3>
|
||||
<ul>
|
||||
{#each phrases as phrase, rang (rang)}
|
||||
<li>{t(phrase.cle, phrase.details)}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{#if montree.annonce.refus !== null}
|
||||
<Refus refus={montree.annonce.refus} {gestes} />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if produite !== null}
|
||||
<div class="produite">
|
||||
{#if produite.chemin !== null}
|
||||
<p>
|
||||
<span>{t('badges.produite.chemin')}</span>
|
||||
<span class="chemin" data-chemin-planche>{produite.chemin}</span>
|
||||
</p>
|
||||
{#if explorateur.disponible && !produite.ailleurs}
|
||||
<Commande cle="badges.commande.ouvrirDossier" nom="ouvrirDossier" executer={() => facade.ouvrirExplorateur()} />
|
||||
{/if}
|
||||
{:else}
|
||||
<p data-chemin-planche>{t('badges.produite.telechargee', { nom: produite.nom })}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Reedition {charge} executer={produire} {gestes} />
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-3);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: var(--taille-titre);
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: var(--taille-texte);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.reglages,
|
||||
.commandes {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: var(--espace-3);
|
||||
}
|
||||
|
||||
.reglage {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: var(--espace-2);
|
||||
}
|
||||
|
||||
select {
|
||||
padding: var(--espace-1) var(--espace-2);
|
||||
border: 1px solid var(--couleur-page-filet);
|
||||
border-radius: var(--rayon-1);
|
||||
background-color: var(--couleur-page-fond);
|
||||
color: var(--couleur-page-texte);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
/* « Produire » désactivé tant qu'un refus tient : son filet tireté et son
|
||||
texte secondaire le distinguent, et son explication le suit. */
|
||||
button {
|
||||
padding: var(--espace-1) var(--espace-3);
|
||||
border: 1px dashed var(--couleur-page-filet);
|
||||
border-radius: var(--rayon-1);
|
||||
background-color: var(--couleur-page-fond);
|
||||
color: var(--couleur-page-texte-secondaire);
|
||||
font: inherit;
|
||||
font-size: var(--taille-texte);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
select:focus-visible,
|
||||
button:focus-visible {
|
||||
outline: 2px solid var(--couleur-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.explication {
|
||||
color: var(--couleur-page-texte-secondaire);
|
||||
}
|
||||
|
||||
.annonce {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--espace-2);
|
||||
padding: var(--espace-2) var(--espace-3);
|
||||
border: 1px solid var(--couleur-page-filet);
|
||||
border-radius: var(--rayon-1);
|
||||
}
|
||||
|
||||
ul {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-1);
|
||||
margin: 0;
|
||||
padding-left: var(--espace-4);
|
||||
}
|
||||
|
||||
.produite {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: var(--espace-2);
|
||||
}
|
||||
|
||||
/* Un chemin long se coupe où il faut, plutôt que de déborder. */
|
||||
.chemin {
|
||||
font-weight: bold;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
</style>
|
||||
210
src/interface/badges/modele.js
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Le modèle de la section des badges (§ 11.2, § 11.5, § 11.6, § 11.8, Q3),
|
||||
// pur : SectionBadges.svelte et Reedition.svelte le câblent.
|
||||
//
|
||||
// Une planche se tire du placement retenu, ou d'une proposition — celle que
|
||||
// la section des propositions a demandée au plan —, toujours BROUILLON (Q3).
|
||||
// La section offre ces sources, le retenu d'abord ; la source qui vaut est
|
||||
// celle que l'opérateur a choisie tant qu'elle reste offerte, sinon la
|
||||
// première. Sans source offerte, la demande désigne le retenu, et la séance
|
||||
// la refuse PLANCHE_SANS_PLACEMENT, remède vers les propositions.
|
||||
//
|
||||
// L'annonce que rend seance.preparerPlanche se dit en phrases : chacune une
|
||||
// clé de libelles/badges.js et ses détails, données comprises — le nom du
|
||||
// fichier, les noms des homonymes, les caractères absents de la police —,
|
||||
// que t compose en une seule chaîne. Le refus de l'annonce ne fait pas de
|
||||
// phrase : la section le pose par Refus, avec son remède. Le modèle ne rend
|
||||
// que des clés, des détails et des données.
|
||||
import { cleNormalisee } from '../../application/csv.js';
|
||||
import { nomAffiche } from '../../application/personnes.js';
|
||||
import { IMPOSITIONS, ORDRES, comparerPointsDeCode } from '../../application/planche.js';
|
||||
import { PAPIERS } from '../../geometrie/papiers.js';
|
||||
import { clesPapier } from '../impression/modele.js';
|
||||
|
||||
/** Les impositions que la section offre, la première par défaut (§ 11.6). */
|
||||
export const IMPOSITIONS_OFFERTES = IMPOSITIONS;
|
||||
|
||||
/** Les ordres de la pile que la section offre, le premier par défaut. */
|
||||
export const ORDRES_OFFERTS = ORDRES;
|
||||
|
||||
// Le refus qui ne bloque pas « Produire » : le papier se choisit sur place,
|
||||
// et la production reprend.
|
||||
const PAPIER_REQUIS = 'PAPIER_REQUIS';
|
||||
|
||||
/** La source que désigne une demande sans source offerte : le retenu, que
|
||||
* la séance refuse alors PLANCHE_SANS_PLACEMENT. */
|
||||
const SOURCE_PAR_DEFAUT = Object.freeze({ source: 'retenu', proposition: null });
|
||||
|
||||
/**
|
||||
* Les sources d'une planche que la section offre (Q3) : le retenu quand la
|
||||
* charge en porte un, puis la proposition demandée au plan quand la charge
|
||||
* la porte. Une liste neuve, vide sans l'un ni l'autre.
|
||||
*
|
||||
* @param {Object} charge la charge de l'événement ouvert
|
||||
* @param {number|null} demandee la proposition demandée au plan (EtatUi.proposition)
|
||||
* @returns {Array<{source: 'retenu'|'proposition', proposition: number|null}>}
|
||||
*/
|
||||
export function sourcesOffertes(charge, demandee) {
|
||||
const offertes = [];
|
||||
if (charge.retenu !== null) offertes.push({ source: 'retenu', proposition: null });
|
||||
if (demandee !== null && charge.propositions.some(({ id }) => id === demandee)) {
|
||||
offertes.push({ source: 'proposition', proposition: demandee });
|
||||
}
|
||||
return offertes;
|
||||
}
|
||||
|
||||
/** La clé d'une source, valeur de son option : 'retenu', ou
|
||||
* 'proposition:<id>'. */
|
||||
export const cleSource = ({ source, proposition }) => (source === 'retenu' ? 'retenu' : `proposition:${proposition}`);
|
||||
|
||||
/**
|
||||
* La source qui vaut : l'offerte de clé choisie, sinon la première
|
||||
* offerte, sinon le retenu.
|
||||
*
|
||||
* @param {Array<{source: string, proposition: number|null}>} offertes
|
||||
* @param {string|null} choisie la clé que l'opérateur a choisie
|
||||
*/
|
||||
export function sourceChoisie(offertes, choisie) {
|
||||
return offertes.find((offerte) => cleSource(offerte) === choisie) ?? offertes[0] ?? SOURCE_PAR_DEFAUT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Le libellé d'une source, pour son option : {cle, details}.
|
||||
*
|
||||
* @throws {RangeError} une source hors de 'retenu' et 'proposition'
|
||||
*/
|
||||
export function libelleSource({ source, proposition }) {
|
||||
if (source === 'retenu') return { cle: 'badges.source.retenu', details: {} };
|
||||
if (source === 'proposition') return { cle: 'badges.source.proposition', details: { id: proposition } };
|
||||
throw new RangeError(`source inconnue : ${JSON.stringify(source)}`);
|
||||
}
|
||||
|
||||
/** La clé du libellé d'une imposition ; RangeError hors d'IMPOSITIONS. */
|
||||
export function cleImposition(imposition) {
|
||||
if (!IMPOSITIONS.includes(imposition)) throw new RangeError(`imposition inconnue : ${JSON.stringify(imposition)}`);
|
||||
return `badges.imposition.${imposition}`;
|
||||
}
|
||||
|
||||
/** La clé du libellé d'un ordre ; RangeError hors d'ORDRES. */
|
||||
export function cleOrdre(ordre) {
|
||||
if (!ORDRES.includes(ordre)) throw new RangeError(`ordre inconnu : ${JSON.stringify(ordre)}`);
|
||||
return `badges.ordre.${ordre}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deux demandes ont-elles les mêmes réglages — source, proposition,
|
||||
* imposition, ordre — ? La sélection d'une réédition n'en est pas un :
|
||||
* l'annonce d'une réédition reste montrée tant que les réglages tiennent.
|
||||
*/
|
||||
export function memesReglages(a, b) {
|
||||
return a.source === b.source && a.proposition === b.proposition && a.imposition === b.imposition && a.ordre === b.ordre;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vrai quand l'annonce porte un refus qui empêche toute production : celui
|
||||
* du placement ou de la mise en page. PAPIER_REQUIS ne bloque pas : la
|
||||
* production pose la question du papier, puis reprend. Faux sans annonce.
|
||||
*
|
||||
* @param {import('../../application/planche_seance.js').Annonce|null} annonce
|
||||
*/
|
||||
export function produireBloque(annonce) {
|
||||
return annonce !== null && annonce.refus !== null && annonce.refus.code !== PAPIER_REQUIS;
|
||||
}
|
||||
|
||||
// Le point de code d'un caractère, écrit U+XXXX, quatre chiffres au moins,
|
||||
// comme la feuille de contrôle l'imprime.
|
||||
const pointDeCode = (caractere) => `U+${caractere.codePointAt(0).toString(16).toUpperCase().padStart(4, '0')}`;
|
||||
|
||||
/**
|
||||
* Les phrases de l'annonce d'une planche (§ 11.2, § 11.5, § 11.8,
|
||||
* décision 12), dans cet ordre : la source ; les comptes de la soirée, ou
|
||||
* celui d'une réédition ; le nombre de planches sur chaque papier de
|
||||
* PAPIERS, le papier choisi marqué ; chaque groupe d'homonymes ; les
|
||||
* caractères absents de la police avec leur point de code ; la mention
|
||||
* BROUILLON, de la source ; le nom du fichier. Sous un refus du placement,
|
||||
* seule la source se dit : comptes, planches et nom manquent à l'annonce.
|
||||
* L'annonce reçue n'est pas modifiée.
|
||||
*
|
||||
* @param {import('../../application/planche_seance.js').Annonce} annonce
|
||||
* @param {{source?: 'retenu'|'proposition', proposition?: number|null, selection?: number[]|null}} [demande]
|
||||
* @returns {Array<{cle: string, details: Object}>}
|
||||
*/
|
||||
export function phrasesAnnonce(annonce, { source = 'retenu', proposition = null, selection = null } = {}) {
|
||||
const { details } = libelleSource({ source, proposition });
|
||||
const phrases = [{ cle: `badges.annonce.source.${source}`, details }];
|
||||
if (annonce.comptes !== null) {
|
||||
phrases.push(
|
||||
selection === null
|
||||
? { cle: 'badges.annonce.comptes', details: { ...annonce.comptes } }
|
||||
: { cle: 'badges.annonce.reedition', details: { n: annonce.comptes.produits } },
|
||||
);
|
||||
}
|
||||
if (annonce.planches !== null) {
|
||||
const parPapier = Object.keys(PAPIERS).map((papier) => ({
|
||||
papier,
|
||||
nom: clesPapier(papier).nom,
|
||||
planches: annonce.planches[papier],
|
||||
choisi: papier === annonce.papier,
|
||||
}));
|
||||
phrases.push({ cle: 'badges.annonce.planches', details: { parPapier } });
|
||||
}
|
||||
for (const { nom, ids } of annonce.homonymes) phrases.push({ cle: 'badges.annonce.homonymes', details: { nom, ids: [...ids] } });
|
||||
if (annonce.absents.length > 0) {
|
||||
const caracteres = annonce.absents.map((caractere) => ({ caractere, point: pointDeCode(caractere) }));
|
||||
phrases.push({ cle: 'badges.annonce.absents', details: { caracteres } });
|
||||
}
|
||||
if (annonce.comptes !== null && annonce.brouillon) {
|
||||
phrases.push({ cle: `badges.annonce.brouillon.${source === 'proposition' ? 'proposition' : 'retenu'}`, details: {} });
|
||||
}
|
||||
if (annonce.nom !== null) phrases.push({ cle: 'badges.annonce.fichier', details: { nom: annonce.nom } });
|
||||
return phrases;
|
||||
}
|
||||
|
||||
// La clé de tri d'un nom affiché : sa forme normalisée (§ 10.1).
|
||||
const cleDeNom = (personne) => cleNormalisee(nomAffiche(personne));
|
||||
|
||||
/**
|
||||
* Les personnes dont un badge peut se rééditer (§ 11.8) : les non exclues,
|
||||
* par ordre de leur nom affiché normalisé, comparé par points de code,
|
||||
* départagées par identifiant. {id, nom, appartenance}, nom affiché.
|
||||
*
|
||||
* @param {Object} charge la charge de l'événement ouvert
|
||||
* @returns {Array<{id: number, nom: string, appartenance: string|null}>}
|
||||
*/
|
||||
export function personnesReeditables(charge) {
|
||||
return charge.participants
|
||||
.filter(({ exclu }) => !exclu)
|
||||
.map((personne) => ({ personne, cle: cleDeNom(personne) }))
|
||||
.sort((a, b) => comparerPointsDeCode(a.cle, b.cle) || a.personne.id - b.personne.id)
|
||||
.map(({ personne }) => ({ id: personne.id, nom: nomAffiche(personne), appartenance: personne.appartenance }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Les personnes dont le nom affiché ou l'appartenance contient la saisie,
|
||||
* l'un et l'autre normalisés ; toutes pour une saisie vide ou de blancs.
|
||||
*
|
||||
* @param {Array<{id: number, nom: string, appartenance: string|null}>} personnes
|
||||
* @param {string} saisie
|
||||
*/
|
||||
export function filtrerPersonnes(personnes, saisie) {
|
||||
const cherchee = cleNormalisee(saisie);
|
||||
if (cherchee === '') return personnes;
|
||||
return personnes.filter(
|
||||
({ nom, appartenance }) => cleNormalisee(nom).includes(cherchee) || cleNormalisee(appartenance ?? '').includes(cherchee),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* La sélection d'une réédition : les identifiants cochés qui restent
|
||||
* rééditables, dans l'ordre de la liste ; null quand il n'en reste aucun.
|
||||
*
|
||||
* @param {number[]} cochees
|
||||
* @param {Array<{id: number}>} personnes celles de personnesReeditables
|
||||
* @returns {number[]|null}
|
||||
*/
|
||||
export function selectionRetenue(cochees, personnes) {
|
||||
const retenues = personnes.filter(({ id }) => cochees.includes(id)).map(({ id }) => id);
|
||||
return retenues.length === 0 ? null : retenues;
|
||||
}
|
||||
300
src/interface/badges/modele.test.js
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Le modèle de la section des badges (§ 11.2, § 11.5, § 11.6, § 11.8, Q3),
|
||||
// sans DOM : les sources offertes et celle qui vaut, les réglages d'une
|
||||
// demande, les phrases de l'annonce, ce qui bloque « Produire », les
|
||||
// personnes d'une réédition. Chaque clé que le modèle désigne existe, en
|
||||
// français comme en langue témoin, et se compose sans lever. Une annonce se
|
||||
// prend telle que la séance la rend (planche_seance.js), sur de petites
|
||||
// soirées construites ; aucune production de PDF.
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, test } from '../../../test/lanceur.js';
|
||||
import { personne, soiree, table } from '../../../test/fixtures/badges/cas.js';
|
||||
import { lirePolices } from '../../../test/polices.js';
|
||||
import { TABLES_APPLICATION } from '../../application/libelles.js';
|
||||
import { creerPlanches } from '../../application/planche_seance.js';
|
||||
import { MARQUE_TEMOIN, creerTraduction } from '../../application/traduction.js';
|
||||
import { PAPIERS } from '../../geometrie/papiers.js';
|
||||
import { VERSION } from '../../version.genere.js';
|
||||
import { TABLES_INTERFACE } from '../libelles.js';
|
||||
import {
|
||||
IMPOSITIONS_OFFERTES,
|
||||
ORDRES_OFFERTS,
|
||||
cleImposition,
|
||||
cleOrdre,
|
||||
cleSource,
|
||||
filtrerPersonnes,
|
||||
libelleSource,
|
||||
memesReglages,
|
||||
personnesReeditables,
|
||||
phrasesAnnonce,
|
||||
produireBloque,
|
||||
selectionRetenue,
|
||||
sourceChoisie,
|
||||
sourcesOffertes,
|
||||
} from './modele.js';
|
||||
|
||||
const TABLES = [...TABLES_APPLICATION, ...TABLES_INTERFACE];
|
||||
const { t } = creerTraduction(TABLES);
|
||||
const { t: tTemoin } = creerTraduction(TABLES, { langue: 'temoin' });
|
||||
|
||||
// Chaque phrase se compose, en français et en langue témoin, entière dans sa
|
||||
// marque ; rend les textes français.
|
||||
function composer(phrases) {
|
||||
assert.ok(phrases.length > 0, 'aucune phrase');
|
||||
return phrases.map(({ cle, details }) => {
|
||||
const francais = t(cle, details);
|
||||
assert.equal(typeof francais, 'string', cle);
|
||||
assert.notEqual(francais.trim(), '', cle);
|
||||
assert.equal(tTemoin(cle, details), `${MARQUE_TEMOIN.ouverture}${francais}${MARQUE_TEMOIN.fermeture}`, cle);
|
||||
return francais;
|
||||
});
|
||||
}
|
||||
|
||||
const cles = (phrases) => phrases.map(({ cle }) => cle);
|
||||
|
||||
// Deux tables de quatre, deux tours : quatre personnes, dont une sans
|
||||
// prénom ; la personne 5 est exclue.
|
||||
const PERSONNES = () => [
|
||||
personne(1, 'Iris', 'Ombrelle', { appartenance: 'Club des Merles' }),
|
||||
personne(2, 'Théo', 'Pervenche'),
|
||||
personne(3, 'Ondine', 'Lacasse'),
|
||||
personne(4, null, 'Grisaille'),
|
||||
personne(5, 'Iris', 'Pervenche', { exclu: true }),
|
||||
];
|
||||
const TABLES_SOIREE = () => [table(1, 1, 4), table(2, 2, 4)];
|
||||
const PLAN = [{ sieges: [[1, 2], [3, 4]] }, { sieges: [[1, 3], [2, 4]] }];
|
||||
|
||||
// La soirée retenue, datée, sur un plan retenu, et une proposition 2.
|
||||
const retenue = (champs = {}) =>
|
||||
soiree({ personnes: PERSONNES(), tables: TABLES_SOIREE(), plan: PLAN, propositions: [[2, PLAN]], ...champs });
|
||||
|
||||
// Les opérations de la planche d'une séance, sur la police embarquée lue
|
||||
// du disque, la provenance d'épreuve et la version engendrée.
|
||||
const planches = creerPlanches({ polices: async () => lirePolices(), provenance: 'test', version: VERSION.affichee });
|
||||
const fsInutile = Object.freeze({ nature: 'epreuve' });
|
||||
|
||||
// L'annonce que la séance rendrait sur cette charge, ce papier et cette
|
||||
// demande : la lecture que la séance passe, sans système de fichiers lu.
|
||||
const annoncer = (charge, papier, demande = {}) =>
|
||||
planches.preparer({ charge, base: 'lucioles', fs: fsInutile, racine: { id: 'documents', chemin: '/d' }, papier }, demande);
|
||||
|
||||
describe('les sources offertes (Q3)', () => {
|
||||
test('le retenu d’abord, puis la proposition demandée au plan quand elle existe ; rien sans l’un ni l’autre', () => {
|
||||
const charge = retenue();
|
||||
assert.deepEqual(sourcesOffertes(charge, null), [{ source: 'retenu', proposition: null }]);
|
||||
assert.deepEqual(sourcesOffertes(charge, 2), [
|
||||
{ source: 'retenu', proposition: null },
|
||||
{ source: 'proposition', proposition: 2 },
|
||||
]);
|
||||
// Une proposition demandée que la charge ne porte plus n'est pas offerte.
|
||||
assert.deepEqual(sourcesOffertes(charge, 9), [{ source: 'retenu', proposition: null }]);
|
||||
const sansRetenu = soiree({ personnes: PERSONNES(), tables: TABLES_SOIREE(), etat: 'propose', propositions: [[2, PLAN]] });
|
||||
assert.deepEqual(sourcesOffertes(sansRetenu, 2), [{ source: 'proposition', proposition: 2 }]);
|
||||
assert.deepEqual(sourcesOffertes(sansRetenu, null), []);
|
||||
});
|
||||
|
||||
test('la source qui vaut : celle de la clé choisie quand elle est offerte, sinon la première, sinon le retenu', () => {
|
||||
const offertes = [
|
||||
{ source: 'retenu', proposition: null },
|
||||
{ source: 'proposition', proposition: 2 },
|
||||
];
|
||||
assert.equal(cleSource(offertes[0]), 'retenu');
|
||||
assert.equal(cleSource(offertes[1]), 'proposition:2');
|
||||
assert.deepEqual(sourceChoisie(offertes, null), offertes[0]);
|
||||
assert.deepEqual(sourceChoisie(offertes, 'proposition:2'), offertes[1]);
|
||||
assert.deepEqual(sourceChoisie(offertes, 'proposition:7'), offertes[0]);
|
||||
assert.deepEqual(sourceChoisie([offertes[1]], 'retenu'), offertes[1]);
|
||||
assert.deepEqual(sourceChoisie([], 'proposition:2'), { source: 'retenu', proposition: null });
|
||||
});
|
||||
|
||||
test('chaque source, imposition et ordre a son libellé ; une valeur inconnue lève', () => {
|
||||
assert.deepEqual([...IMPOSITIONS_OFFERTES], ['feuille', 'pile']);
|
||||
assert.deepEqual([...ORDRES_OFFERTS], ['alphabetique', 'table']);
|
||||
composer([
|
||||
libelleSource({ source: 'retenu', proposition: null }),
|
||||
libelleSource({ source: 'proposition', proposition: 2 }),
|
||||
...IMPOSITIONS_OFFERTES.map((imposition) => ({ cle: cleImposition(imposition), details: {} })),
|
||||
...ORDRES_OFFERTS.map((ordre) => ({ cle: cleOrdre(ordre), details: {} })),
|
||||
]);
|
||||
assert.match(t(libelleSource({ source: 'proposition', proposition: 2 }).cle, { id: 2 }), /2/);
|
||||
assert.throws(() => cleImposition('rame'), RangeError);
|
||||
assert.throws(() => cleOrdre('hasard'), RangeError);
|
||||
assert.throws(() => libelleSource({ source: 'reservations', proposition: null }), RangeError);
|
||||
});
|
||||
|
||||
test('deux demandes ont les mêmes réglages quand source, proposition, imposition et ordre concordent, sélection à part', () => {
|
||||
const base = { source: 'retenu', proposition: null, imposition: 'feuille', ordre: 'alphabetique', selection: null };
|
||||
assert.equal(memesReglages(base, { ...base, selection: [1, 2] }), true);
|
||||
for (const [cle, valeur] of [
|
||||
['source', 'proposition'],
|
||||
['proposition', 2],
|
||||
['imposition', 'pile'],
|
||||
['ordre', 'table'],
|
||||
]) {
|
||||
assert.equal(memesReglages(base, { ...base, [cle]: valeur }), false, cle);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('les phrases de l’annonce (§ 11.2, § 11.5, § 11.8)', () => {
|
||||
test('un retenu, papier choisi : la source, les comptes, les planches des deux papiers, le fichier ; ni brouillon ni refus', async () => {
|
||||
const charge = retenue();
|
||||
const annonce = await annoncer(charge, 'A4');
|
||||
assert.equal(annonce.refus, null);
|
||||
const phrases = phrasesAnnonce(annonce, { source: 'retenu', proposition: null, selection: null });
|
||||
assert.deepEqual(cles(phrases), [
|
||||
'badges.annonce.source.retenu',
|
||||
'badges.annonce.comptes',
|
||||
'badges.annonce.planches',
|
||||
'badges.annonce.fichier',
|
||||
]);
|
||||
const [source, comptes, planchesLues, fichier] = composer(phrases);
|
||||
assert.match(source, /retenu/);
|
||||
assert.equal(comptes, '4 badges pour 5 participants, 1 exclu.');
|
||||
assert.deepEqual(phrases[2].details.parPapier, Object.keys(PAPIERS).map((papier) => ({
|
||||
papier,
|
||||
nom: `impression.papier.nom.${papier.toLowerCase()}`,
|
||||
planches: annonce.planches[papier],
|
||||
choisi: papier === 'A4',
|
||||
})));
|
||||
assert.equal(planchesLues, 'Planches : 1 planche en A4 (papier choisi) et 2 planches en Lettre.');
|
||||
assert.ok(fichier.endsWith(annonce.nom), fichier);
|
||||
assert.equal(produireBloque(annonce), false);
|
||||
});
|
||||
|
||||
test('un brouillon : un plan proposé qui garde son retenu, puis une proposition, toujours BROUILLON, que l’annonce nomme', async () => {
|
||||
const proposee = retenue({ etat: 'propose' });
|
||||
const surRetenu = phrasesAnnonce(await annoncer(proposee, 'A4'), { source: 'retenu', proposition: null, selection: null });
|
||||
assert.ok(cles(surRetenu).includes('badges.annonce.brouillon.retenu'));
|
||||
composer(surRetenu);
|
||||
|
||||
const demande = { source: 'proposition', proposition: 2, selection: null };
|
||||
const annonce = await annoncer(retenue(), 'A4', demande);
|
||||
assert.equal(annonce.brouillon, true);
|
||||
const phrases = phrasesAnnonce(annonce, demande);
|
||||
assert.deepEqual(cles(phrases), [
|
||||
'badges.annonce.source.proposition',
|
||||
'badges.annonce.comptes',
|
||||
'badges.annonce.planches',
|
||||
'badges.annonce.brouillon.proposition',
|
||||
'badges.annonce.fichier',
|
||||
]);
|
||||
const textes = composer(phrases);
|
||||
assert.match(textes[0], /n° 2/);
|
||||
assert.match(textes[3], /BROUILLON/);
|
||||
});
|
||||
|
||||
test('une dérive : le refus nomme ce qui a changé ; ni comptes, ni planches, ni fichier ; « Produire » bloqué', async () => {
|
||||
const charge = retenue();
|
||||
charge.participants.push(personne(6, 'Ondine', 'Pervenche'));
|
||||
charge.prochainsIds.participant = 7;
|
||||
const annonce = await annoncer(charge, 'A4');
|
||||
assert.equal(annonce.refus.code, 'PLANCHE_DERIVE');
|
||||
assert.ok(annonce.refus.details.raisons.length > 0);
|
||||
assert.deepEqual(annonce.refus.details.remede, { geste: 'ouvrirSection', section: 'propositions' });
|
||||
const phrases = phrasesAnnonce(annonce, { source: 'retenu', proposition: null, selection: null });
|
||||
assert.deepEqual(cles(phrases), ['badges.annonce.source.retenu']);
|
||||
composer(phrases);
|
||||
assert.match(t(annonce.refus.code, annonce.refus.details), /Ondine Pervenche/);
|
||||
assert.equal(produireBloque(annonce), true);
|
||||
});
|
||||
|
||||
test('sans placement, ou sous tout refus du placement ou de la mise en page, « Produire » est bloqué ; sans papier, non', () => {
|
||||
const vide = { papier: null, comptes: null, planches: null, homonymes: [], absents: [], brouillon: true, nom: null };
|
||||
assert.equal(produireBloque(null), false);
|
||||
assert.equal(produireBloque({ ...vide, refus: null }), false);
|
||||
assert.equal(produireBloque({ ...vide, refus: { code: 'PAPIER_REQUIS', details: {} } }), false);
|
||||
for (const code of ['PLANCHE_SANS_PLACEMENT', 'RETENU_FAUTIF', 'PLANCHE_DERIVE', 'PLANCHE_TROP_DE_TOURS', 'PLANCHE_NOM_TROP_LONG']) {
|
||||
assert.equal(produireBloque({ ...vide, refus: { code, details: {} } }), true, code);
|
||||
}
|
||||
});
|
||||
|
||||
test('un papier absent : PAPIER_REQUIS, remède « Choisir le papier », les planches des deux papiers sans papier choisi, aucun fichier', async () => {
|
||||
const annonce = await annoncer(retenue(), null);
|
||||
assert.equal(annonce.refus.code, 'PAPIER_REQUIS');
|
||||
assert.deepEqual(annonce.refus.details.remede, { geste: 'choisirPapier' });
|
||||
const phrases = phrasesAnnonce(annonce, { source: 'retenu', proposition: null, selection: null });
|
||||
assert.deepEqual(cles(phrases), ['badges.annonce.source.retenu', 'badges.annonce.comptes', 'badges.annonce.planches']);
|
||||
assert.ok(phrases[2].details.parPapier.every(({ choisi }) => choisi === false));
|
||||
assert.equal(composer(phrases)[2], 'Planches : 1 planche en A4 et 2 planches en Lettre.');
|
||||
});
|
||||
|
||||
test('des homonymes : chaque groupe nommé, avec les identifiants que le badge imprime', async () => {
|
||||
const charge = retenue();
|
||||
charge.participants[2] = personne(3, 'Iris', 'Ombrelle', { appartenance: 'Club des Merles' });
|
||||
const annonce = await annoncer(charge, 'A4');
|
||||
assert.deepEqual(annonce.homonymes, [{ nom: 'Iris Ombrelle', ids: [1, 3] }]);
|
||||
const phrases = phrasesAnnonce(annonce, { source: 'retenu', proposition: null, selection: null });
|
||||
const rang = cles(phrases).indexOf('badges.annonce.homonymes');
|
||||
assert.ok(rang > 0);
|
||||
assert.deepEqual(phrases[rang].details, { nom: 'Iris Ombrelle', ids: [1, 3] });
|
||||
assert.equal(composer(phrases)[rang], 'Iris Ombrelle : 2 badges que seul leur identifiant imprimé sépare, n° 1 et n° 3.');
|
||||
});
|
||||
|
||||
test('des glyphes absents : chaque caractère nommé avec son point de code, dans l’ordre de l’annonce', async () => {
|
||||
const charge = retenue();
|
||||
charge.participants[1] = personne(2, 'Théo', 'Pervenche 李');
|
||||
charge.participants[3] = personne(4, null, 'Grisaille \u{1F600}');
|
||||
const annonce = await annoncer(charge, 'A4');
|
||||
// Grisaille vient avant Théo Pervenche dans la pile : son caractère est
|
||||
// rencontré le premier.
|
||||
assert.deepEqual(annonce.absents, ['\u{1F600}', '李']);
|
||||
const phrases = phrasesAnnonce(annonce, { source: 'retenu', proposition: null, selection: null });
|
||||
const rang = cles(phrases).indexOf('badges.annonce.absents');
|
||||
assert.ok(rang > 0);
|
||||
assert.deepEqual(phrases[rang].details.caracteres, [
|
||||
{ caractere: '\u{1F600}', point: 'U+1F600' },
|
||||
{ caractere: '李', point: 'U+674E' },
|
||||
]);
|
||||
assert.match(composer(phrases)[rang], /U\+1F600.*U\+674E/);
|
||||
});
|
||||
|
||||
test('une réédition : son compte à la place de ceux de la soirée', async () => {
|
||||
const demande = { source: 'retenu', proposition: null, selection: [4, 2, 3] };
|
||||
const annonce = await annoncer(retenue(), 'A4', demande);
|
||||
const phrases = phrasesAnnonce(annonce, demande);
|
||||
assert.deepEqual(cles(phrases).slice(0, 2), ['badges.annonce.source.retenu', 'badges.annonce.reedition']);
|
||||
assert.deepEqual(phrases[1].details, { n: 3 });
|
||||
assert.match(composer(phrases)[1], /3 badges/);
|
||||
assert.match(annonce.nom, /\.badges-3_/);
|
||||
});
|
||||
|
||||
test('sans demande, l’annonce se lit sur le retenu, sans sélection ; l’annonce reçue ne change pas', async () => {
|
||||
const annonce = await annoncer(retenue(), 'A4');
|
||||
const copie = structuredClone(annonce);
|
||||
assert.deepEqual(phrasesAnnonce(annonce), phrasesAnnonce(annonce, { source: 'retenu', proposition: null, selection: null }));
|
||||
assert.deepEqual(annonce, copie);
|
||||
});
|
||||
});
|
||||
|
||||
describe('la réédition (§ 11.8)', () => {
|
||||
test('les personnes non exclues, par ordre alphabétique de leur nom affiché, accents et casse ignorés', () => {
|
||||
const charge = retenue();
|
||||
charge.participants.push(personne(6, 'éloi', 'Abeille'));
|
||||
assert.deepEqual(personnesReeditables(charge), [
|
||||
{ id: 6, nom: 'éloi Abeille', appartenance: null },
|
||||
{ id: 4, nom: 'Grisaille', appartenance: null },
|
||||
{ id: 1, nom: 'Iris Ombrelle', appartenance: 'Club des Merles' },
|
||||
{ id: 3, nom: 'Ondine Lacasse', appartenance: null },
|
||||
{ id: 2, nom: 'Théo Pervenche', appartenance: null },
|
||||
]);
|
||||
});
|
||||
|
||||
test('le filtre retient les noms ou appartenances qui contiennent la saisie normalisée ; une saisie vide retient tout', () => {
|
||||
const personnes = personnesReeditables(retenue());
|
||||
assert.deepEqual(filtrerPersonnes(personnes, ''), personnes);
|
||||
assert.deepEqual(filtrerPersonnes(personnes, ' '), personnes);
|
||||
assert.deepEqual(filtrerPersonnes(personnes, 'THEO').map(({ id }) => id), [2]);
|
||||
assert.deepEqual(filtrerPersonnes(personnes, 'merles').map(({ id }) => id), [1]);
|
||||
assert.deepEqual(filtrerPersonnes(personnes, 'zz'), []);
|
||||
});
|
||||
|
||||
test('la sélection retenue : les cochées encore rééditables, dans l’ordre de la liste, null quand aucune', () => {
|
||||
const personnes = personnesReeditables(retenue());
|
||||
assert.deepEqual(selectionRetenue([2, 5, 1], personnes), [1, 2]);
|
||||
assert.equal(selectionRetenue([5], personnes), null);
|
||||
assert.equal(selectionRetenue([], personnes), null);
|
||||
});
|
||||
});
|
||||
|
|
@ -178,7 +178,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(7);
|
||||
expect(ordre.length).toBe(8);
|
||||
for (const nom of [...ordre, ...[...ordre].reverse()]) {
|
||||
const bouton = dans(m, `[data-commande="section"][data-cible="${nom}"]`);
|
||||
expect(visible(bouton), nom).toBe(true);
|
||||
|
|
@ -200,7 +200,7 @@ describe('aucun assistant (§ 2.1)', () => {
|
|||
});
|
||||
|
||||
describe('la place fixe (§ 8.4, § 8.5)', () => {
|
||||
test('« Modifier » aux mêmes coordonnées sur les sept 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 les huit sections ; 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) {
|
||||
|
|
@ -209,7 +209,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(7);
|
||||
expect(coins.length).toBe(8);
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@
|
|||
// nombre des avertissements, et le support rompu, en tête de la zone,
|
||||
// garde ses issues en vue (CadreMode.svelte).
|
||||
import { getContext } from 'svelte';
|
||||
import SectionBadges from '../badges/SectionBadges.svelte';
|
||||
import Diagnostic from '../diagnostic/Diagnostic.svelte';
|
||||
import SectionGeneration from '../generation/SectionGeneration.svelte';
|
||||
import SectionImport from '../import/SectionImport.svelte';
|
||||
|
|
@ -52,6 +53,7 @@
|
|||
['diagnostic', Diagnostic],
|
||||
['generation', SectionGeneration],
|
||||
['propositions', SectionPropositions],
|
||||
['badges', SectionBadges],
|
||||
]);
|
||||
|
||||
const Section = $derived(COMPOSANTS.get($ui.section));
|
||||
|
|
@ -115,8 +117,12 @@
|
|||
gap: var(--espace-2);
|
||||
}
|
||||
|
||||
/* Le retrait latéral des boutons de section vaut --espace-2 : les huit
|
||||
sections tiennent sur une ligne à côté de la place des annonces dans
|
||||
une fenêtre de 1 366 px, et un avertissement ne retire donc aucune
|
||||
hauteur au plan (CadreMode.svelte). */
|
||||
button {
|
||||
padding: var(--espace-1) var(--espace-3);
|
||||
padding: var(--espace-1) var(--espace-2);
|
||||
border: 1px solid var(--couleur-page-filet);
|
||||
border-radius: var(--rayon-1);
|
||||
background-color: var(--couleur-page-fond);
|
||||
|
|
|
|||
|
|
@ -15,11 +15,13 @@ const CLES = new Map([
|
|||
['diagnostic', 'coquille.section.diagnostic'],
|
||||
['generation', 'coquille.section.generation'],
|
||||
['propositions', 'coquille.section.propositions'],
|
||||
['badges', 'coquille.section.badges'],
|
||||
]);
|
||||
|
||||
/** Les sections de l'itération, dans l'ordre du § 2.1 : import,
|
||||
* participants, tables, plan, diagnostic, generation, propositions. Aucune
|
||||
* n'en exige une autre. Le titre et le nom de navigation de chacune sont la
|
||||
/** 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. */
|
||||
export const SECTIONS = Object.freeze([...CLES.keys()]);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
// © 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) : sept noms distincts, dans
|
||||
// l'ordre du parcours de l'opérateur, chacun nommé par sa clé
|
||||
// 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.
|
||||
import assert from 'node:assert/strict';
|
||||
|
|
@ -11,9 +12,9 @@ import { TABLE as COQUILLE } from '../libelles/coquille.js';
|
|||
import { SECTION_OUVERTURE, SECTIONS, cleDeSection } from './sections.js';
|
||||
|
||||
describe('sections de l’événement (§ 2.1)', () => {
|
||||
test('sept noms distincts, dans l’ordre du parcours, figés', () => {
|
||||
assert.deepEqual(SECTIONS, ['import', 'participants', 'tables', 'plan', 'diagnostic', 'generation', 'propositions']);
|
||||
assert.equal(new Set(SECTIONS).size, 7);
|
||||
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);
|
||||
assert.ok(Object.isFrozen(SECTIONS));
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
// démarrage et le livrable les appellent sans connaître la traduction.
|
||||
import { creerTraduction } from '../application/traduction.js';
|
||||
import { TABLE as ACCUEIL } from './libelles/accueil.js';
|
||||
import { TABLE as BADGES } from './libelles/badges.js';
|
||||
import { TABLE as COMMUNS } from './libelles/communs.js';
|
||||
import { TABLE as COQUILLE } from './libelles/coquille.js';
|
||||
import { TABLE as DIAGNOSTIC } from './libelles/diagnostic.js';
|
||||
|
|
@ -28,6 +29,7 @@ import { TABLE as TABLES } from './libelles/tables.js';
|
|||
/** Les tables de libelles/, dans l'ordre alphabétique de leurs fichiers. */
|
||||
export const TABLES_INTERFACE = Object.freeze([
|
||||
ACCUEIL,
|
||||
BADGES,
|
||||
COMMUNS,
|
||||
COQUILLE,
|
||||
DIAGNOSTIC,
|
||||
|
|
|
|||
|
|
@ -65,11 +65,11 @@ 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, quatorze, par ordre alphabétique', async () => {
|
||||
test('chaque module de libelles/ exporte TABLE, une Map ; TABLES_INTERFACE les assemble, quinze, par ordre alphabétique', async () => {
|
||||
const tables = await tablesDuDossier();
|
||||
assert.equal(tables.length, 14);
|
||||
assert.equal(tables.length, 15);
|
||||
assert.deepEqual(tables.map(({ table }) => table), [
|
||||
'accueil', 'communs', 'coquille', 'diagnostic', 'evenement', 'feuilles', 'generation',
|
||||
'accueil', 'badges', 'communs', 'coquille', 'diagnostic', 'evenement', 'feuilles', 'generation',
|
||||
'import', 'impression', 'participants', 'plan', 'propositions', 'raccourcis', 'tables',
|
||||
]);
|
||||
for (const { table, module } of tables) assert.ok(module.TABLE instanceof Map, table);
|
||||
|
|
|
|||
100
src/interface/libelles/badges.js
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Les libellés de la section des badges (§ 11.2, § 11.5, § 11.6, § 11.8,
|
||||
// Q3) : ses réglages — source, imposition, ordre —, ses commandes, les
|
||||
// phrases de l'annonce que rend phrasesAnnonce (badges/modele.js), la
|
||||
// planche produite et la réédition. Chaque clé commence par « badges. ».
|
||||
// Les refus de la planche sont des textes de l'application
|
||||
// (application/libelles/planche.js), le nom d'un papier un libellé de
|
||||
// l'aperçu (impression.papier.nom.*), que l'annonce lit par texte. Le
|
||||
// chemin de la planche produite est une donnée : l'écran le pose dans son
|
||||
// propre nœud, à côté de son intitulé.
|
||||
import { compte, enumeration } from '../../application/libelles/aides.js';
|
||||
|
||||
export const TABLE = new Map([
|
||||
['badges.introduction', "Un badge A6 par personne présente, imposé sur le papier de l'imprimante, précédé d'une feuille de contrôle."],
|
||||
|
||||
// --- Les réglages de la planche (§ 11.6, Q3) ------------------------------
|
||||
['badges.source.titre', 'Placement imprimé'],
|
||||
['badges.source.retenu', 'Le placement retenu'],
|
||||
['badges.source.proposition', ({ id }) => `La proposition n° ${id}`],
|
||||
['badges.imposition.titre', 'Imposition'],
|
||||
['badges.imposition.feuille', 'Feuille par feuille (ciseaux, massicot à main)'],
|
||||
['badges.imposition.pile', 'En pile (massicot qui tranche toute la rame)'],
|
||||
['badges.ordre.titre', 'Ordre des badges'],
|
||||
['badges.ordre.alphabetique', 'Alphabétique'],
|
||||
['badges.ordre.table', 'Par table du premier tour'],
|
||||
|
||||
// --- Les commandes ----------------------------------------------------------
|
||||
['badges.commande.preparer', 'Préparer'],
|
||||
['badges.commande.produire', 'Produire la planche'],
|
||||
['badges.commande.ouvrirDossier', 'Ouvrir le dossier'],
|
||||
['badges.produire.bloque', 'La planche ne se produit pas tant que le refus de l\u{2019}annonce tient.'],
|
||||
|
||||
// --- L'annonce, avant de produire (§ 11.2, § 11.5, § 11.8) ------------------
|
||||
['badges.annonce.titre', 'Avant de produire'],
|
||||
['badges.annonce.source.retenu', 'Source : le placement retenu.'],
|
||||
[
|
||||
'badges.annonce.source.proposition',
|
||||
({ id }) => `Source : la proposition n° ${id}, qui n\u{2019}est pas le placement retenu.`,
|
||||
],
|
||||
[
|
||||
'badges.annonce.comptes',
|
||||
({ produits, participants, exclus }) =>
|
||||
`${compte(produits, 'badge', 'badges')} pour ${compte(participants, 'participant', 'participants')}` +
|
||||
`${exclus === 0 ? '' : `, ${compte(exclus, 'exclu', 'exclus')}`}.`,
|
||||
],
|
||||
[
|
||||
'badges.annonce.reedition',
|
||||
({ n }) => `Réédition : ${compte(n, 'badge', 'badges')}, que la feuille de contrôle annonce comme telle.`,
|
||||
],
|
||||
// parPapier : par papier, la clé de son nom, ses planches, et s'il est
|
||||
// celui de la machine.
|
||||
[
|
||||
'badges.annonce.planches',
|
||||
({ parPapier }, texte) =>
|
||||
`Planches : ${enumeration(
|
||||
parPapier.map(
|
||||
({ nom, planches, choisi }) => `${compte(planches, 'planche', 'planches')} en ${texte(nom)}${choisi ? ' (papier choisi)' : ''}`,
|
||||
),
|
||||
)}.`,
|
||||
],
|
||||
[
|
||||
'badges.annonce.homonymes',
|
||||
({ nom, ids }) =>
|
||||
`${nom} : ${compte(ids.length, 'badge', 'badges')} que seul leur identifiant imprimé sépare, ` +
|
||||
`${enumeration(ids.map((id) => `n° ${id}`))}.`,
|
||||
],
|
||||
[
|
||||
'badges.annonce.absents',
|
||||
({ caracteres }) =>
|
||||
`Caractères absents de la police, imprimés sans glyphe et listés sur la feuille de contrôle : ${enumeration(
|
||||
caracteres.map(({ caractere, point }) => `« ${caractere} » (${point})`),
|
||||
)}.`,
|
||||
],
|
||||
[
|
||||
'badges.annonce.brouillon.retenu',
|
||||
'Chaque badge portera la mention BROUILLON : le plan n\u{2019}est ni retenu ni bloqué, ou son placement ne couvre pas tous les tours.',
|
||||
],
|
||||
[
|
||||
'badges.annonce.brouillon.proposition',
|
||||
'Chaque badge portera la mention BROUILLON : une proposition n\u{2019}est pas le placement retenu.',
|
||||
],
|
||||
['badges.annonce.fichier', ({ nom }) => `Fichier : ${nom}`],
|
||||
|
||||
// --- La planche produite (§ 11.6, décision 13) -----------------------------
|
||||
['badges.produite.chemin', 'Planche déposée :'],
|
||||
['badges.produite.telechargee', ({ nom }) => `Planche téléchargée sous le nom ${nom}.`],
|
||||
|
||||
// --- La réédition (§ 11.8) --------------------------------------------------
|
||||
['badges.reedition.titre', 'Rééditer des badges'],
|
||||
[
|
||||
'badges.reedition.explication',
|
||||
"Cochez les personnes dont le badge est à refaire : la planche ne porte qu'elles, et sa feuille de contrôle l'annonce.",
|
||||
],
|
||||
['badges.reedition.filtre', 'Chercher une personne'],
|
||||
['badges.reedition.personnes', 'Personnes présentes'],
|
||||
['badges.reedition.aucune', 'Aucune personne ne répond à cette recherche.'],
|
||||
['badges.reedition.produire', ({ n }) => `Rééditer ${compte(n, 'badge', 'badges')}`],
|
||||
]);
|
||||
|
|
@ -71,4 +71,5 @@ export const TABLE = new Map([
|
|||
['coquille.section.diagnostic', 'Diagnostic'],
|
||||
['coquille.section.generation', 'Génération'],
|
||||
['coquille.section.propositions', 'Propositions'],
|
||||
['coquille.section.badges', 'Badges'],
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@
|
|||
// [attribut, porteur, valeurs], dans l'ordre des attributs.
|
||||
const ENTREES = [
|
||||
['data-affichee', 'import : la valeur affichée d’une cellule de l’aperçu', ['']],
|
||||
['data-annonce-planche', 'badges : l’annonce de la planche, avant de produire', ['']],
|
||||
['data-annonces', 'événement : la zone des annonces à côté de la tête', ['']],
|
||||
['data-apercu', 'import : l’aperçu d’un fichier ou d’un collage', ['']],
|
||||
['data-attente-demonstration', 'accueil : le message d’une démonstration cliquée pendant un chargement', ['']],
|
||||
|
|
@ -34,6 +35,7 @@ const ENTREES = [
|
|||
['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-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],
|
||||
['data-cible-redondance', 'propositions : le champ de la cible de redondance', ['']],
|
||||
|
|
@ -143,7 +145,7 @@ const ENTREES = [
|
|||
['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', ['diagnostic', 'generation', 'import', 'participants', 'plan', 'propositions', 'tables']],
|
||||
['data-section', 'une section de l’événement', ['badges', 'diagnostic', 'generation', 'import', 'participants', 'plan', 'propositions', 'tables']],
|
||||
['data-separateur', 'import : le séparateur nommé', ['']],
|
||||
['data-siege', 'plan : un siège, par sa table et son numéro', null],
|
||||
['data-sieges-retires', 'tables : les sièges retirés', ['']],
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import { flushSync } from 'svelte';
|
|||
import { appliquerCommande } from '../../src/application/commandes.js';
|
||||
import { ErreurAnnulee } from '../../src/application/generation.js';
|
||||
import { texteLivre } from '../../src/demo/livrees.js';
|
||||
import { SECTIONS } from '../../src/interface/evenement/sections.js';
|
||||
import { rechercher } from '../../src/moteur/recherche.js';
|
||||
import { serialiser } from '../../src/stockage/canonique.js';
|
||||
import { analyser, capacite, configurationDepuisCharge } from '../../src/stockage/document.js';
|
||||
|
|
@ -39,8 +40,10 @@ export const BASE = 'soiree';
|
|||
/** Le nom du CSV des cas limites que sert le dialogue d'import. */
|
||||
export const NOM_CAS_LIMITES = 'participants_cas_limites.csv';
|
||||
|
||||
/** Les sections d'un événement, dans l'ordre de la navigation. */
|
||||
export const SECTIONS_EVENEMENT = Object.freeze(['import', 'participants', 'tables', 'plan', 'diagnostic', 'generation', 'propositions']);
|
||||
/** Les sections d'un événement, dans l'ordre de la navigation : celles
|
||||
* que l'application offre, si bien qu'une section ajoutée se visite à
|
||||
* chaque mode sans retouche ici. */
|
||||
export const SECTIONS_EVENEMENT = SECTIONS;
|
||||
|
||||
// L'exécutable publié du système simulé : son dossier data/ refuse
|
||||
// l'écriture, et la question du dossier de travail se pose au démarrage.
|
||||
|
|
|
|||