diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index aad21ec..5a6943d 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -3784,3 +3784,61 @@ 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`. + +**Nachtrag 3 — die Regeln ziehen nach `snapshots.js` um, damit sie prüfbar +sind.** Nachtrag 2 endete mit dem Satz, die Stände seien „nicht durch Tests +gedeckt … das ist der Preis dafür, dass die Zustandslogik im UI-Modul sitzt". +Der Preis war zu hoch: Der Fehler kam bis in Produktion, und ein Test hätte +ihn in einer Zeile gefunden. Also wird nicht der Preis bezahlt, sondern die +Ursache beseitigt. + +**Geschnitten wird nach dem Vorbild von `remote.js` (D31): die entscheidbare +Hälfte heraus, die I/O bleibt.** `snapshots.js` beantwortet, **was gilt** — +wann ein Stand entsteht (`addSnapshot`), was bei Platzmangel zuerst fliegt +(`dropOldestSnap`, `persistSnaps`), was aus dem Speicher überhaupt gelesen +werden darf (`parseSnaps`) und wie ein Eintrag heißt (`snapLabel`). In +`app.js` bleibt, **woher die Werte kommen** (aktives Dokument, Schreibschutz, +Textfeld) und **wohin sie gehen** (`localStorage`, Menü). Der Speicher wird +als `{setItem, removeItem}` hereingereicht, die Uhr als Zahl — genau die +beiden Abhängigkeiten, an denen die Prüfbarkeit vorher scheiterte. `app.js` +verliert dabei 55 Zeilen; `snapshotNow()` schrumpft auf sechs. + +**Kein Store-Objekt, keine Klasse:** Die Nachbarmodule sind schlichte +Funktionsmodule, und `snaps` als Parameter durchzureichen kostet nichts. Wo +das Original den Zustand verändert hat, tut es das weiter (`addSnapshot` +hängt an die Liste an) — das ist im Test genauso ablesbar und hält den Diff +klein. + +**Die Gegenprobe zählt, nicht die Zahl der Tests.** Baut man exakt den +ausgelieferten Fehler wieder ein (`manual ? null : base` → `base`), fällt +**genau eine** Zusicherung — die, die nach ihm benannt ist —, und die +übrigen 27 bleiben grün. Ein Test, von dem man das nicht geprüft hat, ist nur +eine Behauptung. + +28 Tests decken jetzt ab: Knopf gegen Takt (beide Richtungen, inklusive des +gemeldeten Falls), kein Doppel-Eintrag, Deckelung bei 20, dokumentübergreifende +Verdrängung des Ältesten, das Aufgeben bei vollem Speicher samt Wegräumen des +Schlüssels, sechs Formen beschädigten Speichers und die Beschriftung (heute +nur Uhrzeit, sonst mit Datum, Kalendertag statt 24 Stunden, Rückfall bei +unbekannter Sprache). + +**Zwei Verhaltensänderungen, beide bewusst.** `parseSnaps()` wirft Einträge +weg, die nicht die erwartete Form haben (Liste kein Array, `t` keine Zahl, +`text` kein String) — vorher wären sie stehen geblieben und hätten beim Lesen +zugeschlagen. Und ein Array statt eines Objekts im Speicher ergibt jetzt `{}` +statt eines halb benutzbaren Zustands. Ein Sicherheitsnetz darf die App nicht +umbringen. + +**Was Unit-Tests weiterhin nicht abdecken, ist die Verdrahtung** — dass +`app.js` `base` und `manual` richtig durchreicht. Dafür bleibt die Messung im +Browser, und sie ist nach dem Umbau wiederholt worden: Knopf ohne jede +Eingabe → ein Stand; Takt (für die Prüfung auf 2 s) bei bloß angesehenem +Dokument → **0**, nach einer Änderung → 1, weitere Takte → 1. + +**Nebenbefund, als Werkzeugfalle notiert:** Die Konsole des Browser-Werkzeugs +puffert kumulativ — `console.clear()` und ein Neuladen räumen sie nicht. Eine +`ReferenceError`-Meldung aus einer HMR-Zwischenfassung stand deshalb noch da, +als der Fehler längst weg war. Auseinandergehalten hat es der `?t=`-Stempel +im Stacktrace gegen den der geladenen Datei (`performance.getEntriesByType`): +`…961240` gegen `…049984`. Dieselbe Sorte Lehre wie D25 und D17-Nachtrag 4 — +die Meldung des Werkzeugs ist noch kein Befund. diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index c43a392..9d9e3c4 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -66,6 +66,16 @@ verworfene Elemente. Quelle sind ES-Module unter `src/`; `index.html` ist der `app.js` (DOM/Events/i18n/Persistenz/Export). Modell/Renderer bekommen UI-State (verworfene einblenden, Pfad an/aus) als **Parameter** — keine Globals; nur `cheapPathOn` lebt als UI-State in `app.js`. Tests: `tests/*.test.js`. +- **Was entscheidbar ist, gehört in ein eigenes Modul** — auch bei Features, die + wie reine UI aussehen: `remote.js` (Pad-URLs normalisieren, D31), + `warnings.js` (Warnung → Text), `snapshots.js` (frühere Stände: wann entsteht + ein Stand, was fliegt bei Platzmangel raus, wie sieht der Speicherinhalt aus). + Dort steht **was gilt**, in `app.js` bleibt **woher die Werte kommen und wohin + sie gehen**; Speicher (`{setItem, removeItem}`) und Uhr (`Date.now()`) werden + hereingereicht, damit der Test sie stellen kann. Anlass war ein Fehler, der bis + in Produktion kam und den ein Test in einer Zeile gefunden hätte + (D54-Nachtrag 3). Faustregel: Sobald du eine Regel im Browser „nachmisst", + gehört sie in ein Modul. - Günstigster Pfad: `computeCheapPlan()`/`cheapestCost()`/`cheapCls()` (in `model.js`) markieren die nötigen Knoten (Klassen `cheap`, `cheap-leaf`); `drawCheapPath()` (app.js) diff --git a/frontend/src/app.js b/frontend/src/app.js index abaecc3..68e63d9 100644 --- a/frontend/src/app.js +++ b/frontend/src/app.js @@ -4,6 +4,8 @@ import { computeCheapPlan, freshProdSet, initialCollapsed, nodeKeys, effectiveSt import { esc, renderTreeHtml, TIP_RULE } from './render.js'; import { formatWarning, warningText } from './warnings.js'; import { padUrls } from './remote.js'; +import { LS_SNAPS, SNAP_EVERY, parseSnaps, addSnapshot, persistSnaps, snapLabel } + from './snapshots.js'; /* Werkbaum, mit Werkbaum geplant — als mitgeliefertes Dokument „Werkbank" (D27). Dieselbe Datei, die auch per ?sourceUrl= geladen werden kann; `?raw` bettet sie beim Build in die eine Ausgabedatei ein (D19), es wird nichts nachgeladen @@ -2926,65 +2928,24 @@ function flushActive(){ const d = activeDoc(); if(d) d.text = src.value; } wenn er sich seit dem letzten Stand geändert hat. Aufgehoben werden die letzten 20 je Dokument (rund 3½ Stunden Arbeit bei gleichmäßigen Abständen). Es ist ein Sicherheitsnetz gegen Versehen, kein Versionsverwaltungssystem — - wer weiter zurück will, hat Git. */ -const LS_SNAPS = 'werkbaum-snaps'; -const SNAP_EVERY = 10 * 60 * 1000; -const SNAP_KEEP = 20; + wer weiter zurück will, hat Git. + + Die Regeln stehen in snapshots.js und sind dort getestet; hier bleibt nur, + was DOM oder Speicher berührt: welches Dokument aktiv ist, ob sein Text + beschreibbar ist (Pad-Dokumente, D31, bleiben außen vor — ein alter Stand + ließe sich dort gar nicht einsetzen), und das Nachzeichnen des Menüs. */ let snaps = {}; /* {docId: [{t, text}, …]} — ältester zuerst */ let snapBase = ''; /* Text bei Dokumentwechsel; Vergleich, solange es keinen Stand gibt */ -function loadSnaps(){ - try{ const o = JSON.parse(localStorage.getItem(LS_SNAPS) || '{}'); - snaps = o && typeof o === 'object' ? o : {}; } - catch(_){ snaps = {}; } -} -/* Der Platz im localStorage ist geteilt. Läuft er über, sollen die - **Dokumente** überleben, nicht ihre Stände — der Fehlerfall wirft deshalb - Stände weg, bis es passt, notfalls alle. */ -function persistSnaps(){ - for(;;){ - try{ localStorage.setItem(LS_SNAPS, JSON.stringify(snaps)); return true; } - catch(_){ - if(!dropOldestSnap()){ try{ localStorage.removeItem(LS_SNAPS); }catch(_){} return false; } - } - } -} -function dropOldestSnap(){ - let id = null, t = Infinity; - for(const k in snaps){ - const l = snaps[k]; - if(l && l.length && l[0].t < t){ t = l[0].t; id = k; } - } - if(id === null) return false; - snaps[id].shift(); - if(!snaps[id].length) delete snaps[id]; - return true; -} -/* Legt den aktuellen Text weg, wenn er neu ist. Pad-Dokumente (D31) bleiben - 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, - wäre nur Ballast. */ -/* `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 loadSnaps(){ snaps = parseSnaps(localStorage.getItem(LS_SNAPS)); } + function snapshotNow(manuell){ const d = activeDoc(); if(!d || src.readOnly) return false; const text = src.value; - const list = snaps[d.id] || (snaps[d.id] = []); - const letzter = list.length ? list[list.length-1].text : (manuell ? null : snapBase); - if(text === letzter) return false; - list.push({t: Date.now(), text}); - while(list.length > SNAP_KEEP) list.shift(); + if(!addSnapshot(snaps, d.id, text, Date.now(), {base: snapBase, manual: manuell})) return false; snapBase = text; - persistSnaps(); + persistSnaps(snaps, localStorage); if(!snapMenu.hidden) renderSnapMenu(); return true; } @@ -2997,16 +2958,6 @@ const snapBtn = document.getElementById('snapBtn'); const snapAddBtn = document.getElementById('snapAddBtn'); const snapMenu = document.getElementById('snapMenu'); -function snapLabel(ms){ - const d = new Date(ms); - const heute = d.toDateString() === new Date().toDateString(); - try{ - return heute - ? d.toLocaleTimeString(lang, {hour: '2-digit', minute: '2-digit'}) - : d.toLocaleString(lang, {day: '2-digit', month: '2-digit', - hour: '2-digit', minute: '2-digit'}); - }catch(_){ return d.toISOString().slice(0, 16).replace('T', ' '); } -} function renderSnapMenu(){ const d = activeDoc(); const list = d ? (snaps[d.id] || []) : []; @@ -3025,7 +2976,7 @@ function renderSnapMenu(){ b.className = 'snapitem'; b.setAttribute('role', 'menuitem'); b.innerHTML = ''; - b.firstChild.textContent = snapLabel(s.t); + b.firstChild.textContent = snapLabel(s.t, lang, Date.now()); b.lastChild.textContent = t('snapLines', {n: s.text.split('\n').length}); b.addEventListener('click', e => { e.stopPropagation(); loadSnapshot(s); }); snapMenu.appendChild(b); @@ -3186,7 +3137,7 @@ function deleteDoc(){ if(!window.confirm(t('docDeleteConfirm', {name: d.name}))) return; if(padSource && padSource.id === d.id) stopPad(); /* danach gibt es nichts mehr zu holen (D31) */ docs = docs.filter(x => x.id !== d.id); - if(snaps[d.id]){ delete snaps[d.id]; persistSnaps(); } /* mit dem Dokument gehen seine Stände (D54) */ + if(snaps[d.id]){ delete snaps[d.id]; persistSnaps(snaps, localStorage); } /* mit dem Dokument gehen seine Stände (D54) */ if(!docs.length) docs = [{ id: EXAMPLE_ID, name: EXAMPLE_NAME, text: INITIAL }]; activeId = docs[0].id; foldOverrides.clear(); diff --git a/frontend/src/snapshots.js b/frontend/src/snapshots.js new file mode 100644 index 0000000..2eb79bb --- /dev/null +++ b/frontend/src/snapshots.js @@ -0,0 +1,106 @@ +/* Frühere Stände (D54) — die entscheidbare Hälfte, ohne DOM und ohne Speicher. + + Herausgezogen, nachdem ein Fehler durch alle Prüfungen kam, den ein Test in + einer Zeile gefunden hätte: Der Knopf „von Hand sichern" legte nichts weg, + solange am Dokument noch nichts geändert war (D54-Nachtrag 2). Die Regel + dafür ist reine Logik — sie hing nur an `localStorage`, `Date.now()` und + dem `