bookmarks_test.go (7679 bytes)
1 package main 2 3 import ( 4 "slices" 5 "strings" 6 "testing" 7 8 "pgregory.net/rapid" 9 ) 10 11 func TestParseLine(t *testing.T) { 12 for line, want := range map[string]Bookmark{ 13 "https://a.org\tA b\tx y": {URL: "https://a.org", Desc: "A b", Tags: []string{"x", "y"}}, 14 " https://a.org \t\t": {URL: "https://a.org"}, 15 "https://a.org old format | tags": {URL: "https://a.org", Desc: "old format | tags"}, 16 "https://a.org": {URL: "https://a.org"}, 17 } { 18 got, ok := parseLine(line) 19 if !ok || got.URL != want.URL || got.Desc != want.Desc || !slices.Equal(got.Tags, want.Tags) { 20 t.Errorf("parseLine(%q) = %+v %v, want %+v", line, got, ok, want) 21 } 22 } 23 for _, line := range []string{"", " ", "# comment", "\tno URL\t"} { 24 if b, ok := parseLine(line); ok { 25 t.Errorf("parseLine(%q) = %+v, want no bookmark", line, b) 26 } 27 } 28 } 29 30 // A form gives one line, which holds the URL without white space, the words 31 // of the description and the tags. 32 func TestFormGivesOneLine(t *testing.T) { 33 rapid.Check(t, func(t *rapid.T) { 34 e := entry{rapid.String().Draw(t, "url"), rapid.String().Draw(t, "desc"), rapid.String().Draw(t, "tags")} 35 u := strings.Join(strings.Fields(e.URL), "") 36 line, err := e.line() 37 if u == "" || strings.HasPrefix(u, "#") { 38 if err == nil { 39 t.Fatalf("line of URL %q = %q, want an error", e.URL, line) 40 } 41 return 42 } 43 if err != nil { 44 t.Fatal(err) 45 } 46 if strings.Count(line, "\t") != 2 || strings.ContainsAny(line, "\r\n") { 47 t.Fatalf("%q is not one line of three fields", line) 48 } 49 b, ok := parseLine(line) 50 if !ok || b.URL != u || !slices.Equal(strings.Fields(b.Desc), strings.Fields(e.Desc)) || 51 !slices.Equal(b.Tags, strings.Fields(e.Tags)) { 52 t.Fatalf("parseLine(%q) = %+v %v", line, b, ok) 53 } 54 }) 55 } 56 57 func TestLink(t *testing.T) { 58 for u, want := range map[string]string{ 59 "https://a.org": "https://a.org", 60 "HTTP://A.org/x": "HTTP://A.org/x", 61 "www.a.org/x": "https://www.a.org/x", 62 "javascript:alert(1)": "", 63 "JavaScript:alert(1)": "", 64 "data:text/html,x": "", 65 "file:///etc/passwd": "", 66 } { 67 if got := (Bookmark{URL: u}).Link(); got != want { 68 t.Errorf("link of %q = %q, want %q", u, got, want) 69 } 70 } 71 } 72 73 // TestScoreAsTheAddOn compares with the scores of lib/fuzzy.js in the add-on. 74 func TestScoreAsTheAddOn(t *testing.T) { 75 for _, c := range []struct { 76 query, text string 77 want int 78 }{ 79 {"suck", "suckless software c tools https://suckless.org", 124}, 80 {"sls", "suckless", 6}, 81 {"jsh", "Jisho: 日本語の辞書 ja https://jisho.org", 11}, 82 {"日本", "Jisho: 日本語", 122}, 83 {"go doc", "Go docs dev-tools https://go.dev/doc/", 245}, 84 {"xyz", "abc", -1}, 85 {"", "anything", 0}, 86 {"GITHUB", "https://github.com/equwal/sbm", 126}, 87 {"eqsbm", "https://github.com/equwal/sbm", 26}, 88 {"a b", "b a", 242}, 89 {"tools c", "suckless software c tools", 226}, 90 {"wiki", "Wikipedia, the free encyclopedia https://en.wikipedia.org", 124}, 91 {"wkp", "Wikipedia, the free encyclopedia https://en.wikipedia.org", 6}, 92 {"é", "Café crème", 101}, 93 {"cr", "Café crème", 122}, 94 } { 95 if got := score(c.query, c.text); got != c.want { 96 t.Errorf("score(%q, %q) = %d, want %d", c.query, c.text, got, c.want) 97 } 98 } 99 } 100 101 // letters draws text from few letters, with case, white space, punctuation 102 // and letters of more than one byte, so that searches often match. 103 var letters = rapid.StringOf(rapid.SampledFrom([]rune("abcABC xyz.-/:日本éÉ\t"))) 104 105 // matches is a slow reference for the search: case aside, the letters of 106 // each word of the query occur in the text in their order. 107 func matches(query, text string) bool { 108 hay := []rune(strings.ToLower(text)) 109 for _, word := range strings.FieldsFunc(strings.ToLower(query), func(r rune) bool { return r == ' ' || r == '\t' }) { 110 w, i := []rune(word), 0 111 for _, r := range hay { 112 if i < len(w) && r == w[i] { 113 i++ 114 } 115 } 116 if i < len(w) { 117 return false 118 } 119 } 120 return true 121 } 122 123 func TestScoreMatchesAsTheReference(t *testing.T) { 124 rapid.Check(t, func(t *rapid.T) { 125 query, text := letters.Draw(t, "query"), letters.Draw(t, "text") 126 if s := score(query, text); (s >= 0) != matches(query, text) || s < -1 { 127 t.Fatalf("score(%q, %q) = %d, reference match %v", query, text, s, matches(query, text)) 128 } 129 }) 130 } 131 132 func TestScoreFindsEachPartOfTheText(t *testing.T) { 133 rapid.Check(t, func(t *rapid.T) { 134 text := letters.Draw(t, "text") 135 rs := []rune(strings.ToLower(text)) 136 var words []string 137 for range rapid.IntRange(1, 3).Draw(t, "words") { 138 i := rapid.IntRange(0, len(rs)).Draw(t, "i") 139 j := rapid.IntRange(i, len(rs)).Draw(t, "j") 140 words = append(words, string(rs[i:j])) 141 } 142 query := strings.Join(words, " ") 143 n := len(strings.FieldsFunc(query, func(r rune) bool { return r == ' ' || r == '\t' })) 144 if s := score(query, text); s < 100*n { 145 t.Fatalf("score(%q, %q) = %d, want at least %d", query, text, s, 100*n) 146 } 147 }) 148 } 149 150 func TestScoreIgnoresCaseAndWordOrder(t *testing.T) { 151 rapid.Check(t, func(t *rapid.T) { 152 a, b, text := letters.Draw(t, "a"), letters.Draw(t, "b"), letters.Draw(t, "text") 153 s := score(a+" "+b, text) 154 if got := score(strings.ToUpper(b+" "+a), text); got != s { 155 t.Fatalf("score(%q) = %d, but score(%q) = %d", strings.ToUpper(b+" "+a), got, a+" "+b, s) 156 } 157 if got := score(a+" "+b, strings.ToUpper(text)); got != s { 158 t.Fatalf("in %q: %d, in %q: %d", strings.ToUpper(text), got, text, s) 159 } 160 }) 161 } 162 163 func FuzzScore(f *testing.F) { 164 f.Add("sls", "suckless") 165 f.Add("é", "Café") 166 f.Add(string([]byte{0xff}), "a"+string([]byte{0xff, 0xe6, 0x97})+"b") 167 f.Fuzz(func(t *testing.T, query, text string) { 168 if s := score(query, text); s < -1 { 169 t.Fatalf("score(%q, %q) = %d", query, text, s) 170 } 171 }) 172 } 173 174 func TestSearchKeepsTheOrderOfEqualScores(t *testing.T) { 175 bs := parse("https://a.org\tgo\t\nhttps://b.org\tgolang\t\nhttps://c.org\tgo\t\nhttps://d.org\tpython\t\n") 176 var got []string 177 for _, b := range search(bs, "go") { 178 got = append(got, b.URL) 179 } 180 // "go" at the start of a word scores the same in each; b.org has it too. 181 if want := []string{"https://a.org", "https://b.org", "https://c.org"}; !slices.Equal(got, want) { 182 t.Errorf("search: %q, want %q", got, want) 183 } 184 } 185 186 func TestTarget(t *testing.T) { 187 for text, want := range map[string]string{ 188 "suckless.org/dwm": "https://suckless.org/dwm", 189 "gemini://x.org": "gemini://x.org", 190 "posix sh printf": "https://duckduckgo.com/?q=posix+sh+printf", 191 " word ": "https://duckduckgo.com/?q=word", 192 "c++": "https://duckduckgo.com/?q=c%2B%2B", 193 } { 194 if got := target(text); got != want { 195 t.Errorf("target(%q) = %q, want %q", text, got, want) 196 } 197 } 198 } 199 200 // Each imported row with a new URL comes in once. The other rows are known, 201 // or are no bookmark. 202 func TestMergeRowsAddsEachURLOnce(t *testing.T) { 203 rapid.Check(t, func(t *rapid.T) { 204 text, rows := file(t, "file"), lines(file(t, "rows")) 205 add, skipped := mergeRows(text, rows) 206 seen := map[string]bool{} 207 for _, l := range lines(text) { 208 seen[key(l)] = true 209 } 210 for _, a := range add { 211 if k := key(a); k == "" || seen[k] { 212 t.Fatalf("added %q, but its URL is known", a) 213 } 214 seen[key(a)] = true 215 } 216 empty := 0 217 for _, r := range rows { 218 if key(r) == "" { 219 empty++ 220 } 221 } 222 if len(add)+skipped+empty != len(rows) { 223 t.Fatalf("%d added, %d skipped and %d empty of %d rows", len(add), skipped, empty, len(rows)) 224 } 225 }) 226 } 227 228 func TestFolderTag(t *testing.T) { 229 for name, want := range map[string]string{"Dev Tools": "dev-tools", " -C++ & Go- ": "c-go", "日本語": ""} { 230 if got := folderTag(name); got != want { 231 t.Errorf("folderTag(%q) = %q, want %q", name, got, want) 232 } 233 } 234 }