fix(editor): Knopf sicherte nichts, solange nichts geändert war

Gemeldet: „I took a manual snapshot ... List is empty." Nachgestellt und
bestätigt — schlimmste Bauart: nichts gespeichert, Knopf bestätigte
trotzdem.

Ursache ist der Denkfehler aus D54-Nachtrag 1: `snapshotNow()` vergleicht
bei leerer Liste gegen `snapBase`, den Text beim Öffnen des Dokuments. Für
den Takt richtig (bloßes Ansehen sammelt nichts), für den Knopf falsch. Die
Begründung „bei unverändertem Text steht er schon oben in der Liste" gilt
eben nicht, wenn die Liste leer ist — und das ist genau der Anlass des
Knopfes: öffnen, sichern, DANN ändern. Die frühere Messung begann mit einer
Eingabe und prüfte damit die Wirkung, nicht den Anlass.

- `snapshotNow(manuell)`: Die snapBase-Sperre gilt nur noch für den Takt;
  der Knopf vergleicht allein gegen den letzten Eintrag.
- `loadSnapshot()` sagt jetzt ebenfalls `true` — bewusstes Wegleg-Ereignis.
- `setInterval(() => snapshotNow(), …)`: Ein durchgereichtes Argument wäre
  wahr und hebelte die Sperre aus (dieselbe Falle wie D17-Nachtrag 4).

Nachgemessen — Knopf: 10 Zeilen, Klick ohne jede Eingabe -> [10]; nach einer
Änderung -> [10,11]; ältesten laden -> zurück auf 10, kein Doppel-Eintrag.
Takt (2 s für die Prüfung): nur angesehen, 3 Takte -> 0; nach Änderung -> 1;
weitere Takte -> 1.

