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
+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
(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)
+14 -63
View File
@@ -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 = '<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.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();
+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');
});
});