[FIX] event: page never scrolls, broken-support remedies always shown

At 1366 x 700 an open event that shows warnings no longer scrolls the page:
the warnings scroll within their zone and the plan keeps at least 75 % of
its height. A broken support never shrinks, so its three remedies stay in
view, the only way to write the event elsewhere (spec 8.6). Tests now
prove the working-folder path and duplicate nodes are visible, the guard
of « Remplacer » in import, and which header a refused re-alignment shows.
Checked: 2692 node, 499 browser tests, from the index alone.

--- FR ---

[FIX] événement : sans défilement, remèdes du support rompu visibles

À 1366 x 700, un événement ouvert qui montre des avertissements ne fait
plus défiler la page : les avertissements défilent dans leur zone et le
plan garde au moins 75 % de sa hauteur. Un support rompu ne rétrécit
jamais : ses trois remèdes, seul moyen d'écrire l'événement ailleurs
(§ 8.6), restent visibles. Les épreuves prouvent que le chemin et les
doublons du dossier sont visibles, la garde de « Remplacer » à l'import, et
l'en-tête que montre un réalignement refusé. Vérifié : 2692 node, 499.

Assisted-by: Claude Opus 5.5
This commit is contained in:
Mathieu Benoit 2026-10-07 15:05:59 -04:00
parent a2812cddb9
commit 8186b06713
8 changed files with 245 additions and 18 deletions

View file

@ -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);
}
});

View file

@ -74,7 +74,6 @@
display: flex;
flex-direction: column;
gap: var(--espace-2);
margin-bottom: var(--espace-3);
}
.avertissement {

View file

@ -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()}
</div>
{/if}
{#if annonces !== null}
<div class="annonces" class:rompu={$seance.supportRompu !== null} data-annonces>
{@render annonces()}
</div>
{/if}
<div class="surface">
{@render children()}
</div>
@ -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);

View file

@ -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' });

View file

@ -18,6 +18,12 @@
// globale ci-dessous donne pour cela une hauteur à l'ascendance de l'écran
// — le conteneur de l'application et son <main> — 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);
</script>
{#snippet tete()}
@ -65,12 +75,15 @@
</nav>
{/snippet}
{#snippet annonces()}
{#if $seance.supportRompu !== null}
<SupportRompu />
{/if}
<Avertissements />
{/snippet}
<div class="evenement" data-ecran="evenement">
<CadreMode tete={$ui.pleinEcran ? null : tete}>
{#if $seance.supportRompu !== null}
<SupportRompu />
{/if}
<Avertissements />
<CadreMode tete={$ui.pleinEcran ? null : tete} annonces={annoncer ? annonces : null}>
<Section />
</CadreMode>
</div>

View file

@ -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);

View file

@ -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)', () => {

View file

@ -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);