Recently Written · git

sbm-webpublish

Publish an sbm bookmark file as a list of links on a web site

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

Log | Files | Refs


links_test.go (8245 bytes)

1 package main
2 
3 import (
4 	"slices"
5 	"strings"
6 	"testing"
7 	"time"
8 
9 	"pgregory.net/rapid"
10 )
11 
12 // genRow makes a file line: a URL, a description, tags and sometimes a feed,
13 // with tabs, spaces and line breaks in awkward places.
14 func genRow(t *rapid.T) string {
15 	host := rapid.SampledFrom([]string{"a.com", "www.a.com", "b.org", "c.net"}).Draw(t, "host")
16 	path := rapid.SampledFrom([]string{"", "/", "/x", "/x/", "/y"}).Draw(t, "path")
17 	sch := rapid.SampledFrom([]string{"https://", "http://", ""}).Draw(t, "scheme")
18 	desc := rapid.StringMatching(`[a-z \t\n]{0,8}`).Draw(t, "desc")
19 	tags := rapid.StringMatching(`[a-z ]{0,8}`).Draw(t, "tags")
20 	feed := rapid.SampledFrom([]string{"", "", "https://a.com/feed", "b.org/rss", " c.net/atom.xml "}).Draw(t, "feed")
21 	return line(sch+host+path, desc, tags, feed)
22 }
23 
24 // countDates gives the number of date lines of text.
25 func countDates(text string) int {
26 	n := 0
27 	for _, s := range lines(text) {
28 		if _, ok := dateOf(s); ok {
29 			n++
30 		}
31 	}
32 	return n
33 }
34 
35 func TestLineParsesBack(t *testing.T) {
36 	rapid.Check(t, func(t *rapid.T) {
37 		u := rapid.StringMatching(`[a-z:/. ]{1,12}`).Draw(t, "url")
38 		desc := rapid.String().Draw(t, "desc")
39 		tags := rapid.StringMatching(`[a-z \t]{0,12}`).Draw(t, "tags")
40 		feed := rapid.StringMatching(`[a-z:/. \t]{0,12}`).Draw(t, "feed")
41 		row := line(u, desc, tags, feed)
42 		if row == "" {
43 			return
44 		}
45 		// A line without a feed has the three fields of bm.
46 		tabs := 2
47 		if squeeze(feed) != "" {
48 			tabs = 3
49 		}
50 		if strings.ContainsAny(row, "\n\r") || strings.Count(row, "\t") != tabs {
51 			t.Fatalf("bad line %q", row)
52 		}
53 		l, ok := parseLine(row)
54 		if !ok || l.URL != squeeze(u) || !slices.Equal(l.Tags, strings.Fields(tags)) || l.Feed != squeeze(feed) {
55 			t.Fatalf("line %q parsed as %+v", row, l)
56 		}
57 	})
58 }
59 
60 func TestMergeKeepsOneLineForEachURL(t *testing.T) {
61 	rapid.Check(t, func(t *rapid.T) {
62 		base, _, _, _ := merge("", rapid.SliceOf(rapid.Custom(genRow)).Draw(t, "base"))
63 		rows := rapid.SliceOf(rapid.Custom(genRow)).Draw(t, "rows")
64 		text, added, skipped, _ := merge(base, rows)
65 		if added+skipped != len(rows) {
66 			t.Fatalf("added %d + skipped %d != %d rows", added, skipped, len(rows))
67 		}
68 		seen := map[string]bool{}
69 		for _, s := range lines(text) {
70 			if seen[key(s)] {
71 				t.Fatalf("duplicate %q in %q", key(s), text)
72 			}
73 			seen[key(s)] = true
74 		}
75 		for _, r := range rows {
76 			if !seen[key(r)] {
77 				t.Fatalf("row %q is missing", r)
78 			}
79 		}
80 		if again, n, _, f := merge(text, rows); n != 0 || f != 0 || again != text {
81 			t.Fatalf("second merge changed the file")
82 		}
83 	})
84 }
85 
86 // TestMergeFeeds: after merge, each link has the first feed of its URL: the
87 // feed of its line in the text, else the feed of the first row with that
88 // URL that has a feed.
89 func TestMergeFeeds(t *testing.T) {
90 	rapid.Check(t, func(t *rapid.T) {
91 		base, _, _, _ := merge("", rapid.SliceOf(rapid.Custom(genRow)).Draw(t, "base"))
92 		rows := rapid.SliceOf(rapid.Custom(genRow)).Draw(t, "rows")
93 		out, _, _, feeds := merge(base, rows)
94 		want, seen, filled := map[string]string{}, map[string]bool{}, 0
95 		for _, s := range append(lines(base), rows...) {
96 			k := key(s)
97 			l, _ := parseLine(s)
98 			switch {
99 			case k == "":
100 			case !seen[k]:
101 				seen[k], want[k] = true, l.Feed
102 			case want[k] == "" && l.Feed != "":
103 				want[k] = l.Feed
104 				filled++
105 			}
106 		}
107 		if feeds != filled {
108 			t.Fatalf("merge gave %d feeds, want %d", feeds, filled)
109 		}
110 		for _, s := range lines(out) {
111 			if l, _ := parseLine(s); l.Feed != want[key(s)] {
112 				t.Fatalf("%q has the feed %q, want %q", s, l.Feed, want[key(s)])
113 			}
114 		}
115 	})
116 }
117 
118 func TestSetFeed(t *testing.T) {
119 	rapid.Check(t, func(t *rapid.T) {
120 		text, _, _, _ := merge("", rapid.SliceOf(rapid.Custom(genRow)).Draw(t, "rows"))
121 		k := key(genRow(t))
122 		feed := rapid.SampledFrom([]string{"", "https://x.org/feed", " y.org/rss "}).Draw(t, "feed")
123 		old, ls := lines(text), lines(setFeed(text, k, feed))
124 		if len(ls) != len(old) {
125 			t.Fatalf("%d lines, want %d", len(ls), len(old))
126 		}
127 		for i := range old {
128 			a, _ := parseLine(old[i])
129 			b, _ := parseLine(ls[i])
130 			want := a.Feed
131 			if key(old[i]) == k {
132 				want = squeeze(feed)
133 			}
134 			if b.URL != a.URL || b.Desc != a.Desc || !slices.Equal(b.Tags, a.Tags) || b.Feed != want {
135 				t.Fatalf("line %q became %q, want the feed %q", old[i], ls[i], want)
136 			}
137 		}
138 	})
139 }
140 
141 func TestRemove(t *testing.T) {
142 	rapid.Check(t, func(t *rapid.T) {
143 		text, _, _, _ := merge("", rapid.SliceOf(rapid.Custom(genRow)).Draw(t, "rows"))
144 		k := key(genRow(t))
145 		out := remove(text, k)
146 		var want []string
147 		for _, s := range lines(text) {
148 			if key(s) != k {
149 				want = append(want, s)
150 			}
151 		}
152 		if out != join(want) {
153 			t.Fatalf("remove %q from %q gave %q", k, text, out)
154 		}
155 	})
156 }
157 
158 func TestDateLineParsesBack(t *testing.T) {
159 	rapid.Check(t, func(t *rapid.T) {
160 		// From the year 1 to the year 9999: RFC 3339 has four digits for
161 		// the year.
162 		u := time.Unix(rapid.Int64Range(-62135596800, 253402300799).Draw(t, "unix"), rapid.Int64Range(0, 999999999).Draw(t, "ns"))
163 		got, ok := dateOf(dateLine(u))
164 		if !ok || !got.Equal(u.Truncate(time.Second)) {
165 			t.Fatalf("%q gave %v %v", dateLine(u), got, ok)
166 		}
167 	})
168 }
169 
170 func TestParseDates(t *testing.T) {
171 	d := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
172 	e := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
173 	ls := parse("a.com\tA\t\n# 2026-01-02T03:04:05Z\nb.com\tB\t\n# a comment\nc.com\tC\t\n#2026-01-01T00:00:00Z\nd.com\tD\t\n")
174 	want := []time.Time{{}, d, d, e}
175 	if len(ls) != len(want) {
176 		t.Fatalf("got %d links", len(ls))
177 	}
178 	for i, l := range ls {
179 		if !l.Added.Equal(want[i]) {
180 			t.Errorf("%s: added %v, want %v", l.URL, l.Added, want[i])
181 		}
182 	}
183 }
184 
185 func TestAddDatedDatesOnlyTheNewLinks(t *testing.T) {
186 	rapid.Check(t, func(t *rapid.T) {
187 		base, _, _, _ := merge("", rapid.SliceOf(rapid.Custom(genRow)).Draw(t, "base"))
188 		rows := rapid.SliceOf(rapid.Custom(genRow)).Draw(t, "rows")
189 		when := time.Unix(rapid.Int64Range(0, 4102444800).Draw(t, "when"), 0)
190 		out, added, _, feeds := addDated(base, rows, when)
191 		old, ls := parse(base), parse(out)
192 		dates := countDates(base)
193 		if added > 0 {
194 			dates++
195 		}
196 		switch {
197 		case added == 0 && feeds == 0 && out != base:
198 			t.Fatalf("no link and no feed added, but the text changed to %q", out)
199 		case countDates(out) != dates:
200 			t.Fatalf("%d date lines in %q, want %d", countDates(out), out, dates)
201 		case len(ls) != len(old)+added:
202 			t.Fatalf("%d links, want %d + %d", len(ls), len(old), added)
203 		}
204 		for i, l := range ls {
205 			if i < len(old) && (l.URL != old[i].URL || !l.Added.Equal(old[i].Added)) || i >= len(old) && !l.Added.Equal(when) {
206 				t.Fatalf("link %d %q has the time %v", i, l.URL, l.Added)
207 			}
208 		}
209 	})
210 }
211 
212 func TestHrefIsOnlyHTTP(t *testing.T) {
213 	for u, want := range map[string]string{
214 		"https://a.com":         "https://a.com",
215 		"HTTP://a.com":          "HTTP://a.com",
216 		"a.com/x":               "https://a.com/x",
217 		"":                      "",
218 		"javascript:alert(1)":   "",
219 		"data:text/html,x":      "",
220 		"ftp://a.com":           "",
221 		"JavaScript:alert(1)//": "",
222 	} {
223 		if got := (Link{URL: u}).Href(); got != want {
224 			t.Errorf("Href(%q) = %q, want %q", u, got, want)
225 		}
226 		if got := (Link{Feed: u}).FeedHref(); got != want {
227 			t.Errorf("FeedHref(%q) = %q, want %q", u, got, want)
228 		}
229 	}
230 }
231 
232 func TestAddress(t *testing.T) {
233 	for u, want := range map[string]string{
234 		"https://github.com/equwal/sbm/": "github.com/equwal/sbm",
235 		"http://a.com":                   "a.com",
236 		"a.com/x?y=1":                    "a.com/x?y=1",
237 	} {
238 		if got := (Link{URL: u}).Address(); got != want {
239 			t.Errorf("Address(%q) = %q, want %q", u, got, want)
240 		}
241 	}
242 }
243 
244 func TestFromHTML(t *testing.T) {
245 	in := `<!DOCTYPE NETSCAPE-Bookmark-file-1>
246 <DL><p>
247 <DT><H3>Dev Tools</H3>
248 <DL><p>
249 <DT><A HREF="https://go.dev/" ADD_DATE="1">Go &amp; more</A>
250 <DT><A HREF="javascript:x">bad</A>
251 </DL><p>
252 <DT><A HREF="https://a.com">A</A>
253 </DL>`
254 	want := []string{"https://go.dev/\tGo & more\tdev-tools", "https://a.com\tA\t"}
255 	if got := importRows(in); !slices.Equal(got, want) {
256 		t.Fatalf("got %q, want %q", got, want)
257 	}
258 }
259 
260 func TestMergeOldFormat(t *testing.T) {
261 	text, added, _, _ := merge("", []string{"https://a.com some site"})
262 	if added != 1 || text != "https://a.com\tsome site\t\n" {
263 		t.Fatalf("got %q", text)
264 	}
265 }