code.pablomurad.com

patchwork

Publishes the revisions of your pages as a feed, with notes and readable diffs, written in Go

internal/diff/diff_test.go raw
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
package diff

import (
	"strings"
	"testing"

	"git.pablomurad.com/pablo/patchwork/internal/doc"
)

// apply rebuilds b from a and an edit script.
func apply[T comparable](a, b []T, edits []Edit) (gotA, gotB []T) {
	for _, e := range edits {
		switch e.Kind {
		case Eq:
			gotA = append(gotA, a[e.A0:e.A1]...)
			gotB = append(gotB, a[e.A0:e.A1]...)
		case Del:
			gotA = append(gotA, a[e.A0:e.A1]...)
		case Ins:
			gotB = append(gotB, b[e.B0:e.B1]...)
		}
	}
	return
}

func TestMyersMinimal(t *testing.T) {
	a := strings.Split("ABCABBA", "")
	b := strings.Split("CBABAC", "")
	edits := Myers(a, b)
	ga, gb := apply(a, b, edits)
	if strings.Join(ga, "") != "ABCABBA" || strings.Join(gb, "") != "CBABAC" {
		t.Fatalf("bad script %+v", edits)
	}
	d := 0
	for _, e := range edits {
		if e.Kind != Eq {
			d += (e.A1 - e.A0) + (e.B1 - e.B0)
		}
	}
	if d != 5 {
		t.Fatalf("edit distance %d, want 5 (the classic Myers example)", d)
	}
}

func FuzzMyers(f *testing.F) {
	f.Add("the quick brown fox", "the slow brown dog")
	f.Add("", "abc")
	f.Add("abc", "")
	f.Fuzz(func(t *testing.T, x, y string) {
		// Compare runes, not the input strings: []rune turns invalid UTF-8
		// into U+FFFD, which is a property of the conversion, not of Myers.
		a, b := []rune(x), []rune(y)
		ga, gb := apply(a, b, Myers(a, b))
		if string(ga) != string(a) || string(gb) != string(b) {
			t.Fatalf("script does not rebuild inputs: %q %q", string(ga), string(gb))
		}
	})
}

func rebuild(segs []Segment) (old, new string) {
	var a, b strings.Builder
	for _, s := range segs {
		if s.T != SegIns {
			a.WriteString(s.S)
		}
		if s.T != SegDel {
			b.WriteString(s.S)
		}
	}
	return a.String(), b.String()
}

func FuzzWords(f *testing.F) {
	f.Add("E se cada protótipo fosse cuidado", "E se cada rascunho fosse regado")
	f.Add("one two three", "")
	f.Fuzz(func(t *testing.T, x, y string) {
		segs, _, _, _ := Words(x, y)
		a, b := rebuild(segs)
		if a != x || b != y {
			t.Fatalf("segments do not rebuild: %q / %q", a, b)
		}
	})
}

func TestWordsHighlightsChangedWords(t *testing.T) {
	segs, replaced, added, removed := Words(
		"O modelo híbrido é o pior dos dois mundos para equipes pequenas.",
		"O modelo híbrido funciona para equipes pequenas.")
	if replaced {
		t.Fatal("should not be a whole replacement")
	}
	want := []Segment{
		{SegEq, "O modelo híbrido "},
		{SegDel, "é o pior dos dois mundos"},
		{SegIns, "funciona"},
		{SegEq, " para equipes pequenas."},
	}
	if len(segs) != len(want) {
		t.Fatalf("got %q", segs)
	}
	for i := range want {
		if segs[i] != want[i] {
			t.Fatalf("segment %d = %q, want %q", i, segs[i], want[i])
		}
	}
	if added != 1 || removed != 6 {
		t.Fatalf("added %d removed %d", added, removed)
	}
}

func TestWordsCleanupAbsorbsLonelySpaces(t *testing.T) {
	segs, _, _, _ := Words("the cat sat on the mat today", "a dog sat on the mat today")
	if segs[0].T != SegDel || segs[0].S != "the cat" || segs[1].S != "a dog" {
		t.Fatalf("got %q", segs)
	}
}

func TestWordsReplacedWhenRewritten(t *testing.T) {
	segs, replaced, _, _ := Words("Completely different sentence here.", "Nothing in common at all, really.")
	if !replaced || len(segs) != 2 {
		t.Fatalf("want replacement, got %v %q", replaced, segs)
	}
}

