feat(frontend): Zeilen-Diff und Adressen fuer Server-Dokumente (live.js)
Die entscheidbare Haelfte des Live-Editing-Clients (D76), headless und geprueft: ?live=-Adressen normalisieren, Zeilen-Diff berechnen und anwenden, die Cursor-Zeile durch fremde Aenderungen mitfuehren, und die Regel, wann eine Feed-Antwort ueberhaupt angewendet werden darf. Zerlegen und Hashen liegen hier und nicht verstreut in app.js: Beide Seiten muessen Text gleich in Zeilen zerlegen, sonst zeigen die Indizes auseinander. Das Diff-Modell ist dasselbe wie im Backend (de.werkbaum.diff.LineDiff). Die Cursor-Rechnung ist der Teil, ohne den "kein Neuladen" nichts wert waere: Ohne sie spraenge die Schreibmarke bei jeder fremden Aenderung weiter oben im Dokument. 518 Tests (31 neu). Gegenprobe: Feed-Basis nicht geprueft, Zeile im Eingriff wie darunter behandelt, Protokoll nicht geprueft -> es faellt jeweils genau die danach benannte. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
bc8850a518
commit
5881be1a2c
@@ -0,0 +1,187 @@
|
||||
import {describe, it, expect} from 'vitest';
|
||||
import {
|
||||
liveUrls, normalize, lines, text, computeOps, applyOps,
|
||||
mapLine, caretToLineCol, lineColToCaret, feedAction,
|
||||
} 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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user