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:
co-authored by
Claude Opus 5
parent
324ba8a806
commit
e937e1bbbb
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
|
|||||||
@@ -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', ' '); }
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user