func blocks(src string) []doc.Block { return doc.SplitBlocks(src) }

func opsString(ops []Op) string {
	var out []string
	for _, op := range ops {
		out = append(out, op.Op)
	}
	return strings.Join(out, ",")
}

func TestBlocks(t *testing.T) {
	cases := []struct{ name, old, new, want string }{
		{"no change", "a\n\nb", "a\n\nb", "eq,eq"},
		{"modify", "Intro.\n\nThe hybrid model is the worst of both worlds.\n\nEnd.",
			"Intro.\n\nThe hybrid model is the best of both worlds.\n\nEnd.", "eq,modify,eq"},
		{"insert list item", "- one\n- two", "- one\n- one and a half\n- two", "eq,insert,eq"},
		{"delete", "keep\n\ndrop this paragraph\n\nkeep too", "keep\n\nkeep too", "eq,delete,eq"},
		{"move", "First paragraph here.\n\nSecond paragraph here.\n\nThird one.",
			"Third one.\n\nFirst paragraph here.\n\nSecond paragraph here.", "move,eq,eq"},
		{"one for one replace is an edit", "Alpha beta gamma.", "Totally new words now.", "modify"},
		{"unrelated two for two", "Alpha beta gamma.\n\nDelta epsilon.", "Totally new words.\n\nNothing shared.", "delete,delete,insert,insert"},
		{"insert and unrelated delete", "keep\n\nold unrelated text\n\nend", "keep\n\nbrand new paragraph\n\nanother new one\n\nend", "eq,delete,insert,insert,eq"},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			res := Blocks(blocks(c.old), blocks(c.new))
			if got := opsString(res.Ops); got != c.want {
				t.Fatalf("got %s, want %s", got, c.want)
			}
		})
	}
}

func TestBlocksMoveRecordsPositions(t *testing.T) {
	res := Blocks(blocks("A one.\n\nB two.\n\nC three."), blocks("C three.\n\nA one.\n\nB two."))
	mv := res.Ops[0]
	if mv.Op != OpMove || *mv.From != 2 || *mv.To != 0 {
		t.Fatalf("got %+v", mv)
	}
	if res.Stats.BlocksChanged != 1 || res.Stats.WordsAdded != 0 {
		t.Fatalf("stats %+v", res.Stats)
	}
}

func TestBlocksPairsSimilarInOrder(t *testing.T) {
	old := "First idea about gardens at night.\n\nSecond idea about wind and grass."
	new := "First idea about gardens at dusk.\n\nSecond idea about wind and tall grass."
	res := Blocks(blocks(old), blocks(new))
	if opsString(res.Ops) != "modify,modify" {
		t.Fatalf("got %s", opsString(res.Ops))
	}
	if res.Stats.WordsAdded != 2 || res.Stats.WordsRemoved != 1 {
		t.Fatalf("stats %+v", res.Stats)
	}
}

func TestExcerpt(t *testing.T) {
	ops := []Op{{Op: OpEq}, {Op: OpEq}, {Op: OpEq}, {Op: OpModify}, {Op: OpEq}, {Op: OpEq}, {Op: OpEq}, {Op: OpInsert}, {Op: OpEq}}
	got, cut := Excerpt(ops, 1, 6)
	if cut || opsString(got) != "gap,eq,modify,eq,gap,eq,insert,eq" {
		t.Fatalf("got %s cut=%v", opsString(got), cut)
	}
	if got[0].Count != 2 || got[4].Count != 1 {
		t.Fatalf("gap counts %d %d", got[0].Count, got[4].Count)
	}
	got, cut = Excerpt(ops, 0, 1)
	if !cut || opsString(got) != "gap,modify" {
		t.Fatalf("got %s cut=%v", opsString(got), cut)
	}
}

func TestMeta(t *testing.T) {
	opt := doc.DefaultOptions()
	a := doc.Parse([]byte("---\ntitle: Old\ntags: [a, b]\nmaturity: seed\n---\nx"), opt)
	b := doc.Parse([]byte("---\ntitle: New\ntags: [a, b]\nmaturity: sprout\n---\nx"), opt)
	ch := Meta(a, b, opt.DiffKeys)
	if len(ch) != 2 || ch[0].Key != "title" || ch[1].Key != "maturity" || ch[1].New != "sprout" {
		t.Fatalf("got %+v", ch)
	}
}