[FIX] session: undo never moves id counters back

Undoing back before a generation restored the whole charge, its
prochainsIds included: the next generation numbered its propositions 1 to 3
again, and a label of the abandoned thread such as "retained proposition 2"
named another proposition. § 8.9 forbids it: a counter never goes back. A
retour now restores the content of the instant and keeps each counter at
the larger of its restored and current value, so the next id is fresh.
Checked: 1416 node tests; the retour test is red on the previous seance.js.

--- FR ---

[FIX] séance : un retour ne fait jamais reculer les compteurs

Défaire jusqu'avant une génération restituait la charge entière, prochainsIds
compris : la génération suivante renumérotait ses propositions de 1 à 3, et
un libellé du fil abandonné, comme « retenu la proposition 2 », désignait
une autre proposition. Le § 8.9 l'interdit : un compteur ne recule jamais.
Un retour restitue désormais le contenu de l'instant et porte chaque
compteur au plus grand de sa valeur restituée et de sa valeur courante.
Vérifié : 1416 épreuves node ; l'épreuve du retour est rouge sur l'ancien code.

Assisted-by: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01EUXSGcwCLSC69FWEdCtWSb
This commit is contained in:
Mathieu Benoit 2026-10-06 23:40:40 -04:00
parent 481f6a6bae
commit a1cac443bb
2 changed files with 25 additions and 3 deletions

View file

@ -416,6 +416,19 @@ export function creerSeance({
}
}
// La charge restituée, ses compteurs d'identifiants portés au plus grand
// de leur valeur restituée et de leur valeur courante : un retour rend le
// contenu d'un instant, jamais un identifiant déjà donné (§ 8.9) — une
// génération après un défaire numérote au-delà des propositions défaites,
// et le retenu d'un fil abandonné garde la proposition qu'il désignait.
function avecCompteursCourants(charge, courante) {
const prochainsIds = {};
for (const cle of ['participant', 'table', 'proposition']) {
prochainsIds[cle] = Math.max(charge.prochainsIds[cle], courante.prochainsIds[cle]);
}
return { ...charge, prochainsIds };
}
// Un retour, sans son suivi : défaire vise le prédécesseur de l'instant
// courant sur le fil, refaire le sommet de sa pile, revenir l'instant
// donné ; l'entrée porte la charge restituée, son sens et sa cible.
@ -431,7 +444,7 @@ export function creerSeance({
if (cibleRefaire === null) throw new ErreurSeance('RIEN_A_REFAIRE');
cible = cibleRefaire;
}
const charge = restituer(ouvert, cible);
const charge = avecCompteursCourants(restituer(ouvert, cible), ouvert.charge);
if (serialiserCharge(charge) === serialiserCharge(ouvert.charge)) throw new ErreurSeance('SANS_EFFET', { sens });
const instant = sens === 'defaire' ? courant : libelleDeRevision(ouvert.journal, cible);
const entree = { charge, libelle: libelleDuRetour(sens, instant), avertissements: [] };

View file

@ -641,8 +641,17 @@ describe('séance : revenir, défaire, refaire (§ 8.3)', () => {
[7, 'revenir', 2, 'Retour à « Ajouté Ancolie »'],
],
);
assert.deepEqual(etatDe(fs).charge, reconstruire(journal, 2));
assert.deepEqual(reconstruire(journal, 7), reconstruire(journal, 2));
// L'instant 2 revient avec son contenu, et ses compteurs restent où trois
// ajouts les ont portés : aucun identifiant donné ne se redonne (§ 8.9).
const instant2 = reconstruire(journal, 2);
const attendu = { ...instant2, prochainsIds: { ...instant2.prochainsIds, participant: 4 } };
assert.deepEqual(etatDe(fs).charge, attendu);
assert.deepEqual(reconstruire(journal, 7), attendu);
await seance.executer('ajouterParticipant', { champs: { nom: 'Digitale' } });
assert.deepEqual(
seance.charge.participants.map(({ id, nom }) => [id, nom]),
[[1, 'Ancolie'], [4, 'Digitale']],
);
});
test("refaire n'existe qu'après des retours, tant qu'aucun geste ne les suit", async () => {