D54-Nachtrag 2. 243 Tests grün (die Stände selbst sind nicht test-gedeckt,
sie leben in app.js — Lücke benannt).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-08-24 11:28:49 +02:00
co-authored by Claude Opus 5
parent f7d963809a
commit c4f70ebb24
2 changed files with 64 additions and 5 deletions
+46
View File
@@ -3738,3 +3738,49 @@ trotzdem. Text geändert, Klick → **2**; Menü zeigt beide, neueste zuoberst,
und liegt vollständig im Panel. Im schmalen Layout kostet der Knopf nichts: und liegt vollständig im Panel. Im schmalen Layout kostet der Knopf nichts:
Kopfhöhe 44 px und Zeilenzahl sind mit und ohne ihn identisch, die Breite des Kopfhöhe 44 px und Zeilenzahl sind mit und ohne ihn identisch, die Breite des
Dokument-Namens bleibt bei 54 px. Dokument-Namens bleibt bei 54 px.
**Nachtrag 2 — der Knopf sicherte nichts, wenn noch nichts geändert war.**
Gemeldet: „I took a manual snapshot, and expected it to appear in the list of
snapshots. but it does not appear. List is empty." Nachgestellt und
bestätigt — und es war die schlimmste Bauart eines Fehlers: Es wurde **nichts
gespeichert, der Knopf bestätigte aber trotzdem**. Er hat gelogen.
**Die Ursache ist der Denkfehler des ersten Nachtrags.** `snapshotNow()`
vergleicht bei **leerer** Liste gegen `snapBase` — den Text beim Öffnen des
Dokuments. Für den Takt ist das richtig und der Grund, warum bloßes Ansehen
keine Stände sammelt. Für den Knopf ist es falsch, und die Begründung von oben
(„bei unverändertem Text steht er schon oben in der Liste") trifft genau dann
nicht zu, wenn die Liste leer ist: Dann steht er **nirgends**. Das ist
ausgerechnet der Fall, für den der Knopf gebaut wurde — Dokument öffnen,
zuerst sichern, *dann* die große Änderung. Die Messung des ersten Nachtrags
hat ihn nicht gefunden, weil sie mit einer Eingabe begann; sie prüfte die
Wirkung des Knopfes, nicht seinen Anlass.
**Behoben mit einem Schalter, nicht mit einer zweiten Funktion:**
`snapshotNow(manuell)` lässt die `snapBase`-Sperre nur für den Takt gelten.
Verglichen wird für den Knopf allein gegen den **letzten Eintrag** — der
doppelte Eintrag bleibt vermieden, und die Zusage „dein Stand ist gesichert"
wird in jedem Fall wahr, statt nur meistens.
Zwei Stellen zogen mit. `loadSnapshot()` legt den aktuellen Stand ebenfalls
als **bewusstes** Ereignis weg (in der Sache unverändert — dort ist die Liste
nie leer, sonst gäbe es nichts zu laden — aber jetzt sagt der Aufruf, was er
meint). Und der Takt heißt jetzt `setInterval(() => snapshotNow(), …)`: Ein
durchgereichtes Argument wäre wahr und hebelte genau die Sperre aus, die er
als Einziger braucht — dieselbe Falle, die schon D17-Nachtrag 4 bei
`setAppHeight` benannt hat.
**Nachgemessen**, beide Richtungen getrennt. Knopf (kleines Dokument, Liste
zuvor leer): 10 Zeilen, Klick **ohne jede Eingabe** → Liste `[10]`; eine Zeile
eingefügt, Klick → `[10, 11]`; Menü zeigt beide, neueste zuoberst; ältesten
laden → Text zurück auf 10 Zeilen, kein zusätzlicher Eintrag. Takt (für die
Prüfung auf 2 s verkürzt, danach zurückgestellt und nachgesehen): Dokument nur
angesehen, drei Takte → **0** Stände; nach einer Änderung → 1; weitere Takte
ohne Änderung → weiterhin 1. Die Sperre wirkt also weiter genau dort, wo sie
hingehört.
**Nicht durch Tests gedeckt:** Die Stände leben in `app.js` (localStorage,
DOM), und dafür gibt es keine Testumgebung — dieselbe Lücke wie bei
`applyOptStairs()` (D29) und `drawDepLinks()` (D41). Ein Fehler dieser Art
fällt deshalb erst im Browser auf, und das ist der Preis dafür, dass die
Zustandslogik im UI-Modul sitzt statt in `model.js`.
+18 -5
View File
@@ -2964,12 +2964,22 @@ function dropOldestSnap(){
außen vor: Ihr Text ist schreibgeschützt, ein alter Stand ließe sich dort außen vor: Ihr Text ist schreibgeschützt, ein alter Stand ließe sich dort
gar nicht wieder einsetzen Stände zu sammeln, die niemand laden kann, gar nicht wieder einsetzen Stände zu sammeln, die niemand laden kann,
wäre nur Ballast. */ wäre nur Ballast. */
function snapshotNow(){ /* `manuell` schaltet die `snapBase`-Sperre ab und das ist der ganze
Unterschied zwischen Takt und Knopf. `snapBase` ist der Text beim Öffnen des
Dokuments; solange nichts daran geändert wurde, soll der **Takt** nichts
sammeln (sonst legte jedes bloße Ansehen einen Stand an). Für den Knopf wäre
dieselbe Sperre falsch: vor der großen Änderung sichern" heißt gerade, dass
noch nichts geändert ist. Bei leerer Liste ist der Text dann **nirgends**
gesichert, und der Knopf bestätigte etwas, das nicht stimmte (gemeldet und
nachgestellt, D54-Nachtrag 2). Verglichen wird für ihn nur noch gegen den
**letzten Eintrag** der Doppelte bleibt vermieden, und die Zusage dein
Stand ist gesichert" wird in jedem Fall wahr. */
function snapshotNow(manuell){
const d = activeDoc(); const d = activeDoc();
if(!d || src.readOnly) return false; if(!d || src.readOnly) return false;
const text = src.value; const text = src.value;
const list = snaps[d.id] || (snaps[d.id] = []); const list = snaps[d.id] || (snaps[d.id] = []);
const letzter = list.length ? list[list.length-1].text : snapBase; const letzter = list.length ? list[list.length-1].text : (manuell ? null : snapBase);
if(text === letzter) return false; if(text === letzter) return false;
list.push({t: Date.now(), text}); list.push({t: Date.now(), text});
while(list.length > SNAP_KEEP) list.shift(); while(list.length > SNAP_KEEP) list.shift();
@@ -2978,7 +2988,10 @@ function snapshotNow(){
if(!snapMenu.hidden) renderSnapMenu(); if(!snapMenu.hidden) renderSnapMenu();
return true; return true;
} }
setInterval(snapshotNow, SNAP_EVERY); /* `() => snapshotNow()`, nicht `snapshotNow` direkt: Ein durchgereichtes
Argument wäre wahr und hebelte die `snapBase`-Sperre des Takts aus
dieselbe Falle wie bei `setAppHeight` (D17-Nachtrag 4). */
setInterval(() => snapshotNow(), SNAP_EVERY);
const snapBtn = document.getElementById('snapBtn'); const snapBtn = document.getElementById('snapBtn');
const snapAddBtn = document.getElementById('snapAddBtn'); const snapAddBtn = document.getElementById('snapAddBtn');
@@ -3023,7 +3036,7 @@ function renderSnapMenu(){
nicht drin ist sonst wäre er das Einzige, was der Griff verlöre. */ nicht drin ist sonst wäre er das Einzige, was der Griff verlöre. */
function loadSnapshot(s){ function loadSnapshot(s){
if(src.readOnly) return; if(src.readOnly) return;
snapshotNow(); snapshotNow(true); /* bewusstes Wegleg-Ereignis wie der Knopf, nicht der Takt */
closeSnapMenu(); closeSnapMenu();
if(!replaceTextUndoable(s.text)) render(); if(!replaceTextUndoable(s.text)) render();
snapBase = src.value; snapBase = src.value;
@@ -3054,7 +3067,7 @@ snapBtn.addEventListener('click', e => {
sieht man den neuen Eintrag entstehen. */ sieht man den neuen Eintrag entstehen. */
snapAddBtn.addEventListener('click', e => { snapAddBtn.addEventListener('click', e => {
e.stopPropagation(); e.stopPropagation();
snapshotNow(); snapshotNow(true);
flashBtn(snapAddBtn); flashBtn(snapAddBtn);
}); });
document.addEventListener('click', e => { document.addEventListener('click', e => {