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


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 }