diff --git a/backend/src/main/kotlin/de/werkbaum/diff/LineDiff.kt b/backend/src/main/kotlin/de/werkbaum/diff/LineDiff.kt new file mode 100644 index 0000000..a51d81d --- /dev/null +++ b/backend/src/main/kotlin/de/werkbaum/diff/LineDiff.kt @@ -0,0 +1,242 @@ +package de.werkbaum.diff + +import java.security.MessageDigest + +/** Ein Diff ist nicht anwendbar (Index außerhalb, Überschneidung, Prüfsummenfehler). */ +class DiffNotApplicableException(message: String) : RuntimeException(message) + +/** + * Zeilen-Diffs: anwenden, berechnen, rebasen — reine Funktionen ohne Spring. + * + * Grundlage des Live-Editings (D76, `backend/docs/live-editing-proposal.md`). + * Das Backend parst die Notation dabei **nicht** (D14): Zeilen sind opake + * Strings, Fortsetzungszeilen und Beschreibungsblöcke sind schlicht Zeilen. + */ +object LineDiff { + + const val CHECKSUM_PREFIX = "sha256:" + + /** + * Obergrenze für die LCS-Tabelle. Darüber wird der abweichende Abschnitt in + * einem einzigen `replace` zusammengefasst — bei einem so großen + * Unterschied ist das ohnehin die ehrliche Beschreibung, und die Tabelle + * bliebe sonst ein Ausfall-Vektor (Konzept: „Grenzen"). + */ + const val LCS_LIMIT = 1_000_000L + + // ----------------------------------------------------------------------- + // Text ↔ Zeilen + // ----------------------------------------------------------------------- + + /** + * Zeilenenden auf LF (SPEC §12). Der **Server** normalisiert autoritativ + * beim Speichern, der Client beim Laden — nur so hashen beide denselben + * Text. + */ + fun normalize(text: String): String = text.replace("\r\n", "\n").replace('\r', '\n') + + /** + * Zerlegt in physische Zeilen. Es gilt `text(lines(t)) == normalize(t)`; + * ein abschließendes LF ergibt also eine leere letzte Zeile, und der leere + * Text ist genau eine leere Zeile. Der Client muss identisch zerlegen, + * sonst zeigen die Indizes auseinander. + */ + fun lines(text: String): List = normalize(text).split("\n") + + fun text(lines: List): String = lines.joinToString("\n") + + /** Prüfsumme des Basistexts, Format `sha256:`. */ + fun checksum(text: String): String { + val digest = MessageDigest.getInstance("SHA-256") + .digest(normalize(text).toByteArray(Charsets.UTF_8)) + return CHECKSUM_PREFIX + digest.joinToString("") { "%02x".format(it) } + } + + // ----------------------------------------------------------------------- + // Anwenden + // ----------------------------------------------------------------------- + + /** + * Wendet [ops] auf [base] an. Erwartet aufsteigend sortierte, + * überschneidungsfreie Operationen innerhalb der Dokumentgrenzen — + * sonst [DiffNotApplicableException] (im Protokoll: 422). + */ + fun apply(base: List, ops: List): List { + validate(base.size, ops) + val out = ArrayList(base.size) + var cursor = 0 + for (op in ops) { + out.addAll(base.subList(cursor, op.index)) + out.addAll(op.insertedLines) + cursor = op.endExclusive + } + out.addAll(base.subList(cursor, base.size)) + return out + } + + private fun validate(size: Int, ops: List) { + var prevEnd = 0 + for (op in ops) { + if (op.removedCount < 0) { + throw DiffNotApplicableException("count darf nicht negativ sein: $op") + } + if (op.index < 0 || op.index > size) { + throw DiffNotApplicableException("Index ${op.index} liegt außerhalb von 0..$size") + } + if (op.endExclusive > size) { + throw DiffNotApplicableException( + "Operation reicht über das Dokumentende hinaus (${op.endExclusive} > $size): $op" + ) + } + if (op.index < prevEnd) { + throw DiffNotApplicableException( + "Operationen müssen aufsteigend und überschneidungsfrei sein: $op" + ) + } + prevEnd = op.endExclusive + } + } + + // ----------------------------------------------------------------------- + // Überschneidung + // ----------------------------------------------------------------------- + + /** + * Überschneiden sich zwei Operationen derselben Basis? Das ist die + * Konfliktdefinition des Protokolls. + * + * `replace`/`delete` belegen den halboffenen Bereich + * `[index, index+count)`. `insert` ist ein **Punkt**, und zwar zwischen + * den Zeilen: Er kollidiert nur mit einem Bereich, in dessen **Innerem** + * er liegt (`start < index < end`). + * + * Daraus folgt, was das Konzept fordert: + * - Zwei Einfügungen an derselben Stelle sind **kein** Konflikt. + * - Eine Einfügung **in** einen gelöschten Bereich hinein ist einer. + * + * An den Rändern ist die Einfügung dagegen eindeutig — vor bzw. hinter dem + * fremden Block —, und Ränder sind der häufige Fall: Wer eine Zeile über + * einer gerade geänderten einfügt, soll keinen 409 bekommen. + */ + fun conflicts(a: LineOp, b: LineOp): Boolean = when { + a is LineOp.Insert && b is LineOp.Insert -> false + a is LineOp.Insert -> b.index < a.index && a.index < b.endExclusive + b is LineOp.Insert -> a.index < b.index && b.index < a.endExclusive + else -> a.index < b.endExclusive && b.index < a.endExclusive + } + + // ----------------------------------------------------------------------- + // Rebasen + // ----------------------------------------------------------------------- + + /** + * Verschiebt [ops] (gegen eine veraltete Basis gebildet) auf den Stand, + * der durch [onto] daraus entstanden ist. + * + * Ergebnis `null` heißt **echter Konflikt** (im Protokoll: 409) — nur dann + * muss der Client entscheiden. Ohne Überschneidung rebased der Server + * selbst und akzeptiert; ohne das verhungerte ein Client mit hoher Latenz + * bei fleißigen Mitschreibern (D76). + */ + fun rebase(ops: List, onto: List): List? { + if (onto.isEmpty() || ops.isEmpty()) return ops + for (mine in ops) { + for (theirs in onto) { + if (conflicts(mine, theirs)) return null + } + } + return ops.map { shift(it, onto) } + } + + /** + * Verschiebt eine einzelne Operation um die Zeilenänderung aller fremden + * Operationen, die **vor** ihr liegen. Eine fremde Einfügung an derselben + * Stelle zählt dazu: Sie ist bereits bestätigt und steht deshalb oben. + */ + private fun shift(op: LineOp, onto: List): LineOp { + val delta = onto.filter { it.endExclusive <= op.index }.sumOf { it.lineDelta } + if (delta == 0) return op + return when (op) { + is LineOp.Insert -> op.copy(index = op.index + delta) + is LineOp.Delete -> op.copy(index = op.index + delta) + is LineOp.Replace -> op.copy(index = op.index + delta) + } + } + + // ----------------------------------------------------------------------- + // Berechnen + // ----------------------------------------------------------------------- + + /** + * Zeilen-Diff zwischen zwei Ständen; es gilt `apply(from, compute(from, to)) == to`. + * + * Gemeinsamer Anfang und gemeinsames Ende fallen zuerst weg — der übliche + * Fall (ein paar geänderte Zeilen in einem großen Plan) kostet danach fast + * nichts. Erst der Rest geht durch die LCS-Tabelle. + */ + fun compute(from: List, to: List): List { + var head = 0 + val shortest = minOf(from.size, to.size) + while (head < shortest && from[head] == to[head]) head++ + var tail = 0 + while (tail < shortest - head && from[from.size - 1 - tail] == to[to.size - 1 - tail]) tail++ + + val a = from.subList(head, from.size - tail) + val b = to.subList(head, to.size - tail) + + return when { + a.isEmpty() && b.isEmpty() -> emptyList() + a.isEmpty() -> listOf(LineOp.Insert(head, b.toList())) + b.isEmpty() -> listOf(LineOp.Delete(head, a.size)) + a.size.toLong() * b.size.toLong() > LCS_LIMIT -> + listOf(LineOp.Replace(head, a.size, b.toList())) + else -> lcsOps(a, b, head) + } + } + + private fun lcsOps(a: List, b: List, offset: Int): List { + val n = a.size + val m = b.size + // lcs[i][j] = Länge der längsten gemeinsamen Teilfolge von a[i..] und b[j..] + val lcs = Array(n + 1) { IntArray(m + 1) } + for (i in n - 1 downTo 0) { + for (j in m - 1 downTo 0) { + lcs[i][j] = if (a[i] == b[j]) lcs[i + 1][j + 1] + 1 + else maxOf(lcs[i + 1][j], lcs[i][j + 1]) + } + } + + val ops = mutableListOf() + var i = 0 + var j = 0 + while (i < n || j < m) { + if (i < n && j < m && a[i] == b[j]) { + i++ + j++ + continue + } + // Ein zusammenhängender Unterschied: alles bis zur nächsten + // gemeinsamen Zeile wird zu einer Operation zusammengefasst. + val removedFrom = i + val inserted = mutableListOf() + while (i < n || j < m) { + if (i < n && j < m && a[i] == b[j]) break + if (j < m && (i == n || lcs[i][j + 1] >= lcs[i + 1][j])) { + inserted += b[j] + j++ + } else { + i++ + } + } + val removed = i - removedFrom + ops += when { + removed > 0 && inserted.isNotEmpty() -> + LineOp.Replace(offset + removedFrom, removed, inserted.toList()) + + removed > 0 -> LineOp.Delete(offset + removedFrom, removed) + else -> LineOp.Insert(offset + removedFrom, inserted.toList()) + } + } + return ops + } +} diff --git a/backend/src/main/kotlin/de/werkbaum/diff/LineOp.kt b/backend/src/main/kotlin/de/werkbaum/diff/LineOp.kt new file mode 100644 index 0000000..f431f45 --- /dev/null +++ b/backend/src/main/kotlin/de/werkbaum/diff/LineOp.kt @@ -0,0 +1,48 @@ +package de.werkbaum.diff + +/** + * Eine Zeilen-Operation relativ zu einer Basisversion (0-basierter Index). + * + * Die drei Formen entsprechen 1:1 dem Draht-Format aus dem Live-Editing-Konzept + * (`backend/docs/live-editing-proposal.md`, Abschnitt „Datenmodell"): + * + * - [Insert] – [lines] werden **vor** [index] eingefügt; `index == Zeilenzahl` + * hängt an. + * - [Delete] – [count] Zeilen ab [index] entfallen. + * - [Replace] – [count] Zeilen ab [index] werden durch [lines] ersetzt. + * + * Bewusst ein eigenes, Spring-freies Modell: Anwenden, Berechnen und Rebasen + * sind reine Funktionen und damit ohne Kontext testbar (D54-Nachtrag 3 zieht + * dieselbe Grenze im Frontend). + */ +sealed interface LineOp { + val index: Int + + data class Insert(override val index: Int, val lines: List) : LineOp + data class Delete(override val index: Int, val count: Int) : LineOp + data class Replace(override val index: Int, val count: Int, val lines: List) : LineOp +} + +/** Zahl der Zeilen, die diese Operation aus der Basis entfernt. */ +val LineOp.removedCount: Int + get() = when (this) { + is LineOp.Insert -> 0 + is LineOp.Delete -> count + is LineOp.Replace -> count + } + +/** Zeilen, die diese Operation einsetzt. */ +val LineOp.insertedLines: List + get() = when (this) { + is LineOp.Insert -> lines + is LineOp.Delete -> emptyList() + is LineOp.Replace -> lines + } + +/** Ende des betroffenen Basisbereichs, exklusiv. Bei [LineOp.Insert] gleich [LineOp.index]. */ +val LineOp.endExclusive: Int + get() = index + removedCount + +/** Wie viele Zeilen das Dokument durch diese Operation länger (positiv) oder kürzer wird. */ +val LineOp.lineDelta: Int + get() = insertedLines.size - removedCount diff --git a/backend/src/test/kotlin/de/werkbaum/diff/LineDiffTest.kt b/backend/src/test/kotlin/de/werkbaum/diff/LineDiffTest.kt new file mode 100644 index 0000000..79b2535 --- /dev/null +++ b/backend/src/test/kotlin/de/werkbaum/diff/LineDiffTest.kt @@ -0,0 +1,378 @@ +package de.werkbaum.diff + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test + +class LineDiffTest { + + private val basis = listOf("a", "b", "c", "d") + + // ----------------------------------------------------------------------- + @Nested + inner class TextUndZeilen { + + @Test + fun `zerlegen und zusammensetzen sind zueinander invers`() { + val text = "eins\nzwei\ndrei" + LineDiff.text(LineDiff.lines(text)) shouldBe text + } + + @Test + fun `ein abschliessendes LF ergibt eine leere letzte Zeile`() { + LineDiff.lines("a\n") shouldBe listOf("a", "") + LineDiff.text(listOf("a", "")) shouldBe "a\n" + } + + @Test + fun `der leere Text ist genau eine leere Zeile`() { + LineDiff.lines("") shouldBe listOf("") + } + + @Test + fun `CRLF und CR werden auf LF normalisiert`() { + LineDiff.lines("a\r\nb\rc") shouldBe listOf("a", "b", "c") + } + } + + // ----------------------------------------------------------------------- + @Nested + inner class Pruefsumme { + + @Test + fun `gleicher Text ergibt gleiche Pruefsumme`() { + LineDiff.checksum("a\nb") shouldBe LineDiff.checksum("a\nb") + } + + @Test + fun `unterschiedlicher Text ergibt unterschiedliche Pruefsumme`() { + LineDiff.checksum("a\nb") shouldNotBe LineDiff.checksum("a\nc") + } + + @Test + fun `Zeilenenden gehen nicht in die Pruefsumme ein`() { + // Sonst haetten Windows- und Unix-Client nie dieselbe Basis. + LineDiff.checksum("a\r\nb") shouldBe LineDiff.checksum("a\nb") + } + + @Test + fun `die Pruefsumme nennt ihr Verfahren`() { + LineDiff.checksum("a").startsWith("sha256:") shouldBe true + LineDiff.checksum("a").length shouldBe "sha256:".length + 64 + } + } + + // ----------------------------------------------------------------------- + @Nested + inner class Anwenden { + + @Test + fun `replace ersetzt count Zeilen ab index`() { + LineDiff.apply(basis, listOf(LineOp.Replace(1, 2, listOf("X")))) shouldBe + listOf("a", "X", "d") + } + + @Test + fun `insert setzt vor den Index`() { + LineDiff.apply(basis, listOf(LineOp.Insert(2, listOf("X", "Y")))) shouldBe + listOf("a", "b", "X", "Y", "c", "d") + } + + @Test + fun `insert am Dokumentende haengt an`() { + LineDiff.apply(basis, listOf(LineOp.Insert(4, listOf("e")))) shouldBe + listOf("a", "b", "c", "d", "e") + } + + @Test + fun `delete entfernt count Zeilen ab index`() { + LineDiff.apply(basis, listOf(LineOp.Delete(0, 2))) shouldBe listOf("c", "d") + } + + @Test + fun `die letzte Zeile laesst sich loeschen`() { + LineDiff.apply(basis, listOf(LineOp.Delete(3, 1))) shouldBe listOf("a", "b", "c") + } + + @Test + fun `mehrere Operationen wirken alle gegen die Basis`() { + // Die Indizes der zweiten Operation zaehlen die erste NICHT mit. + val ops = listOf( + LineOp.Insert(1, listOf("neu")), + LineOp.Delete(3, 1), + ) + LineDiff.apply(basis, ops) shouldBe listOf("a", "neu", "b", "c") + } + + @Test + fun `ein leeres Dokument nimmt eine Zeile auf`() { + LineDiff.apply(LineDiff.lines(""), listOf(LineOp.Replace(0, 1, listOf("a")))) shouldBe + listOf("a") + } + + @Test + fun `ohne Operationen bleibt alles stehen`() { + LineDiff.apply(basis, emptyList()) shouldBe basis + } + + @Test + fun `ein Index hinter dem Dokumentende ist nicht anwendbar`() { + shouldThrow { + LineDiff.apply(basis, listOf(LineOp.Insert(5, listOf("x")))) + } + } + + @Test + fun `ein Bereich ueber das Dokumentende hinaus ist nicht anwendbar`() { + shouldThrow { + LineDiff.apply(basis, listOf(LineOp.Delete(3, 2))) + } + } + + @Test + fun `ein negativer Index ist nicht anwendbar`() { + shouldThrow { + LineDiff.apply(basis, listOf(LineOp.Insert(-1, listOf("x")))) + } + } + + @Test + fun `ein negatives count ist nicht anwendbar`() { + shouldThrow { + LineDiff.apply(basis, listOf(LineOp.Delete(1, -1))) + } + } + + @Test + fun `unsortierte Operationen sind nicht anwendbar`() { + shouldThrow { + LineDiff.apply(basis, listOf(LineOp.Delete(2, 1), LineOp.Delete(0, 1))) + } + } + + @Test + fun `sich ueberschneidende Operationen sind nicht anwendbar`() { + shouldThrow { + LineDiff.apply(basis, listOf(LineOp.Delete(0, 2), LineOp.Replace(1, 1, listOf("x")))) + } + } + } + + // ----------------------------------------------------------------------- + @Nested + inner class Ueberschneidung { + + @Test + fun `zwei Einfuegungen an derselben Stelle vertragen sich`() { + LineDiff.conflicts( + LineOp.Insert(3, listOf("x")), + LineOp.Insert(3, listOf("y")), + ) shouldBe false + } + + @Test + fun `eine Einfuegung IN einen geloeschten Bereich ist ein Konflikt`() { + LineDiff.conflicts( + LineOp.Insert(6, listOf("x")), + LineOp.Delete(5, 3), + ) shouldBe true + } + + @Test + fun `eine Einfuegung am Rand eines geloeschten Bereichs ist keiner`() { + // Vor dem Block und hinter dem Block ist die Stelle eindeutig. + LineDiff.conflicts(LineOp.Insert(5, listOf("x")), LineOp.Delete(5, 3)) shouldBe false + LineDiff.conflicts(LineOp.Insert(8, listOf("x")), LineOp.Delete(5, 3)) shouldBe false + } + + @Test + fun `eine Einfuegung neben einer geaenderten Zeile ist keiner`() { + LineDiff.conflicts( + LineOp.Insert(5, listOf("x")), + LineOp.Replace(5, 1, listOf("y")), + ) shouldBe false + } + + @Test + fun `dieselbe Zeile zweimal geaendert ist ein Konflikt`() { + LineDiff.conflicts( + LineOp.Replace(5, 1, listOf("x")), + LineOp.Replace(5, 1, listOf("y")), + ) shouldBe true + } + + @Test + fun `aneinandergrenzende Bereiche vertragen sich`() { + LineDiff.conflicts(LineOp.Delete(5, 2), LineOp.Replace(7, 2, listOf("x"))) shouldBe false + } + + @Test + fun `die Regel ist symmetrisch`() { + val insert = LineOp.Insert(6, listOf("x")) + val delete = LineOp.Delete(5, 3) + LineDiff.conflicts(insert, delete) shouldBe LineDiff.conflicts(delete, insert) + } + } + + // ----------------------------------------------------------------------- + @Nested + inner class Rebasen { + + @Test + fun `ohne fremde Operationen bleibt alles unveraendert`() { + val meine = listOf(LineOp.Replace(2, 1, listOf("x"))) + LineDiff.rebase(meine, emptyList()) shouldBe meine + } + + @Test + fun `eine fremde Einfuegung davor verschiebt nach unten`() { + LineDiff.rebase( + listOf(LineOp.Replace(5, 1, listOf("x"))), + listOf(LineOp.Insert(2, listOf("neu", "neu2"))), + ) shouldBe listOf(LineOp.Replace(7, 1, listOf("x"))) + } + + @Test + fun `eine fremde Loeschung davor verschiebt nach oben`() { + LineDiff.rebase( + listOf(LineOp.Replace(5, 1, listOf("x"))), + listOf(LineOp.Delete(1, 2)), + ) shouldBe listOf(LineOp.Replace(3, 1, listOf("x"))) + } + + @Test + fun `eine fremde Operation dahinter verschiebt nichts`() { + val meine = listOf(LineOp.Replace(1, 1, listOf("x"))) + LineDiff.rebase(meine, listOf(LineOp.Insert(5, listOf("neu")))) shouldBe meine + } + + @Test + fun `eine bestaetigte fremde Einfuegung an derselben Stelle steht oben`() { + LineDiff.rebase( + listOf(LineOp.Insert(3, listOf("meins"))), + listOf(LineOp.Insert(3, listOf("fremd"))), + ) shouldBe listOf(LineOp.Insert(4, listOf("meins"))) + } + + @Test + fun `echte Ueberschneidung meldet Konflikt`() { + LineDiff.rebase( + listOf(LineOp.Replace(5, 1, listOf("meins"))), + listOf(LineOp.Replace(5, 1, listOf("fremd"))), + ) shouldBe null + } + + @Test + fun `rebasen ergibt das erwartete Dokument`() { + val basis = listOf("a", "b", "c", "d", "e") + val fremd = listOf(LineOp.Insert(1, listOf("neu"))) + val meine = listOf(LineOp.Replace(3, 1, listOf("D"))) + + val aktuell = LineDiff.apply(basis, fremd) + val verschoben = LineDiff.rebase(meine, fremd)!! + + LineDiff.apply(aktuell, verschoben) shouldBe listOf("a", "neu", "b", "c", "D", "e") + } + + @Test + fun `mehrere eigene Operationen bleiben nach dem Rebasen sortiert und anwendbar`() { + val basis = (0..11).map { "z$it" } + val fremd = listOf(LineOp.Delete(6, 3)) + val meine = listOf(LineOp.Insert(5, listOf("x")), LineOp.Delete(10, 2)) + + val verschoben = LineDiff.rebase(meine, fremd)!! + verschoben shouldBe listOf(LineOp.Insert(5, listOf("x")), LineOp.Delete(7, 2)) + + LineDiff.apply(LineDiff.apply(basis, fremd), verschoben) shouldBe + listOf("z0", "z1", "z2", "z3", "z4", "x", "z5", "z9") + } + } + + // ----------------------------------------------------------------------- + @Nested + inner class Berechnen { + + private fun rundreise(von: List, nach: List) { + LineDiff.apply(von, LineDiff.compute(von, nach)) shouldBe nach + } + + @Test + fun `gleiche Staende ergeben kein Diff`() { + LineDiff.compute(basis, basis) shouldBe emptyList() + } + + @Test + fun `eine geaenderte Zeile ergibt genau ein replace`() { + LineDiff.compute(basis, listOf("a", "B", "c", "d")) shouldBe + listOf(LineOp.Replace(1, 1, listOf("B"))) + } + + @Test + fun `eine eingefuegte Zeile ergibt genau ein insert`() { + LineDiff.compute(basis, listOf("a", "b", "neu", "c", "d")) shouldBe + listOf(LineOp.Insert(2, listOf("neu"))) + } + + @Test + fun `eine entfernte Zeile ergibt genau ein delete`() { + LineDiff.compute(basis, listOf("a", "c", "d")) shouldBe + listOf(LineOp.Delete(1, 1)) + } + + @Test + fun `Anhaengen an das Dokumentende`() { + LineDiff.compute(basis, basis + "e") shouldBe listOf(LineOp.Insert(4, listOf("e"))) + } + + @Test + fun `aus dem leeren Dokument heraus`() { + rundreise(LineDiff.lines(""), listOf("a", "b")) + } + + @Test + fun `in das leere Dokument hinein`() { + rundreise(listOf("a", "b"), LineDiff.lines("")) + } + + @Test + fun `identische Zeilen weiter unten verwirren die Zuordnung nicht`() { + // Leerzeilen und wiederholte Einrueckung sind in der Notation Alltag. + val von = listOf("", "a", "", "a", "") + val nach = listOf("", "a", "", "a", "", "b") + rundreise(von, nach) + } + + @Test + fun `mehrere getrennte Aenderungen ergeben mehrere Operationen`() { + val von = listOf("a", "b", "c", "d", "e", "f") + val nach = listOf("a", "B", "c", "d", "neu", "e", "f") + LineDiff.compute(von, nach) shouldBe listOf( + LineOp.Replace(1, 1, listOf("B")), + LineOp.Insert(4, listOf("neu")), + ) + rundreise(von, nach) + } + + @Test + fun `ein vollstaendig anderer Text ergibt ein anwendbares Diff`() { + rundreise(listOf("a", "b", "c"), listOf("x", "y")) + } + + @Test + fun `ein grosser Plan mit einer geaenderten Zeile bleibt sparsam`() { + val von = (0..899).map { " - [ ] Knoten $it (S)" } + val nach = von.toMutableList().also { it[500] = " - [x] Knoten 500 (S)" } + LineDiff.compute(von, nach) shouldBe + listOf(LineOp.Replace(500, 1, listOf(" - [x] Knoten 500 (S)"))) + } + + @Test + fun `ein berechnetes Diff ist immer anwendbar`() { + val von = "%% Plan\n- [~] Wurzel (XL)\n - [x] Eins (S)\n - [ ] Zwei (M)\n" + val nach = "%% Plan geaendert\n- [~] Wurzel (XL)\n - [x] Eins (S)\n + [?] Zugabe (S)\n - [ ] Zwei (L)\n" + rundreise(LineDiff.lines(von), LineDiff.lines(nach)) + } + } +}