Files
mhoennigandClaude Fable 5.1 28ff6844b0
Deploy to GitHub Pages / build (push) Canceled after 0s
Deploy to GitHub Pages / deploy (push) Canceled after 0s
fix(live): Wiederverbindung sendet nie von selbst — Server-Stand oder Rückfrage (D89-Nachtrag 2)
Ein Fenster, das mit altem Text aufwachte, schickte ihn beim Wiederverbinden
als Diff und überschrieb den aktuellen Plan (v660, v679). Jetzt entscheidet,
ob seit dem letzten Abgleich hier gearbeitet wurde: nein → Server-Stand
übernehmen (lokaler Text in die Sicherungen), ja → Konflikt-Band fragt.
Regel headless in live.js (reconnectAction), Bandtext in neun Sprachen.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 12:34:01 +02:00

264 lines
10 KiB
JavaScript

import {describe, it, expect} from 'vitest';
import {
liveUrls, normalize, lines, text, computeOps, applyOps,
mapLine, caretToLineCol, lineColToCaret, feedAction, serverBase, documentsUrl, infoUrl,
reconnectAction,
} from '../src/live.js';
describe('Adressen', () => {
const doc = 'https://werkbaum.example/api/v1/documents/3f2a1b4c-5d6e-4f70-8a91-b2c3d4e5f607';
it('erkennt eine Dokument-Adresse und leitet die Endpunkte ab', () => {
const u = liveUrls(doc);
expect(u.doc).toBe(doc);
expect(u.content).toBe(doc + '/content');
expect(u.changes).toBe(doc + '/changes');
expect(u.id).toBe('3f2a1b4c-5d6e-4f70-8a91-b2c3d4e5f607');
});
it('schneidet Query, Fragment und Schrägstriche ab', () => {
// Derselbe Link soll genau ein Dokument ergeben, gleich wie er kam.
expect(liveUrls(doc + '/?x=1#top').doc).toBe(doc);
});
it('weist an, was keine Dokument-Adresse ist', () => {
expect(liveUrls('https://werkbaum.example/api/v1/documents')).toBe(null);
expect(liveUrls('https://werkbaum.example/api/v1/documents/keine-uuid')).toBe(null);
});
it('erlaubt nur http und https', () => {
expect(liveUrls('javascript:alert(1)')).toBe(null);
expect(liveUrls('file:///api/v1/documents/3f2a1b4c-5d6e-4f70-8a91-b2c3d4e5f607')).toBe(null);
});
it('löst relative Angaben gegen die Seite auf', () => {
const u = liveUrls('/api/v1/documents/3f2a1b4c-5d6e-4f70-8a91-b2c3d4e5f607',
'https://werkbaum.example/editor/');
expect(u.doc).toBe(doc);
});
});
describe('Zeilen', () => {
it('zerlegen und zusammensetzen sind zueinander invers', () => {
expect(text(lines('a\nb\nc'))).toBe('a\nb\nc');
});
it('ein abschliessendes LF ergibt eine leere letzte Zeile', () => {
expect(lines('a\n')).toEqual(['a', '']);
});
it('der leere Text ist genau eine leere Zeile', () => {
expect(lines('')).toEqual(['']);
});
it('CRLF und CR werden auf LF normalisiert', () => {
expect(lines('a\r\nb\rc')).toEqual(['a', 'b', 'c']);
expect(normalize('a\r\nb')).toBe('a\nb');
});
});
describe('Diff berechnen', () => {
const basis = ['a', 'b', 'c', 'd'];
const rundreise = (von, nach) => expect(applyOps(von, computeOps(von, nach))).toEqual(nach);
it('gleiche Stände ergeben kein Diff', () => {
expect(computeOps(basis, basis)).toEqual([]);
});
it('eine geänderte Zeile ergibt genau ein replace', () => {
expect(computeOps(basis, ['a', 'B', 'c', 'd']))
.toEqual([{op: 'replace', index: 1, count: 1, lines: ['B']}]);
});
it('eine eingefügte Zeile ergibt genau ein insert', () => {
expect(computeOps(basis, ['a', 'b', 'neu', 'c', 'd']))
.toEqual([{op: 'insert', index: 2, lines: ['neu']}]);
});
it('eine entfernte Zeile ergibt genau ein delete', () => {
expect(computeOps(basis, ['a', 'c', 'd']))
.toEqual([{op: 'delete', index: 1, count: 1}]);
});
it('Anhängen an das Dokumentende', () => {
expect(computeOps(basis, [...basis, 'e']))
.toEqual([{op: 'insert', index: 4, lines: ['e']}]);
});
it('identische Zeilen weiter unten verwirren die Zuordnung nicht', () => {
// Leerzeilen und wiederholte Einrückung sind in der Notation Alltag.
rundreise(['', 'a', '', 'a', ''], ['', 'a', '', 'a', '', 'b']);
});
it('mehrere getrennte Änderungen ergeben mehrere Operationen', () => {
const von = ['a', 'b', 'c', 'd', 'e', 'f'];
const nach = ['a', 'B', 'c', 'd', 'neu', 'e', 'f'];
expect(computeOps(von, nach)).toEqual([
{op: 'replace', index: 1, count: 1, lines: ['B']},
{op: 'insert', index: 4, lines: ['neu']},
]);
rundreise(von, nach);
});
it('ein berechnetes Diff ist immer anwendbar', () => {
const von = lines('%% Plan\n- [~] Wurzel (XL)\n - [x] Eins (S)\n - [ ] Zwei (M)\n');
const nach = lines('%% Plan neu\n- [~] Wurzel (XL)\n + [?] Zugabe (S)\n - [ ] Zwei (L)\n');
rundreise(von, nach);
});
it('ein Plan mit einer geänderten Zeile bleibt sparsam', () => {
const von = Array.from({length: 300}, (_, i) => ` - [ ] Knoten ${i} (S)`);
const nach = [...von];
nach[150] = ' - [x] Knoten 150 (S)';
expect(computeOps(von, nach))
.toEqual([{op: 'replace', index: 150, count: 1, lines: [' - [x] Knoten 150 (S)']}]);
});
});
describe('Diff anwenden', () => {
const basis = ['a', 'b', 'c', 'd'];
it('mehrere Operationen wirken alle gegen dieselbe Basis', () => {
expect(applyOps(basis, [
{op: 'insert', index: 1, lines: ['neu']},
{op: 'delete', index: 3, count: 1},
])).toEqual(['a', 'neu', 'b', 'c']);
});
it('ein Diff, das nicht passt, wird nicht halb angewendet', () => {
expect(() => applyOps(basis, [{op: 'delete', index: 7, count: 1}])).toThrow();
expect(() => applyOps(basis, [{op: 'delete', index: 3, count: 2}])).toThrow();
});
});
describe('Cursor', () => {
it('eine Einfügung darüber schiebt die Zeile nach unten', () => {
expect(mapLine(5, [{op: 'insert', index: 2, lines: ['x', 'y']}])).toBe(7);
});
it('eine Löschung darüber zieht die Zeile nach oben', () => {
expect(mapLine(5, [{op: 'delete', index: 1, count: 2}])).toBe(3);
});
it('eine Änderung darunter lässt die Zeile stehen', () => {
expect(mapLine(2, [{op: 'insert', index: 5, lines: ['x']}])).toBe(2);
});
it('eine Zeile im Eingriff landet an dessen Anfang', () => {
// Sie hat kein Gegenüber mehr; der Anfang des Bereichs ist die
// verlässlichste Antwort - dort hat die fremde Änderung eingegriffen.
expect(mapLine(6, [{op: 'replace', index: 5, count: 3, lines: ['x']}])).toBe(5);
});
it('Zeile und Spalte überstehen die Umrechnung', () => {
const t = 'eins\nzwei\ndrei';
const {line, col} = caretToLineCol(t, 7); /* "zw|ei" */
expect([line, col]).toEqual([1, 2]);
expect(lineColToCaret(lines(t), line, col)).toBe(7);
});
it('eine zu grosse Spalte rutscht ans Zeilenende, nicht darüber hinaus', () => {
expect(lineColToCaret(['ab', 'c'], 1, 99)).toBe(4);
});
});
describe('Feed-Antwort anwenden oder nicht', () => {
it('Operationen auf passender Basis werden angewendet', () => {
expect(feedAction({fromVersion: 4, currentVersion: 6, ops: []}, 4)).toBe('apply');
});
it('eine Antwort auf fremder Basis wird übersprungen', () => {
// Sonst wendet der Client dieselben Ops doppelt an - der Fall tritt ein,
// wenn Feed und 409-Antwort beide dasselbe fremde Diff liefern.
expect(feedAction({fromVersion: 3, currentVersion: 6, ops: []}, 4)).toBe('skip');
});
it('was wir schon haben, wird übersprungen', () => {
expect(feedAction({fromVersion: 4, currentVersion: 4, ops: []}, 4)).toBe('skip');
});
it('Volltext ersetzt den Stand', () => {
expect(feedAction({fromVersion: null, currentVersion: 87, content: 'x'}, 4)).toBe('replace');
});
it('nichts Verwertbares heisst nichts tun', () => {
expect(feedAction(null, 4)).toBe('skip');
expect(feedAction({currentVersion: 9}, 4)).toBe('skip');
});
});
describe('Basis-Adresse des Backends', () => {
const seite = 'https://werkbaum.example/editor/';
it('nimmt die Herkunft der Seite, wenn nichts anderes da ist', () => {
expect(serverBase(null, null, seite)).toBe('https://werkbaum.example');
expect(documentsUrl(serverBase(null, null, seite)))
.toBe('https://werkbaum.example/api/v1/documents');
});
it('die Lebendprobe fragt /api/v1/info an der Basis (D81-Nachtrag)', () => {
expect(infoUrl('https://werkbaum.example')).toBe('https://werkbaum.example/api/v1/info');
expect(infoUrl('https://werkbaum.example/unterverzeichnis/'))
.toBe('https://werkbaum.example/unterverzeichnis/api/v1/info');
});
it('der ?server=-Parameter hat Vorrang', () => {
expect(serverBase('http://localhost:8080', 'https://fremd.example/api/v1/documents/x', seite))
.toBe('http://localhost:8080');
});
it('sonst gilt der Server des offenen Dokuments', () => {
// Wer in einem ?live=-Dokument sitzt und ein neues anlegt, meint denselben.
expect(serverBase(null, 'https://anderer.example/api/v1/documents/3f2a', seite))
.toBe('https://anderer.example');
});
it('ein Backend unter einem Unterpfad bleibt erhalten', () => {
expect(serverBase(null, 'https://h.example/werkbaum/api/v1/documents/3f2a', seite))
.toBe('https://h.example/werkbaum');
});
it('ohne brauchbare Herkunft lieber nichts', () => {
// Auf file:// gibt es keine; raten waere schlechter als fragen.
expect(serverBase(null, null, 'file:///home/x/index.html')).toBe(null);
expect(serverBase('javascript:alert(1)', null, null)).toBe(null);
});
})
describe('Feed-Antwort anwenden oder nicht', () => {
it('waehrend ein eigenes Diff unterwegs ist, wird nichts angewendet', () => {
// Der Server schickt jedem die Aenderungen ALLER — die eigenen
// eingeschlossen. Wacht der Feed im Moment des eigenen Sendens auf, kaeme
// die eigene Aenderung zurueck, bevor die Antwort darauf da ist: Die
// Schattenkopie steht noch auf dem Stand davor, der Client haelt die
// eigene Aenderung fuer fremd und fragt, wessen Fassung gelten soll.
// Er streitet mit sich selbst (D76-Nachtrag 9).
const eigene = {fromVersion: 4, currentVersion: 5,
ops: [{op: 'replace', index: 1, count: 1, lines: [' - [ ] > Eins (M)']}]};
expect(feedAction(eigene, 4, true)).toBe('skip');
expect(feedAction(eigene, 4, false)).toBe('apply');
});
it('auch ein Volltext wird waehrend des Sendens ausgelassen', () => {
// Sonst risse er den gerade getippten, noch nicht gesendeten Text weg.
expect(feedAction({fromVersion: null, currentVersion: 87, content: 'x'}, 4, true)).toBe('skip');
});
});
describe('Wiederverbindung (D89-Nachtrag 2)', () => {
// Der Vorfall: Ein Telefon wacht mit dem Stand vom Vortag auf, die Sitzung
// ist tot, ein Tipp auf ein Falt-Zeichen — und die alte Fassung ging als
// Diff über den aktuellen Plan. Entscheidend ist nicht, ob die Texte
// abweichen, sondern ob hier gearbeitet wurde.
it('gleicher Text: nichts zu entscheiden', () => {
expect(reconnectAction(false, 'a\nb', 'a\nb')).toBe('none');
expect(reconnectAction(true, 'a\r\nb', 'a\nb')).toBe('none'); // Zeilenenden normalisiert
});
it('nicht gearbeitet: der Server gilt, auch wenn der lokale Text abweicht', () => {
expect(reconnectAction(false, 'alter Stand', 'neuer Stand')).toBe('adopt');
});
it('gearbeitet: fragen statt senden — beide Stände existieren', () => {
expect(reconnectAction(true, 'alter Stand plus Faltmarke', 'neuer Stand')).toBe('ask');
});
});