feat(backend): Zeilen-Diff als reine Funktionen (Live-Editing, Schritt 1)
Anwenden, Berechnen, Rebasen und Pruefsumme in de.werkbaum.diff — ohne Spring, damit die Regeln ohne Kontext pruefbar sind. Grundlage fuer PATCH /content und den Aenderungsfeed (D76). Beim Bauen entschieden, was das Konzept offenliess: Eine Einfuegung ist ein Punkt ZWISCHEN den Zeilen und kollidiert nur mit dem Inneren eines fremden Bereichs (start < index < end). Beide im Konzept genannten Folgen gelten damit weiter — zwei Einfuegungen an derselben Stelle vertragen sich, eine Einfuegung in einen geloeschten Bereich nicht —, aber die Raender bleiben konfliktfrei: Wer eine Zeile ueber einer gerade geaenderten einfuegt, bekommt keinen 409. 49 Tests. Gegenprobe: Rand-Regel auf halboffen mutiert -> genau die zwei danach benannten Zusicherungen fallen; fremde Einfuegung an gleicher Stelle nicht mitgezaehlt -> genau die eine. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
9e5f546d76
commit
0f3bd8f5b8
@@ -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<String> = normalize(text).split("\n")
|
||||
|
||||
fun text(lines: List<String>): String = lines.joinToString("\n")
|
||||
|
||||
/** Prüfsumme des Basistexts, Format `sha256:<hex>`. */
|
||||
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<String>, ops: List<LineOp>): List<String> {
|
||||
validate(base.size, ops)
|
||||
val out = ArrayList<String>(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<LineOp>) {
|
||||
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<LineOp>, onto: List<LineOp>): List<LineOp>? {
|
||||
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>): 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<String>, to: List<String>): List<LineOp> {
|
||||
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<String>, b: List<String>, offset: Int): List<LineOp> {
|
||||
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<LineOp>()
|
||||
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<String>()
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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<String>) : 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<String>) : 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<String>
|
||||
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
|
||||
@@ -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<DiffNotApplicableException> {
|
||||
LineDiff.apply(basis, listOf(LineOp.Insert(5, listOf("x"))))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ein Bereich ueber das Dokumentende hinaus ist nicht anwendbar`() {
|
||||
shouldThrow<DiffNotApplicableException> {
|
||||
LineDiff.apply(basis, listOf(LineOp.Delete(3, 2)))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ein negativer Index ist nicht anwendbar`() {
|
||||
shouldThrow<DiffNotApplicableException> {
|
||||
LineDiff.apply(basis, listOf(LineOp.Insert(-1, listOf("x"))))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ein negatives count ist nicht anwendbar`() {
|
||||
shouldThrow<DiffNotApplicableException> {
|
||||
LineDiff.apply(basis, listOf(LineOp.Delete(1, -1)))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unsortierte Operationen sind nicht anwendbar`() {
|
||||
shouldThrow<DiffNotApplicableException> {
|
||||
LineDiff.apply(basis, listOf(LineOp.Delete(2, 1), LineOp.Delete(0, 1)))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sich ueberschneidende Operationen sind nicht anwendbar`() {
|
||||
shouldThrow<DiffNotApplicableException> {
|
||||
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<String>, nach: List<String>) {
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user