refactor(snapshots): Regeln nach snapshots.js, 28 Tests

D54-Nachtrag 2 endete mit „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.

Geschnitten nach dem Vorbild von remote.js (D31) — die entscheidbare Hälfte
heraus, die I/O bleibt:
- snapshots.js: addSnapshot (wann entsteht ein Stand), dropOldestSnap /
  persistSnaps (was fliegt bei Platzmangel), parseSnaps (was darf gelesen
  werden), snapLabel. Speicher als {setItem, removeItem} und Uhr als Zahl
  hereingereicht — genau die Abhängigkeiten, an denen die Prüfbarkeit
  vorher scheiterte.
- app.js: -63/+14 Zeilen, snapshotNow() schrumpft auf sechs.

Die Gegenprobe zählt, nicht die Zahl der Tests: Baut man exakt den
ausgelieferten Fehler wieder ein (`manual ? null : base` -> `base`), fällt
GENAU die eine Zusicherung, die nach ihm benannt ist; die übrigen 27 bleiben
grün.

Zwei bewusste Verhaltensänderungen: parseSnaps wirft formfremde Einträge weg
(Liste kein Array, t keine Zahl, text kein String), und ein Array im
Speicher ergibt {} statt eines halb benutzbaren Zustands.

Verdrahtung bleibt Browser-Sache und wurde nachgemessen: Knopf ohne Eingabe
-> 1 Stand; Takt (2 s) bei bloß angesehenem Dokument -> 0, nach Änderung
-> 1, weitere Takte -> 1.

