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 }