feat(editor): Mehr-Fenster-Betrieb gebaut — Ablageschema v3, Tombstones, docsync, Sperre je Dokument (RFC 002, D94)
Kein Fenster schreibt mehr den Schlüssel eines anderen: Text, Meta und frühere Stände liegen je Dokument unter eigenen Schlüsseln, der Index ist nur noch Reihenfolge-Hinweis und löscht nie, Löschungen hinterlassen einen Tombstone (7 Tage). Der Voll-Flush weicht dem Dirty-Flush. Damit sind Befund 2 und 3 des RFC weg — verschiedene Dokumente in zwei Fenstern kollidieren gar nicht mehr. docsync.js zieht Liste, Namen, Tombstones und den Stände-Cache aus dem storage-Ereignis nach; am aktiven Dokument die drei Fälle umbenannt / anderswo gelöscht (behalten, bis getippt wird) / fremd geschrieben. Der modale D89-Dialog samt Präsenz-Kanal ist ersatzlos raus; den einen Verlustfall — dasselbe nicht-`live:`-Dokument in beiden Fenstern vorn — findet eine Sperre per Web Locks, und das zweite Fenster bekommt drei Auswege statt „trotzdem fortfahren". Im Browser mit zwei echten Tabs nachgemessen (RFC §10): Fälle 1–8 grün, Fall 1 im Vor-v3-Build gegengeprüft und dort nachweislich rot. Fall 9 deckte auf, dass reviveGoneDoc() den Index-Hinweis nicht mitschrieb — ein durch Tippen wiederbelebtes Dokument hing bis zum nächsten Flush allein an seinen eigenen Schlüsseln und wäre bei einem Rückbau auf einen Build vor v3 still verlorengegangen; behoben, Gegenprobe per Mutation gezogen. Headless 666 Tests grün. Offen bleibt die Handarbeit: PWA neben Tab, Firefox, Safari, ein Browser ohne Locks-API. SPEC §9, D94, D89-Nachtrag, RFC (Status, §10, Revisionsgeschichte), CHANGELOG und Plan-Knoten #ed.docs.windows nachgezogen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
112ada4e58
commit
caaf7c15e1
@@ -0,0 +1,161 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { applyStorageEvent, lockDecision, lockName, tombstoneExpired,
|
||||
createDirtySet, GONE_TTL } from '../src/docsync.js';
|
||||
|
||||
/* docsync (RFC 002 §6.3): Die Ereignis-Matrix — was ein storage-Ereignis im
|
||||
laufenden Fenster bedeutet. Headless: Liste und Ereignis sind Daten.
|
||||
|
||||
Fixture: A ist dieses Fenster. Das Ereignis kommt von Fenster B. */
|
||||
function apply(docs, activeId, key, newValue, oldValue){
|
||||
return applyStorageEvent(docs, activeId, {key, newValue, oldValue});
|
||||
}
|
||||
const D = (id, name, text) => ({id, name, text: text ?? ''});
|
||||
|
||||
describe('applyStorageEvent — die Matrix aus §6.3', () => {
|
||||
it('meta neu: Dokument hängt in der Liste, ohne Text (lazy)', () => {
|
||||
const docs = [D('a', 'A', '- a')];
|
||||
const erg = apply(docs, 'a', 'werkbaum-meta:neu', '{"name":"Neu","born":1}');
|
||||
expect(erg.docs).toEqual([{id: 'a', name: 'A', text: '- a'}, {id: 'neu', name: 'Neu', text: ''}]);
|
||||
expect(erg.action).toBe('created');
|
||||
});
|
||||
|
||||
it('meta geändert am FREMDEN Dokument: Name/Quelle übernehmen, kein action', () => {
|
||||
const docs = [D('a', 'A', '- a'), D('b', 'Alt')];
|
||||
const erg = apply(docs, 'a', 'werkbaum-meta:b', '{"name":"Neu","born":1}');
|
||||
expect(erg.docs[0]).toBe(docs[0]); /* dasselbe Array — die Mutation trägt */
|
||||
expect(erg.docs[1].name).toBe('Neu');
|
||||
expect(erg.action).toBe(null);
|
||||
});
|
||||
|
||||
it('meta geändert am AKTIVEN Dokument: action „renamed" — der Chip folgt', () => {
|
||||
const docs = [D('a', 'Alt')];
|
||||
const erg = apply(docs, 'a', 'werkbaum-meta:a', '{"name":"Neu","born":1}');
|
||||
expect(erg.action).toBe('renamed');
|
||||
expect(erg.docs[0].name).toBe('Neu');
|
||||
});
|
||||
|
||||
it('gone neu, id NICHT aktiv: aus der Liste', () => {
|
||||
const docs = [D('a', 'A', '- a'), D('b', 'B', '- b')];
|
||||
const erg = apply(docs, 'a', 'werkbaum-gone:b', '123');
|
||||
expect(erg.docs.map(d => d.id)).toEqual(['a']);
|
||||
expect(erg.action).toBe(null);
|
||||
});
|
||||
|
||||
it('gone neu, id AKTIV: behalten, action „deleted" — warnfarben, bis getippt wird (§6.5)', () => {
|
||||
const docs = [D('a', 'A', '- a')];
|
||||
const erg = apply(docs, 'a', 'werkbaum-gone:a', '123');
|
||||
expect(erg.docs).toBe(docs); /* behalten */
|
||||
expect(erg.action).toBe('deleted');
|
||||
});
|
||||
|
||||
it('gone aufgehoben (newValue null): nichts tun — das meta-Ereignis legt an', () => {
|
||||
const erg = apply([D('a', 'A')], 'a', 'werkbaum-gone:a', null);
|
||||
expect(erg.action).toBe(null);
|
||||
});
|
||||
|
||||
it('doc geändert, id NICHT aktiv: Text für die Vorschau nachziehen', () => {
|
||||
const docs = [D('a', 'A', '- a'), D('b', 'B', '- alt')];
|
||||
const erg = apply(docs, 'a', 'werkbaum-doc:b', '- neu');
|
||||
expect(erg.docs[1].text).toBe('- neu');
|
||||
expect(erg.action).toBe(null);
|
||||
});
|
||||
|
||||
it('doc geändert, id AKTIV, kein live: — NICHTS am Text, action „foreignWrite" (§6.6)', () => {
|
||||
const docs = [D('a', 'A', '- mein Text')];
|
||||
const erg = apply(docs, 'a', 'werkbaum-doc:a', '- fremder Text');
|
||||
expect(erg.docs[0].text).toBe('- mein Text'); /* das Getippte bleibt */
|
||||
expect(erg.action).toBe('foreignWrite');
|
||||
});
|
||||
|
||||
it('doc geändert, id aktiv, live: — nichts, der Feed ist die Quelle', () => {
|
||||
const docs = [D('live:https://x/d1', 'Geteilt', '- gemeinsam')];
|
||||
const erg = apply(docs, 'live:https://x/d1', 'werkbaum-doc:live:https://x/d1', '- server');
|
||||
expect(erg.docs[0].text).toBe('- gemeinsam');
|
||||
expect(erg.action).toBe(null);
|
||||
});
|
||||
|
||||
it('doc geändert, id unbekannt: ignorieren — das meta-Ereignis legt das Dokument an', () => {
|
||||
const docs = [D('a', 'A')];
|
||||
const erg = apply(docs, 'a', 'werkbaum-doc:fremd', '- x');
|
||||
expect(erg.docs).toBe(docs);
|
||||
expect(erg.action).toBe(null);
|
||||
});
|
||||
|
||||
it('snaps: action „snaps" — der Stände-Cache der id wird verworfen', () => {
|
||||
const erg = apply([D('a', 'A')], 'a', 'werkbaum-snaps:b', '[{"t":1,"text":"x"}]');
|
||||
expect(erg.action).toBe('snaps');
|
||||
expect(erg.id).toBe('b');
|
||||
});
|
||||
|
||||
it('docs (Index-Hinweis): Reihenfolge übernehmen, unbekannte ids bleiben hinten', () => {
|
||||
const docs = [D('a', 'A'), D('b', 'B'), D('fremd', 'F')];
|
||||
const erg = apply(docs, 'a', 'werkbaum-docs', '[{"id":"b","name":"B"},{"id":"a","name":"A"}]');
|
||||
expect(erg.docs.map(d => d.id)).toEqual(['b', 'a', 'fremd']);
|
||||
expect(erg.action).toBe('order');
|
||||
});
|
||||
|
||||
it('docs unlesbar oder bereits gleich: nichts', () => {
|
||||
const docs = [D('a', 'A')];
|
||||
expect(apply(docs, 'a', 'werkbaum-docs', '{kaputt').docs).toBe(docs);
|
||||
expect(apply(docs, 'a', 'werkbaum-docs', null).docs).toBe(docs);
|
||||
});
|
||||
|
||||
it('Harmlose Schlüssel (active, Spiegel, Ansicht, Merker): nichts', () => {
|
||||
const docs = [D('a', 'A')];
|
||||
for(const k of ['werkbaum-active', 'werkbaum-src', 'werkbaum-ui', 'werkbaum-lang', 'werkbaum-seeded']){
|
||||
const erg = apply(docs, 'a', k, '1');
|
||||
expect(erg.docs).toBe(docs);
|
||||
expect(erg.action).toBe(null);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('lockDecision — gesperrt wird nie ein geteiltes Dokument (§6.4)', () => {
|
||||
it('nicht „live:" wird gesperrt — eigene, URL- und Datei-Dokumente ebenso', () => {
|
||||
expect(lockDecision('example')).toBe(true);
|
||||
expect(lockDecision('d12345')).toBe(true);
|
||||
expect(lockDecision('url:https://x.example/p.werkbaum')).toBe(true);
|
||||
});
|
||||
|
||||
it('live: nie — zwei Fenster sind zwei Live-Clients, der Server führt zusammen', () => {
|
||||
expect(lockDecision('live:https://w.example/api/v1/documents/abc')).toBe(false);
|
||||
expect(lockDecision(null)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tombstoneExpired — Verfall nach 7 Tagen (§6.2)', () => {
|
||||
const jetzt = 1750000000000;
|
||||
it('junge Tombstones bleiben', () => {
|
||||
expect(tombstoneExpired(jetzt - GONE_TTL + 1, jetzt)).toBe(false);
|
||||
expect(tombstoneExpired(jetzt, jetzt)).toBe(false);
|
||||
});
|
||||
|
||||
it('nach sieben Tagen fallen sie', () => {
|
||||
expect(tombstoneExpired(jetzt - GONE_TTL - 1, jetzt)).toBe(true);
|
||||
});
|
||||
|
||||
it('ohne brauchbaren Zeitstempel gilt der Tombstone als verfallen', () => {
|
||||
expect(tombstoneExpired(undefined, jetzt)).toBe(true);
|
||||
expect(tombstoneExpired('kaputt', jetzt)).toBe(true);
|
||||
expect(tombstoneExpired(0, jetzt)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createDirtySet — die Flush-Punkte schreiben nur Eigenes (§6.1)', () => {
|
||||
it('sammelt ids, take räumt auf', () => {
|
||||
const dirty = createDirtySet();
|
||||
expect(dirty.entries()).toEqual([]);
|
||||
dirty.add('a'); dirty.add('b'); dirty.add('a');
|
||||
expect(dirty.entries()).toEqual(['a', 'b']);
|
||||
dirty.clear();
|
||||
expect(dirty.entries()).toEqual([]);
|
||||
});
|
||||
|
||||
it('ignoriert fehlende ids', () => {
|
||||
const dirty = createDirtySet();
|
||||
dirty.add(null); dirty.add(undefined); dirty.add('');
|
||||
expect(dirty.entries()).toEqual([]);
|
||||
dirty.add('x');
|
||||
expect(dirty.has('x')).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user