D54-Nachtrag 3 + Faustregel in frontend/CLAUDE.md. 271 Tests grün (243+28).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-08-24 11:37:09 +02:00
co-authored by Claude Opus 5
parent 324ba8a806
commit e937e1bbbb
5 changed files with 383 additions and 63 deletions
+58
View File
@@ -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 `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 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`. 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.
+10
View File
@@ -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 `app.js` (DOM/Events/i18n/Persistenz/Export). Modell/Renderer bekommen UI-State
(verworfene einblenden, Pfad an/aus) als **Parameter** — keine Globals; nur (verworfene einblenden, Pfad an/aus) als **Parameter** — keine Globals; nur
`cheapPathOn` lebt als UI-State in `app.js`. Tests: `tests/*.test.js`. `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 - Günstigster Pfad: `computeCheapPlan()`/`cheapestCost()`/`cheapCls()` (in
`model.js`) markieren `model.js`) markieren
die nötigen Knoten (Klassen `cheap`, `cheap-leaf`); `drawCheapPath()` (app.js) die nötigen Knoten (Klassen `cheap`, `cheap-leaf`); `drawCheapPath()` (app.js)
+14 -63
View File
@@ -4,6 +4,8 @@ import { computeCheapPlan, freshProdSet, initialCollapsed, nodeKeys, effectiveSt
import { esc, renderTreeHtml, TIP_RULE } from './render.js'; import { esc, renderTreeHtml, TIP_RULE } from './render.js';
import { formatWarning, warningText } from './warnings.js'; import { formatWarning, warningText } from './warnings.js';
import { padUrls } from './remote.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). /* Werkbaum, mit Werkbaum geplant als mitgeliefertes Dokument Werkbank" (D27).
Dieselbe Datei, die auch per ?sourceUrl= geladen werden kann; `?raw` bettet Dieselbe Datei, die auch per ?sourceUrl= geladen werden kann; `?raw` bettet
sie beim Build in die eine Ausgabedatei ein (D19), es wird nichts nachgeladen 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 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). letzten 20 je Dokument (rund 3½ Stunden Arbeit bei gleichmäßigen Abständen).
Es ist ein Sicherheitsnetz gegen Versehen, kein Versionsverwaltungssystem Es ist ein Sicherheitsnetz gegen Versehen, kein Versionsverwaltungssystem
wer weiter zurück will, hat Git. */ wer weiter zurück will, hat Git.
const LS_SNAPS = 'werkbaum-snaps';
const SNAP_EVERY = 10 * 60 * 1000; Die Regeln stehen in snapshots.js und sind dort getestet; hier bleibt nur,
const SNAP_KEEP = 20; 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 snaps = {}; /* {docId: [{t, text}, …]} — ältester zuerst */
let snapBase = ''; /* Text bei Dokumentwechsel; Vergleich, solange es keinen Stand gibt */ let snapBase = ''; /* Text bei Dokumentwechsel; Vergleich, solange es keinen Stand gibt */
function loadSnaps(){ function loadSnaps(){ snaps = parseSnaps(localStorage.getItem(LS_SNAPS)); }
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 snapshotNow(manuell){ 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] = []); if(!addSnapshot(snaps, d.id, text, Date.now(), {base: snapBase, manual: manuell})) return false;
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();
snapBase = text; snapBase = text;
persistSnaps(); persistSnaps(snaps, localStorage);
if(!snapMenu.hidden) renderSnapMenu(); if(!snapMenu.hidden) renderSnapMenu();
return true; return true;
} }
@@ -2997,16 +2958,6 @@ const snapBtn = document.getElementById('snapBtn');
const snapAddBtn = document.getElementById('snapAddBtn'); const snapAddBtn = document.getElementById('snapAddBtn');
const snapMenu = document.getElementById('snapMenu'); 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(){ function renderSnapMenu(){
const d = activeDoc(); const d = activeDoc();
const list = d ? (snaps[d.id] || []) : []; const list = d ? (snaps[d.id] || []) : [];
@@ -3025,7 +2976,7 @@ function renderSnapMenu(){
b.className = 'snapitem'; b.className = 'snapitem';
b.setAttribute('role', 'menuitem'); b.setAttribute('role', 'menuitem');
b.innerHTML = '<span></span><span class="snapsize"></span>'; b.innerHTML = '<span></span><span class="snapsize"></span>';
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.lastChild.textContent = t('snapLines', {n: s.text.split('\n').length});
b.addEventListener('click', e => { e.stopPropagation(); loadSnapshot(s); }); b.addEventListener('click', e => { e.stopPropagation(); loadSnapshot(s); });
snapMenu.appendChild(b); snapMenu.appendChild(b);
@@ -3186,7 +3137,7 @@ function deleteDoc(){
if(!window.confirm(t('docDeleteConfirm', {name: d.name}))) return; if(!window.confirm(t('docDeleteConfirm', {name: d.name}))) return;
if(padSource && padSource.id === d.id) stopPad(); /* danach gibt es nichts mehr zu holen (D31) */ if(padSource && padSource.id === d.id) stopPad(); /* danach gibt es nichts mehr zu holen (D31) */
docs = docs.filter(x => x.id !== d.id); 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 }]; if(!docs.length) docs = [{ id: EXAMPLE_ID, name: EXAMPLE_NAME, text: INITIAL }];
activeId = docs[0].id; activeId = docs[0].id;
foldOverrides.clear(); foldOverrides.clear();
+106
View File
@@ -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 `<textarea>` fest und war deshalb nicht prüfbar.
Aufteilung nach dem Vorbild von remote.js (D31): Hier steht, WAS gilt; in
app.js bleibt, WOHER die Werte kommen (aktives Dokument, Schreibschutz,
Menü) und WOHIN sie gehen (`localStorage`). Der Speicher wird als
`{setItem, removeItem}` hereingereicht, die Uhr als Zahl beides lässt sich
im Test stellen.
Datenform: `{docId: [{t, text}, …]}`, ältester Stand zuerst. */
export const LS_SNAPS = 'werkbaum-snaps';
export const SNAP_EVERY = 10 * 60 * 1000;
export const SNAP_KEEP = 20;
/* Was aus dem Speicher kommt, ist fremder Text: Es kann von einer älteren
Fassung stammen, von Hand bearbeitet oder halb geschrieben sein. Alles, was
nicht die erwartete Form hat, fällt weg statt später beim Lesen zu
stolpern Stände sind ein Sicherheitsnetz, sie dürfen die App nicht
umbringen. */
export function parseSnaps(raw){
let o;
try{ o = JSON.parse(raw || '{}'); }
catch(_){ return {}; }
if(!o || typeof o !== 'object' || Array.isArray(o)) return {};
const out = {};
for(const id of Object.keys(o)){
const list = o[id];
if(!Array.isArray(list)) continue;
const rein = list.filter(s => s && typeof s.text === 'string' && typeof s.t === 'number');
if(rein.length) out[id] = rein;
}
return out;
}
/* Legt den Text als neuen Stand ab, wenn er neu ist; gibt zurück, ob das
geschehen ist. `snaps` wird dabei verändert (wie schon vorher in app.js).
`manual` schaltet die `base`-Sperre ab und das ist der ganze Unterschied
zwischen Takt und Knopf. `base` 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 und bei leerer Liste wäre der Text dann nirgends gesichert.
Für ihn zählt allein der **letzte Eintrag**: Der Doppelte bleibt vermieden,
und die Zusage dein Stand ist gesichert" wird in jedem Fall wahr. */
export function addSnapshot(snaps, id, text, now, opts){
const {base = null, manual = false} = opts || {};
const list = snaps[id] || (snaps[id] = []);
const letzter = list.length ? list[list.length - 1].text : (manual ? null : base);
if(text === letzter) return false;
list.push({t: now, text});
while(list.length > SNAP_KEEP) list.shift();
return true;
}
/* Wirft den ältesten Stand **über alle Dokumente hinweg** weg und sagt, ob
noch einer da war. Über alle, nicht nur im aktiven Dokument: Wenn der Platz
knapp wird, ist das Älteste das Entbehrlichste, gleich zu welchem Dokument
es gehört. */
export function dropOldestSnap(snaps){
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;
}
/* Der Platz im Speicher ist geteilt. Läuft er über, sollen die **Dokumente**
überleben, nicht ihre Stände deshalb wirft der Fehlerfall Stände weg, bis
es passt, notfalls alle. Gibt zurück, ob am Ende etwas gespeichert wurde. */
export function persistSnaps(snaps, store){
for(;;){
try{ store.setItem(LS_SNAPS, JSON.stringify(snaps)); return true; }
catch(_){
if(!dropOldestSnap(snaps)){
try{ store.removeItem(LS_SNAPS); }catch(_){}
return false;
}
}
}
}
/* Beschriftung eines Eintrags: heute nur die Uhrzeit, sonst mit Datum davor
die Zeit trägt die Unterscheidung, das Datum nur, wo sie nicht reicht.
`now` wird hereingereicht, damit heute" prüfbar ist. Der Rückfall greift,
wenn die Laufzeit die Sprache nicht kennt. */
export function snapLabel(ms, lang, now){
const d = new Date(ms);
const heute = d.toDateString() === new Date(now).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', ' '); }
}
+195
View File
@@ -0,0 +1,195 @@
import { describe, it, expect } from 'vitest';
import { LS_SNAPS, SNAP_KEEP, parseSnaps, addSnapshot, dropOldestSnap,
persistSnaps, snapLabel } from '../src/snapshots.js';
/* Frühere Stände (D54). Herausgezogen aus app.js, damit genau das prüfbar
wird, was dort dreimal daneben lag: die Regel, wann ein Stand entsteht. */
describe('addSnapshot — Takt und Knopf unterscheiden sich in einer Sache', () => {
/* DER gemeldete Fehler (D54-Nachtrag 2): Dokument geöffnet, nichts geändert,
Knopf gedrückt und nichts wurde gesichert, obwohl der Knopf bestätigte.
Diese eine Zusicherung hätte ihn vor dem Ausliefern gefunden. */
it('Knopf sichert auch, wenn seit dem Öffnen nichts geändert wurde', () => {
const snaps = {};
expect(addSnapshot(snaps, 'a', 'Plan', 1000, {base: 'Plan', manual: true})).toBe(true);
expect(snaps.a).toEqual([{t: 1000, text: 'Plan'}]);
});
it('Takt sammelt ein bloß angesehenes Dokument nicht', () => {
const snaps = {};
expect(addSnapshot(snaps, 'a', 'Plan', 1000, {base: 'Plan'})).toBe(false);
expect(snaps.a).toEqual([]);
});
it('Takt sichert, sobald sich etwas geändert hat', () => {
const snaps = {};
expect(addSnapshot(snaps, 'a', 'Plan neu', 1000, {base: 'Plan'})).toBe(true);
expect(snaps.a).toHaveLength(1);
});
/* Sobald es einen Eintrag gibt, zählt für beide dasselbe: der letzte. Der
Knopf soll keinen doppelten Eintrag erzeugen er verspricht dein Stand
ist gesichert", nicht „ein Eintrag wurde erzeugt". */
it.each([['Knopf', true], ['Takt', false]])(
'%s legt keinen Doppelten an, wenn der Text schon oben in der Liste steht',
(_name, manual) => {
const snaps = {a: [{t: 1, text: 'Plan'}]};
expect(addSnapshot(snaps, 'a', 'Plan', 2000, {base: 'ganz was anderes', manual})).toBe(false);
expect(snaps.a).toHaveLength(1);
});
it('sichert wieder, sobald sich der Text vom letzten Eintrag unterscheidet', () => {
const snaps = {a: [{t: 1, text: 'Plan'}]};
expect(addSnapshot(snaps, 'a', 'Plan+', 2000, {base: 'Plan', manual: true})).toBe(true);
expect(snaps.a.map(s => s.text)).toEqual(['Plan', 'Plan+']);
});
it('unterscheidet die Dokumente', () => {
const snaps = {};
addSnapshot(snaps, 'a', 'A', 1000, {manual: true});
addSnapshot(snaps, 'b', 'B', 1001, {manual: true});
expect(Object.keys(snaps)).toEqual(['a', 'b']);
expect(snaps.b).toEqual([{t: 1001, text: 'B'}]);
});
/* Ohne `base` (kein Dokument geöffnet, frischer Zustand) darf auch der Takt
sichern verglichen wird dann gegen nichts. */
it('ohne Optionen wird gesichert', () => {
const snaps = {};
expect(addSnapshot(snaps, 'a', 'Plan', 1000)).toBe(true);
});
});
describe('addSnapshot — es bleiben die letzten 20', () => {
it('deckelt bei SNAP_KEEP und wirft den ältesten weg', () => {
const snaps = {};
for(let i = 0; i < SNAP_KEEP + 9; i++) addSnapshot(snaps, 'a', 'T' + i, i, {manual: true});
expect(snaps.a).toHaveLength(SNAP_KEEP);
expect(snaps.a[0].text).toBe('T9'); /* T0…T8 sind weg */
expect(snaps.a[SNAP_KEEP - 1].text).toBe('T' + (SNAP_KEEP + 8));
expect(snaps.a.map(s => s.t)).toEqual([...snaps.a.map(s => s.t)].sort((x, y) => x - y));
});
});
describe('dropOldestSnap — das Älteste geht zuerst, dokumentübergreifend', () => {
it('trifft das älteste über alle Dokumente hinweg', () => {
const snaps = {a: [{t: 50, text: 'a1'}], b: [{t: 10, text: 'b1'}, {t: 60, text: 'b2'}]};
expect(dropOldestSnap(snaps)).toBe(true);
expect(snaps.b.map(s => s.text)).toEqual(['b2']);
expect(snaps.a).toHaveLength(1);
});
it('räumt ein leer gewordenes Dokument ganz weg', () => {
const snaps = {a: [{t: 50, text: 'a1'}]};
dropOldestSnap(snaps);
expect(snaps).toEqual({});
});
it('meldet false, wenn nichts mehr da ist', () => {
expect(dropOldestSnap({})).toBe(false);
expect(dropOldestSnap({a: []})).toBe(false);
});
});
/* Der Speicher ist mit den Dokumenten geteilt. Läuft er über, sollen die
Dokumente überleben Stände sind das Nachgeben-Bare. */
describe('persistSnaps — Dokumente gehen vor Ständen', () => {
function speicher(limit){
const s = {daten: null, entfernt: false, versuche: 0};
return Object.assign(s, {
setItem(k, v){
s.versuche++;
if(v.length > limit) throw new Error('QuotaExceededError');
s.daten = v;
},
removeItem(){ s.entfernt = true; }
});
}
it('speichert, wenn es passt', () => {
const store = speicher(1e6), snaps = {a: [{t: 1, text: 'kurz'}]};
expect(persistSnaps(snaps, store)).toBe(true);
expect(JSON.parse(store.daten)).toEqual(snaps);
expect(store.versuche).toBe(1);
});
it('wirft die ältesten Stände weg, bis es passt', () => {
const snaps = {a: [{t: 1, text: 'x'.repeat(200)}, {t: 3, text: 'y'.repeat(200)}],
b: [{t: 2, text: 'z'.repeat(200)}]};
const store = speicher(300);
expect(persistSnaps(snaps, store)).toBe(true);
/* Übrig bleibt der jüngste — t:1 und t:2 fielen in dieser Reihenfolge. */
expect(Object.keys(snaps)).toEqual(['a']);
expect(snaps.a.map(s => s.t)).toEqual([3]);
});
it('gibt auf und räumt den Schlüssel weg, wenn selbst leer nicht passt', () => {
const snaps = {a: [{t: 1, text: 'x'}]};
const store = speicher(1); /* nicht einmal "{}" passt */
expect(persistSnaps(snaps, store)).toBe(false);
expect(store.entfernt).toBe(true);
expect(snaps).toEqual({});
});
it('speichert unter dem vereinbarten Schlüssel', () => {
let key = null;
persistSnaps({}, {setItem(k){ key = k; }, removeItem(){}});
expect(key).toBe(LS_SNAPS);
});
});
/* Was aus dem Speicher kommt, ist fremder Text: alte Fassung, von Hand
bearbeitet, halb geschrieben. Ein Sicherheitsnetz darf daran nicht die App
aufhängen. */
describe('parseSnaps — beschädigter Speicher bringt die App nicht um', () => {
it('liest die erwartete Form', () => {
const o = {a: [{t: 1, text: 'x'}]};
expect(parseSnaps(JSON.stringify(o))).toEqual(o);
});
it.each([
['leer', null],
['leerer String', ''],
['kaputtes JSON', '{nicht json'],
['kein Objekt', '"text"'],
['Array statt Objekt', '[1,2,3]'],
['null', 'null'],
])('gibt bei %s ein leeres Objekt zurück', (_name, raw) => {
expect(parseSnaps(raw)).toEqual({});
});
it('wirft Einträge weg, die nicht die erwartete Form haben', () => {
const raw = JSON.stringify({
a: [{t: 1, text: 'gut'}, {t: 'späth', text: 'x'}, {t: 2}, null, {text: 'ohne t'}],
b: 'keine Liste',
c: []
});
expect(parseSnaps(raw)).toEqual({a: [{t: 1, text: 'gut'}]});
});
});
describe('snapLabel — heute die Uhrzeit, sonst mit Datum', () => {
const ts = Date.UTC(2026, 2, 5, 14, 37); /* 5. März 2026, 14:37 UTC */
it('nennt am selben Tag nur die Uhrzeit', () => {
const label = snapLabel(ts, 'de-DE', ts + 60 * 1000);
expect(label).toMatch(/\d{2}:\d{2}/);
expect(label).not.toMatch(/05|03\./); /* kein Tag, kein Monat */
});
it('nennt an einem anderen Tag auch das Datum', () => {
const label = snapLabel(ts, 'de-DE', ts + 36 * 60 * 60 * 1000);
expect(label).toMatch(/05/);
expect(label.length).toBeGreaterThan(snapLabel(ts, 'de-DE', ts).length);
});
/* Die Grenze ist der Kalendertag, nicht „vor 24 Stunden“. */
it('zählt den Kalendertag, nicht die verstrichene Zeit', () => {
const kurzNachMitternacht = Date.UTC(2026, 2, 6, 0, 30);
expect(snapLabel(ts, 'de-DE', kurzNachMitternacht)).toMatch(/05/);
});
it('fällt auf eine lesbare Form zurück, wenn die Sprache unbekannt ist', () => {
expect(snapLabel(ts, 'xx-!', ts)).toBe('2026-03-05 14:37');
});
});