diff --git a/src/interface/accueil/Accueil.navigateur.test.js b/src/interface/accueil/Accueil.navigateur.test.js index ffafc6e..909a047 100644 --- a/src/interface/accueil/Accueil.navigateur.test.js +++ b/src/interface/accueil/Accueil.navigateur.test.js @@ -460,7 +460,11 @@ describe('signalements et récupération (§ 8.1, § 8.8)', () => { [b, 'Merles A'], ]) { expect(element.textContent).toContain(t('accueil.signalement.doublon', { autres: 1 })); - expect(element.querySelector(`[data-doublon="${CSS.escape(autre)}"]`)?.textContent).toBe(autre); + const doublon = element.querySelector(`[data-doublon="${CSS.escape(autre)}"]`); + expect(doublon?.textContent).toBe(autre); + // La base nommée se lit à l'écran, pas seulement dans l'arbre. + expect(visible(doublon), autre).toBe(true); + expect(atteint(doublon), autre).toBe(true); const noeuds = noeudsDeTexte(element).filter((texte) => texte.includes('Merles')); expect(noeuds).toEqual([autre]); } @@ -678,7 +682,11 @@ describe('le dossier de travail (§ 8.5, § 8.6)', () => { const cheminAutre = `${SOUS_APPDATA}\\data`; expect(proposition.textContent).toContain(t('accueil.autre.proposition', { evenements: 2 })); // Le chemin est une donnée : son propre nœud de texte, à part de la phrase. - expect(dans(m, '[data-autre-chemin]')?.textContent).toBe(cheminAutre); + const chemin = dans(m, '[data-autre-chemin]'); + expect(chemin?.textContent).toBe(cheminAutre); + // Le chemin se lit à l'écran, pas seulement dans l'arbre. + expect(visible(chemin)).toBe(true); + expect(atteint(chemin)).toBe(true); expect(noeudsDeTexte(proposition).filter((texte) => texte.includes('\\'))).toEqual([cheminAutre]); expect(proposition.textContent).toContain(t('accueil.autre.originaux')); // En tête : avant la création, les démonstrations et toute ligne. @@ -802,6 +810,10 @@ describe('les opérations de fichiers (§ 8.7)', () => { ]) { expect(signalement(base).textContent).toContain(t('accueil.signalement.doublon', { autres: 1 })); expect(noeudsDeTexte(signalement(base)).filter((texte) => texte.includes('Gala'))).toEqual([autre]); + const doublon = signalement(base).querySelector(`[data-doublon="${CSS.escape(autre)}"]`); + expect(doublon?.textContent).toBe(autre); + expect(visible(doublon), autre).toBe(true); + expect(atteint(doublon), autre).toBe(true); } }); diff --git a/src/interface/evenement/Avertissements.svelte b/src/interface/evenement/Avertissements.svelte index fce3370..72a62ea 100644 --- a/src/interface/evenement/Avertissements.svelte +++ b/src/interface/evenement/Avertissements.svelte @@ -74,7 +74,6 @@ display: flex; flex-direction: column; gap: var(--espace-2); - margin-bottom: var(--espace-3); } .avertissement { diff --git a/src/interface/evenement/CadreMode.svelte b/src/interface/evenement/CadreMode.svelte index d4f6bfa..4ff3533 100644 --- a/src/interface/evenement/CadreMode.svelte +++ b/src/interface/evenement/CadreMode.svelte @@ -8,10 +8,16 @@ // à droite, la place fixe, le libellé de mode, puis « Défaire » et // « Refaire » (§ 8.3). Sous la barre vient la tête, tete, quand elle est // donnée — l'en-tête de l'événement et la navigation des sections —, puis - // la surface, children, qui défile seule. La barre ouvre le cadre : rien - // ne la précède dans l'écran de l'événement, si bien qu'un refus posé dans - // la tête, ou un nom qui s'étend sur plusieurs lignes, allonge la tête - // sans déplacer la place fixe (§ 8.4). + // les annonces, annonces, quand il y en a — support rompu et + // avertissements —, puis la surface, children. La barre ouvre le cadre : + // rien ne la précède dans l'écran de l'événement, si bien qu'un refus posé + // dans la tête, ou un nom qui s'étend sur plusieurs lignes, allonge la tête + // sans déplacer la place fixe (§ 8.4). Tête, annonces et surface défilent + // chacune dans sa zone : des avertissements nombreux ne poussent pas la + // surface hors du cadre, ni le cadre hors de la fenêtre. La surface porte + // le plan, que l'opérateur regarde : elle réclame les trois quarts du + // cadre avant que les annonces ne dépassent leur première ligne, sauf + // devant un support rompu, dont les issues restent en vue. // // La place fixe porte la commande que rend commandeFixe (modele.js) : // « Modifier », « Débloquer » — confirmé en place, par la Commande d'I2 —, @@ -29,7 +35,7 @@ import Refus from '../primitives/Refus.svelte'; import { cadreDeMode, commandeFixe, repriseOfferte } from './modele.js'; - let { children, tete = null } = $props(); + let { children, tete = null, annonces = null } = $props(); const t = getContext('t'); const seance = getContext('seance'); @@ -103,6 +109,11 @@ {@render tete()} {/if} + {#if annonces !== null} +
+ {@render annonces()} +
+ {/if}
{@render children()}
@@ -204,8 +215,37 @@ border-bottom: 1px solid var(--couleur-page-filet); } + /* Les annonces ne prennent jamais plus du quart du cadre, et cèdent + d'abord leur hauteur à la surface : leur facteur de rétrécissement, + mille fois celui de la surface, leur fait absorber presque tout le + manque de place, jusqu'à leur hauteur minimale : un avertissement + d'une ligne en entier, et le haut du suivant, qui dit que la zone en + porte d'autres. Au-delà de leur hauteur, elles défilent dans leur + zone. */ + .annonces { + display: flex; + flex: 0 1000 auto; + flex-direction: column; + gap: var(--espace-2); + min-height: 3rem; + max-height: 25%; + overflow-y: auto; + padding: var(--espace-2) var(--espace-3); + border-bottom: 1px solid var(--couleur-page-filet); + } + + /* Le support rompu, en tête des annonces, porte les seules issues de + l'opérateur : tant qu'il est là, les annonces ne cèdent rien à la + surface, et gardent leur hauteur jusqu'au quart du cadre ; les + avertissements qui le suivent défilent sous lui. */ + .annonces.rompu { + flex-shrink: 0; + } + + /* La surface part des trois quarts du cadre, s'étend à ce qui reste, et + ne rétrécit, après les annonces, que pour la barre et la tête. */ .surface { - flex: 1 1 auto; + flex: 1 1 75%; min-height: 0; overflow: auto; padding: var(--espace-3); diff --git a/src/interface/evenement/Evenement.navigateur.test.js b/src/interface/evenement/Evenement.navigateur.test.js index 3be5992..84b594d 100644 --- a/src/interface/evenement/Evenement.navigateur.test.js +++ b/src/interface/evenement/Evenement.navigateur.test.js @@ -30,7 +30,7 @@ import { FORMAT, analyser, configurationDepuisCharge, etatDeduit } from '../../s import { lireJournal } from '../../stockage/journal.js'; import { SUFFIXES } from '../../stockage/noms.js'; import { VERSION } from '../../version.genere.js'; -import { fixerCadre } from '../../../test/navigateur/entrees.js'; +import { fixerCadre, molette } from '../../../test/navigateur/entrees.js'; import { monterApp } from '../../../test/navigateur/montage.js'; import { visible } from '../../../test/navigateur/rendu.js'; import { TABLES_INTERFACE } from '../libelles.js'; @@ -230,6 +230,11 @@ describe('un nom long ou de glyphes larges (point d’attention 3)', () => { expect(titre.textContent).toBe(nom); expect(document.documentElement.scrollWidth, motif).toBeLessThanOrEqual(window.innerWidth); expect(titre.getBoundingClientRect().right, motif).toBeLessThanOrEqual(window.innerWidth); + // Ni le titre ni la tête qui le porte ne débordent : la tête défile en + // hauteur, et un nom qui ne passerait pas à la ligne s'étendrait en + // largeur sous elle sans élargir la page. + const tete = titre.closest('header').parentElement; + expect([titre.scrollWidth, tete.scrollWidth], motif).toEqual([titre.clientWidth, tete.clientWidth]); expect(coin(fixe(m, 'modifier')), motif).toEqual(place); expect(visible(fixe(m, 'modifier')), motif).toBe(true); await m.seance.fermer(); @@ -578,6 +583,125 @@ describe('les annonces d’ouverture (§ 8.9, § 9)', () => { }); }); +describe('l’écran tient dans la fenêtre, avertissements compris (§ 8.4)', () => { + // Ferme l'événement garni et généré de base, l'abîme, puis le rouvre : une + // proposition de longueur fausse, une périmée, et une entrée du journal + // abîmée lui donnent trois avertissements à l'ouverture, au moins. + async function rouvrirAbime(m, base) { + await m.seance.fermer(); + reecrireEtat(m, base, (charge) => { + charge.propositions[0].placement.pop(); + charge.participants[0].exclu = true; + }); + const chemin = `${base}${SUFFIXES.journal}`; + const lignes = m.fs.contenu('documents', chemin).split('\n'); + const rang = lignes.findIndex((ligne) => ligne.startsWith('{"type":"entree"') && JSON.parse(ligne).revision === 4); + expect(rang).toBeGreaterThan(0); + lignes[rang] = '{"type":"entree", abîmée'; + m.fs.deposer('documents', chemin, lignes.join('\n')); + await m.seance.ouvrir(base); + flushSync(); + await new Promise((resoudre) => requestAnimationFrame(resoudre)); + } + + test('à 1 366 × 700, trois avertissements et plus : la page ne défile pas, le plan garde sa hauteur ; les avertissements défilent à la molette, sans rien déplacer', async () => { + const m = await neuf(); + await garnir(m); + await generer(m, { retenir: false }); + const base = baseDe(m); + // La hauteur du dessin du plan sans aucune annonce : la mesure de + // référence de ce que les avertissements peuvent lui prendre. + m.ui.allerA('plan'); + flushSync(); + expect(dans(m, '[data-annonces]')).toBeNull(); + const hauteurSeule = dans(m, '[data-plan]').getBoundingClientRect().height; + expect(hauteurSeule).toBeGreaterThan(0); + await rouvrirAbime(m, base); + expect(m.ui.etat().section).toBe('plan'); + const montres = tous(m, '[data-avertissement]'); + expect(montres.length).toBeGreaterThanOrEqual(3); + const boite = (element) => element.getBoundingClientRect().toJSON(); + // La page ne défile pas : le cadre tient dans la fenêtre, et la section + // du plan dans le cadre, sans que la surface qui la porte ait à défiler. + expect(document.documentElement.scrollHeight).toBeLessThanOrEqual(innerHeight); + expect(cadre(m).getBoundingClientRect().bottom).toBeLessThanOrEqual(innerHeight); + const section = dans(m, '[data-section]'); + const porteur = section.parentElement; + expect(section.getBoundingClientRect().bottom).toBeLessThanOrEqual(porteur.getBoundingClientRect().bottom); + expect(porteur.scrollHeight).toBeLessThanOrEqual(porteur.clientHeight); + expect(visible(dans(m, '[data-plan]'))).toBe(true); + // Le plan reste la surface de travail : les annonces ne prennent pas + // plus du quart de la hauteur de son dessin. + expect(dans(m, '[data-plan]').getBoundingClientRect().height).toBeGreaterThanOrEqual(hauteurSeule * 0.75); + // Les avertissements débordent leur zone, qui défile seule, à la + // molette, comme sous la main de l'opérateur : le dernier, amené dans la + // zone, se voit, et ni la page, ni la place fixe, ni la section n'ont + // bougé. + const zone = montres[0].closest('[data-annonces]'); + expect(zone).not.toBeNull(); + for (const montre of montres) expect(zone.contains(montre)).toBe(true); + expect(zone.scrollHeight).toBeGreaterThan(zone.clientHeight); + expect(['auto', 'scroll']).toContain(getComputedStyle(zone).overflowY); + expect(visible(montres[0])).toBe(true); + const place = coin(fixe(m, 'modifier')); + const avant = boite(section); + const surZone = zone.getBoundingClientRect(); + await molette(surZone.left + surZone.width / 2, surZone.top + surZone.height / 2, zone.scrollHeight); + await attendre(() => expect(visible(montres.at(-1))).toBe(true)); + expect(zone.scrollTop).toBeGreaterThan(0); + expect(window.scrollY).toBe(0); + expect(coin(fixe(m, 'modifier'))).toEqual(place); + expect(boite(section)).toEqual(avant); + // Sur chaque section, la page reste sans défilement, et la zone garde + // sa boîte. + const zoneAvant = boite(zone); + for (const nom of SECTIONS) { + m.ui.allerA(nom); + flushSync(); + expect(document.documentElement.scrollHeight, nom).toBeLessThanOrEqual(innerHeight); + expect(boite(dans(m, '[data-annonces]')), nom).toEqual(zoneAvant); + } + }); + + test('à 1 366 × 700, un support rompu et trois avertissements et plus : chaque issue du support rompu se voit sans défiler, la page ne défile pas, et les avertissements défilent sous lui', async () => { + const m = await neuf(); + await garnir(m); + await generer(m, { retenir: false }); + await rouvrirAbime(m, baseDe(m)); + expect(tous(m, '[data-avertissement]').length).toBeGreaterThanOrEqual(3); + fixe(m, 'modifier').click(); + await attendre(() => expect(m.seance.etat().mode).toBe('ecriture')); + m.fs.pannes.ecriture('documents'); + await expect(m.seance.executer('ajouterParticipant', { champs: { nom: NOMS[0] } })).rejects.toMatchObject({ code: 'ECRITURE' }); + flushSync(); + await new Promise((resoudre) => requestAnimationFrame(resoudre)); + expect(m.seance.etat().supportRompu).not.toBeNull(); + const montres = tous(m, '[data-avertissement]'); + expect(montres.length).toBeGreaterThanOrEqual(3); + expect(document.documentElement.scrollHeight).toBeLessThanOrEqual(innerHeight); + expect(cadre(m).getBoundingClientRect().bottom).toBeLessThanOrEqual(innerHeight); + // Les issues du support rompu, seule sortie de l'opérateur, se voient + // sans que rien ne défile. + const message = dans(m, '[data-support-rompu]'); + for (const nom of ['choisirDossier', 'abandonner']) { + const issue = message.querySelector(`[data-commande="${nom}"]`); + expect(issue, nom).not.toBeNull(); + expect(visible(issue), nom).toBe(true); + } + expect(visible(fixe(m, 'lecture'))).toBe(true); + expect(visible(dans(m, '[data-plan]'))).toBe(true); + // Les avertissements restent dans la zone, qui défile seule : le + // dernier, amené à la molette, se voit, et la page n'a pas bougé. + const zone = message.closest('[data-annonces]'); + expect(zone).not.toBeNull(); + for (const montre of montres) expect(zone.contains(montre)).toBe(true); + const surZone = zone.getBoundingClientRect(); + await molette(surZone.left + surZone.width / 2, surZone.top + surZone.height / 2, zone.scrollHeight); + await attendre(() => expect(visible(montres.at(-1))).toBe(true)); + expect(window.scrollY).toBe(0); + }); +}); + describe('le journal (§ 8.6, § 8.2)', () => { test('cent entrées, la 37ᵉ abîmée : JOURNAL_ECARTEES {n: 64} s’affiche, son texte porte « 64 »', async () => { const m = await neuf(); @@ -742,6 +866,14 @@ describe('le support rompu (§ 8.6, § 8.2)', () => { const message = (m) => dans(m, '[data-support-rompu]'); const commandeListe = (m) => dans(m, '[data-bandeau] [data-commande="liste"]'); + // Le message offre exactement les issues noms, et chacune se voit au cadre + // de l'épreuve : c'est la seule sortie de l'opérateur d'un support rompu. + function issuesVisibles(m, noms) { + const offertes = [...message(m).querySelectorAll('[data-commande]')].map((issue) => issue.dataset.commande); + expect(offertes).toEqual(noms); + for (const nom of noms) expect(visible(message(m).querySelector(`[data-commande="${nom}"]`)), nom).toBe(true); + } + // L'événement ouvert en écriture, puis le support de racine refuse // l'écriture d'un geste. async function rompre(m, racine) { @@ -776,7 +908,7 @@ describe('le support rompu (§ 8.6, § 8.2)', () => { expect(visible(fixe(m, 'lecture'))).toBe(true); expect(visible(dans(m, '[data-refus="liste"]'))).toBe(true); expect(m.seance.etat().evenement.base).toBe(base); - expect(message(m).querySelector('[data-commande="choisirDossier"]')).not.toBeNull(); + issuesVisibles(m, ['ecrireDocuments', 'choisirDossier', 'abandonner']); message(m).querySelector('[data-commande="ecrireDocuments"]').click(); await attendre(() => expect(m.seance.etat().supportRompu).toBeNull()); const nouvelle = baseDe(m); @@ -798,7 +930,7 @@ describe('le support rompu (§ 8.6, § 8.2)', () => { const m = await neuf(); await rompre(m, 'documents'); expect(m.seance.etat().supportRompu.ailleurs).toBeNull(); - expect(message(m).querySelector('[data-commande="ecrireDocuments"]')).toBeNull(); + issuesVisibles(m, ['choisirDossier', 'abandonner']); const choisir = message(m).querySelector('[data-commande="choisirDossier"]'); expect(choisir.textContent).toBe(t('communs.commande.choisirDossier')); choisir.click(); @@ -810,6 +942,7 @@ describe('le support rompu (§ 8.6, § 8.2)', () => { test('non publié, « Fermer sans écrire ailleurs », confirmé en place : la liste revient, sans aucune écriture', async () => { const m = await neuf(); await rompre(m, 'documents'); + issuesVisibles(m, ['choisirDossier', 'abandonner']); const avant = m.trace.length; const abandonner = () => message(m).querySelector('[data-commande="abandonner"]'); expect(abandonner().textContent).toBe(t('evenement.supportRompu.abandonner')); @@ -826,6 +959,7 @@ describe('le support rompu (§ 8.6, § 8.2)', () => { test('non publié, un dossier choisi : état et journal y sont écrits, le geste suivant aussi, et la liste le suit', async () => { const m = await neuf({ systeme: { dossiersChoisis: ['D:\\Secours'] } }); await rompre(m, 'documents'); + issuesVisibles(m, ['choisirDossier', 'abandonner']); message(m).querySelector('[data-commande="choisirDossier"]').click(); await attendre(() => expect(m.seance.etat().supportRompu).toBeNull()); expect(m.seance.etat().racine).toEqual({ id: 'choisi-1', chemin: 'D:\\Secours' }); diff --git a/src/interface/evenement/Evenement.svelte b/src/interface/evenement/Evenement.svelte index e692746..649e031 100644 --- a/src/interface/evenement/Evenement.svelte +++ b/src/interface/evenement/Evenement.svelte @@ -18,6 +18,12 @@ // globale ci-dessous donne pour cela une hauteur à l'ascendance de l'écran // — le conteneur de l'application et son
— tant qu'il porte cet // écran, et à lui seul. + // + // Le support rompu et les avertissements se posent dans la zone des + // annonces du cadre, qui défile seule, et non au-dessus de la section : la + // section garde la hauteur de la surface, plan compris, quel que soit le + // 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 Diagnostic from '../diagnostic/Diagnostic.svelte'; import SectionGeneration from '../generation/SectionGeneration.svelte'; @@ -30,6 +36,7 @@ import CadreMode from './CadreMode.svelte'; import EnteteEvenement from './EnteteEvenement.svelte'; import SupportRompu from './SupportRompu.svelte'; + import { avertissementsOrdonnes } from './modele.js'; import { SECTIONS, cleDeSection } from './sections.js'; const t = getContext('t'); @@ -48,6 +55,9 @@ ]); const Section = $derived(COMPOSANTS.get($ui.section)); + // La zone des annonces n'existe que lorsqu'elle a quelque chose à dire : + // sans annonce, le cadre ne lui garde ni hauteur ni trait. + const annoncer = $derived($seance.supportRompu !== null || avertissementsOrdonnes($seance).length > 0); {#snippet tete()} @@ -65,12 +75,15 @@ {/snippet} +{#snippet annonces()} + {#if $seance.supportRompu !== null} + + {/if} + +{/snippet} +
- - {#if $seance.supportRompu !== null} - - {/if} - +
diff --git a/src/interface/evenement/SupportRompu.svelte b/src/interface/evenement/SupportRompu.svelte index 2e1c4b1..14a1ce4 100644 --- a/src/interface/evenement/SupportRompu.svelte +++ b/src/interface/evenement/SupportRompu.svelte @@ -52,7 +52,6 @@ flex-direction: column; align-items: flex-start; gap: var(--espace-2); - margin-bottom: var(--espace-3); padding: var(--espace-2) var(--espace-3); border-left: 3px solid var(--couleur-refus); color: var(--couleur-refus); diff --git a/src/interface/import/Import.navigateur.test.js b/src/interface/import/Import.navigateur.test.js index 227b41a..884c3e4 100644 --- a/src/interface/import/Import.navigateur.test.js +++ b/src/interface/import/Import.navigateur.test.js @@ -731,6 +731,30 @@ describe('importer selon l’état (§ 10.1, § 9)', () => { expect(pose.querySelector('[data-commande="modifier"]').textContent).toBe(t('communs.remede.modifier')); expect(ecritures(e)).toBe(avant); }); + + test('sous « Remplacer », bloqué puis en lecture : le refus à côté de la commande, avec son remède, aucune fenêtre, rien d’écrit', async () => { + const cas = [ + ['LECTURE_SEULE', 'debloquer', { prepare: (seance) => seance.executer('changerEtat', { etat: 'bloque' }) }], + ['MODE_LECTURE', 'modifier', { ecriture: false }], + ]; + for (const [code, remede, options] of cas) { + const e = await ouvrir({ participants: QUATRE(), ...options }); + expect(e.m.seance.etat().refus?.code, code).toBe(code); + const avant = ecritures(e); + await choisir(e, 'tardif.csv', enUtf8(FICHIER)); + choisirMode(e, 'remplacer'); + commande(e, 'importer').click(); + await vi.waitFor(() => expect(dans(e, `[data-refus="${code}"]`), code).not.toBeNull()); + const pose = dans(e, `[data-refus="${code}"]`); + expect(commande(e, 'importer').parentElement.contains(pose), code).toBe(true); + expect(pose.querySelector(`[data-commande="${remede}"]`)?.textContent, code).toBe(t(`communs.remede.${remede}`)); + expect(vu(pose), code).toBe(true); + expect(dans(e, 'dialog'), code).toBeNull(); + expect(imports(e), code).toEqual([]); + expect(ecritures(e), code).toBe(avant); + expect(participantsDe(e).length, code).toBe(4); + } + }); }); describe('le collage en bloc passe par le même analyseur (§ 10.2, § 14.10)', () => { diff --git a/src/interface/tables/Tables.navigateur.test.js b/src/interface/tables/Tables.navigateur.test.js index 3daee75..cf28857 100644 --- a/src/interface/tables/Tables.navigateur.test.js +++ b/src/interface/tables/Tables.navigateur.test.js @@ -354,6 +354,12 @@ describe('changer le défaut (§ 6.1, § 6.2, § 19.3 06)', () => { expect(fenetre()).toBeNull(); const refus = enonce(m).querySelector('[data-refus-realigner]'); expect(refus).not.toBeNull(); + // L'en-tête est celui du réalignement refusé, qui laisse « Appliquer » + // offert, et non celui d'un défaut que rien n'applique. + const tete = refus.querySelector('[data-phrase]'); + expect(tete?.textContent).toBe(t('tables.defaut.realignementRefuse', { avant: 8, apres: 10 })); + expect(visible(tete)).toBe(true); + expect(refus.textContent).not.toContain(t('tables.defaut.refusee', { avant: 8, apres: 10 })); expect(refus.textContent).toContain('Table 2'); expect(refus.textContent).toContain('11 réservations'); expect(ecritures(m)).toBe(avant);