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 & 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 }