Recently Written · git

sbm-sync

Sync server for sbm bookmark files: one small Go program, plain files, AGPL

git clone https://github.com/equwal/sbm-sync

Log | Files | Refs


merge_test.go (5871 bytes)

1 package main
2 
3 import (
4 	"slices"
5 	"strings"
6 	"testing"
7 
8 	"pgregory.net/rapid"
9 )
10 
11 // file draws a bookmark file from few URLs and descriptions, so that files
12 // share lines, and lines share bookmarks, as often as real files do.
13 func file(t *rapid.T, label string) string {
14 	pool := []string{
15 		"https://suckless.org/\tsuckless\ttools",
16 		"https://suckless.org\tsuckless software\ttools c",
17 		"http://www.suckless.org\tsuckless",
18 		"https://example.org/a\tA\t",
19 		"https://example.org/a\tA, edited\tx",
20 		"https://example.org/b\tB\t",
21 		"https://jisho.org\tJisho: 日本語の辞書\tja",
22 		"https://jisho.org\tJisho\t",
23 		"# comment",
24 		"",
25 	}
26 	ls := rapid.SliceOfN(rapid.SampledFrom(pool), 0, 8).Draw(t, label)
27 	return join(ls)
28 }
29 
30 // unique draws a file without two equal lines.
31 func unique(t *rapid.T, label string) string {
32 	ls := lines(file(t, label))
33 	var out []string
34 	for _, l := range ls {
35 		if !slices.Contains(out, l) {
36 			out = append(out, l)
37 		}
38 	}
39 	return join(out)
40 }
41 
42 func sorted(text string) []string {
43 	ls := lines(text)
44 	slices.Sort(ls)
45 	return ls
46 }
47 
48 func TestNoLocalChangeGivesRemote(t *testing.T) {
49 	rapid.Check(t, func(t *rapid.T) {
50 		base, remote := file(t, "base"), file(t, "remote")
51 		if got := merge(base, base, remote); got != join(lines(remote)) {
52 			t.Fatalf("merge(base, base, remote) = %q, want %q", got, remote)
53 		}
54 	})
55 }
56 
57 func TestNoRemoteChangeGivesLocal(t *testing.T) {
58 	rapid.Check(t, func(t *rapid.T) {
59 		base, local := unique(t, "base"), unique(t, "local")
60 		if got := sorted(merge(base, local, base)); !slices.Equal(got, sorted(local)) {
61 			t.Fatalf("merge(base, local, base) has lines %q, want %q", got, sorted(local))
62 		}
63 	})
64 }
65 
66 func TestMergeOfOneFileIsThatFile(t *testing.T) {
67 	rapid.Check(t, func(t *rapid.T) {
68 		f := file(t, "f")
69 		if got := merge(f, f, f); got != join(lines(f)) {
70 			t.Fatalf("merge(f, f, f) = %q", got)
71 		}
72 	})
73 }
74 
75 func TestLocalAdditionsStay(t *testing.T) {
76 	rapid.Check(t, func(t *rapid.T) {
77 		base, local, remote := file(t, "base"), file(t, "local"), file(t, "remote")
78 		got := set(lines(merge(base, local, remote)))
79 		b := set(lines(base))
80 		for _, l := range lines(local) {
81 			if !b[l] && !got[l] {
82 				t.Fatalf("line %q that local added is lost", l)
83 			}
84 		}
85 	})
86 }
87 
88 func TestLocalRemovalsStay(t *testing.T) {
89 	rapid.Check(t, func(t *rapid.T) {
90 		base, local, remote := file(t, "base"), file(t, "local"), file(t, "remote")
91 		got := set(lines(merge(base, local, remote)))
92 		l := set(lines(local))
93 		for _, x := range lines(base) {
94 			if !l[x] && got[x] {
95 				t.Fatalf("line %q that local removed came back", x)
96 			}
97 		}
98 	})
99 }
100 
101 func TestRemoteAdditionsStayWithoutConflict(t *testing.T) {
102 	rapid.Check(t, func(t *rapid.T) {
103 		base, local, remote := file(t, "base"), file(t, "local"), file(t, "remote")
104 		got := set(lines(merge(base, local, remote)))
105 		b := set(lines(base))
106 		changed := map[string]bool{} // bookmarks that local added or edited
107 		for _, l := range lines(local) {
108 			if !b[l] {
109 				changed[key(l)] = true
110 			}
111 		}
112 		for _, x := range lines(remote) {
113 			if !b[x] && !changed[key(x)] && !got[x] {
114 				t.Fatalf("line %q that the server added is lost", x)
115 			}
116 		}
117 	})
118 }
119 
120 func TestNoNewLines(t *testing.T) {
121 	rapid.Check(t, func(t *rapid.T) {
122 		base, local, remote := file(t, "base"), file(t, "local"), file(t, "remote")
123 		have := set(append(lines(local), lines(remote)...))
124 		for _, x := range lines(merge(base, local, remote)) {
125 			if !have[x] {
126 				t.Fatalf("line %q comes from neither side", x)
127 			}
128 		}
129 	})
130 }
131 
132 // Two devices that sync in turn end with the same file.
133 func TestDevicesConverge(t *testing.T) {
134 	rapid.Check(t, func(t *rapid.T) {
135 		start := file(t, "start")
136 		a, b := file(t, "device a"), file(t, "device b")
137 		m1 := merge(start, a, start) // a syncs: a now has m1
138 		m2 := merge(start, b, m1)    // b syncs: b now has m2
139 		m3 := merge(m1, m1, m2)      // a syncs again
140 		if m3 != m2 {
141 			t.Fatalf("device a has %q, device b has %q", m3, m2)
142 		}
143 	})
144 }
145 
146 func TestNorm(t *testing.T) {
147 	same := []string{"https://suckless.org/", "http://suckless.org", "suckless.org//", "https://WWW.Suckless.ORG"}
148 	for _, u := range same {
149 		if norm(u) != "suckless.org" {
150 			t.Errorf("norm(%q) = %q, want suckless.org", u, norm(u))
151 		}
152 	}
153 	if norm("https://example.org/A") == norm("https://example.org/a") {
154 		t.Error("the case of the path must count")
155 	}
156 }
157 
158 func TestMergeExamples(t *testing.T) {
159 	cases := []struct{ name, base, local, remote, want string }{
160 		{"first device", "", "a\t1\n", "", "a\t1\n"},
161 		{"second device: union", "", "b\t2\n", "a\t1\n", "a\t1\nb\t2\n"},
162 		{"local add goes to the end", "a\t1\n", "a\t1\nc\t3\n", "a\t1\nb\t2\n", "a\t1\nb\t2\nc\t3\n"},
163 		{"local delete", "a\t1\nb\t2\n", "b\t2\n", "a\t1\nb\t2\nc\t3\n", "b\t2\nc\t3\n"},
164 		{"local edit keeps its place", "a\t1\nb\t2\n", "a\tone\nb\t2\n", "a\t1\nb\t2\n", "a\tone\nb\t2\n"},
165 		{"both edit: the device wins", "a\t1\n", "a\tlocal\n", "a\tremote\n", "a\tlocal\n"},
166 		{"local delete, remote edit: the edit stays", "a\t1\n", "", "a\tremote\n", "a\tremote\n"},
167 		{"CRLF", "", "a\t1\r\nb\t2\r\n", "", "a\t1\nb\t2\n"},
168 		{"no final newline", "", "a\t1", "", "a\t1\n"},
169 		{"a file of one empty line", "", "\n", "", "\n"},
170 		{"an empty line from the server", "a\n", "", "a\n\n", "\n"},
171 		{"same page, other form", "", "https://www.x.org/\tx\n", "http://x.org\tx\n", "https://www.x.org/\tx\n"},
172 	}
173 	for _, c := range cases {
174 		if got := merge(c.base, c.local, c.remote); got != c.want {
175 			t.Errorf("%s: got %q, want %q", c.name, got, c.want)
176 		}
177 	}
178 }
179 
180 func TestKey(t *testing.T) {
181 	for _, l := range []string{"", "   ", "# https://x.org"} {
182 		if key(l) != "" {
183 			t.Errorf("key(%q) = %q, want none", l, key(l))
184 		}
185 	}
186 	if k := key("https://x.org/a/\tdesc\ttag"); k != "x.org/a" {
187 		t.Errorf("key = %q", k)
188 	}
189 	if !strings.Contains(key("https://jisho.org/word/日本"), "日本") {
190 		t.Error("key loses non-ASCII text")
191 	}
192 }