github.com/derat/nup@v0.0.0-20230418113745-15592ba7c620/test/web/song.go (about) 1 // Copyright 2021 Daniel Erat. 2 // All rights reserved. 3 4 package web 5 6 import ( 7 "fmt" 8 "reflect" 9 "strings" 10 "time" 11 12 "github.com/derat/nup/server/db" 13 "github.com/derat/nup/test" 14 ) 15 16 // newSong creates a new db.Song containing the supplied data. 17 func newSong(artist, title, album string, fields ...songField) db.Song { 18 s := db.Song{ 19 Artist: artist, 20 Title: title, 21 Album: album, 22 SHA1: fmt.Sprintf("%s-%s-%s", artist, title, album), 23 AlbumID: artist + "-" + album, 24 Filename: test.Song10s.Filename, 25 } 26 for _, f := range fields { 27 f(&s) 28 } 29 // Gross hack: infer the length from the filename. 30 if s.Length == 0 { 31 for _, ks := range []db.Song{test.Song0s, test.Song1s, test.Song5s, test.Song10s} { 32 if s.Filename == ks.Filename { 33 s.Length = ks.Length 34 } 35 } 36 } 37 return s 38 } 39 40 // songField describes a field that should be set by newSong. 41 type songField func(*db.Song) 42 43 func withDate(t time.Time) songField { return func(s *db.Song) { s.Date = t } } 44 func withDisc(d int) songField { return func(s *db.Song) { s.Disc = d } } 45 func withDiscSubtitle(t string) songField { return func(s *db.Song) { s.DiscSubtitle = t } } 46 func withFilename(f string) songField { return func(s *db.Song) { s.Filename = f } } 47 func withLength(l float64) songField { return func(s *db.Song) { s.Length = l } } 48 func withRating(r int) songField { return func(s *db.Song) { s.Rating = r } } 49 func withTags(t ...string) songField { return func(s *db.Song) { s.Tags = t } } 50 func withTrack(t int) songField { return func(s *db.Song) { s.Track = t } } 51 func withPlays(ts ...time.Time) songField { 52 return func(s *db.Song) { 53 for _, t := range ts { 54 s.Plays = append(s.Plays, db.NewPlay(t, "")) 55 } 56 } 57 } 58 59 // joinSongs flattens songs, consisting of db.Song and []db.Song items, into a single slice. 60 func joinSongs(songs ...interface{}) []db.Song { 61 var all []db.Song 62 for _, s := range songs { 63 if ts, ok := s.(db.Song); ok { 64 all = append(all, ts) 65 } else if ts, ok := s.([]db.Song); ok { 66 all = append(all, ts...) 67 } else { 68 panic(fmt.Sprintf("Invalid type %T (must be db.Song or []db.Song)", s)) 69 } 70 } 71 return all 72 } 73 74 // songInfo contains information about a song in the web interface or server. 75 // 76 // I've made a few attempts to get rid of the use of db.Song in this code 77 // (other than when posting songs to the server), but it's harder than it 78 // seems: for example, songs always need filenames when they're sent to the 79 // server, but we don't want to check filenames when we're inspecting the 80 // playlist or search results. 81 type songInfo struct { 82 artist, title, album string // metadata from either <song-table> row or <play-view> 83 84 active *bool // song row is active/highlighted 85 checked *bool // song row is checked 86 menu *bool // song row has context menu 87 paused *bool // audio element is paused 88 ended *bool // audio element is ended 89 90 rating *int // rating from the cover image in [0, 5] 91 filename *string // filename from audio element src attribute 92 imgTitle *string // cover image title attr, e.g. "Rating: ★★★☆☆\nTags: guitar rock" 93 timeStr *string // displayed time, e.g. "0:00 / 0:05" 94 95 srvRating *int // server rating in [1, 5] or 0 for unrated 96 srvTags []string // server tags in ascending order 97 srvPlays [][2]time.Time // server play time lower/upper bounds in ascending order 98 99 timeout *time.Duration // hack for using longer timeouts in checks 100 } 101 102 // makeSongInfo constructs a basic songInfo using data from s. 103 func makeSongInfo(s db.Song) songInfo { 104 return songInfo{ 105 artist: s.Artist, 106 title: s.Title, 107 album: s.Album, 108 } 109 } 110 111 func (s *songInfo) String() string { 112 if s == nil { 113 return "nil" 114 } 115 116 str := fmt.Sprintf("%q %q %q", s.artist, s.title, s.album) 117 118 // Describe optional bools. 119 for _, f := range []struct { 120 pos, neg string 121 val *bool 122 }{ 123 {"active", "inactive", s.active}, 124 {"checked", "unchecked", s.checked}, 125 {"ended", "unended", s.ended}, 126 {"menu", "no-menu", s.menu}, 127 {"paused", "playing", s.paused}, 128 } { 129 if f.val != nil { 130 if *f.val { 131 str += " " + f.pos 132 } else { 133 str += " " + f.neg 134 } 135 } 136 } 137 138 // Describe optional strings. 139 for _, f := range []struct { 140 name string 141 val *string 142 }{ 143 {"filename", s.filename}, 144 {"time", s.timeStr}, 145 {"title", s.imgTitle}, 146 } { 147 if f.val != nil { 148 str += fmt.Sprintf(" %s=%q", f.name, *f.val) 149 } 150 } 151 152 // Describe optional ints. 153 for _, f := range []struct { 154 name string 155 val *int 156 }{ 157 {"rating", s.rating}, 158 {"srvRating", s.srvRating}, 159 } { 160 if f.val != nil { 161 str += fmt.Sprintf(" %s=%d", f.name, *f.val) 162 } 163 } 164 165 // Add other miscellaneous junk. 166 if s.srvTags != nil { 167 str += fmt.Sprintf(" tags=%v", s.srvTags) 168 } 169 if s.srvPlays != nil { 170 const tf = "2006-01-02-15:04:05" 171 var ps []string 172 for _, p := range s.srvPlays { 173 if p[0].Equal(p[1]) { 174 ps = append(ps, p[0].Local().Format(tf)) 175 } else { 176 ps = append(ps, p[0].Local().Format(tf)+"/"+p[1].Local().Format(tf)) 177 } 178 } 179 str += fmt.Sprintf(" plays=[%s]", strings.Join(ps, " ")) 180 } 181 182 return "[" + str + "]" 183 } 184 185 // getTimeout retuns s.timeout if non-nil or def otherwise. 186 func (s *songInfo) getTimeout(def time.Duration) time.Duration { 187 if s.timeout != nil { 188 return *s.timeout 189 } 190 return def 191 } 192 193 // songCheck specifies a check to perform on a song. 194 type songCheck func(*songInfo) 195 196 // See equivalently-named fields in songInfo for more info. 197 func isPaused(p bool) songCheck { return func(i *songInfo) { i.paused = &p } } 198 func isEnded(e bool) songCheck { return func(i *songInfo) { i.ended = &e } } 199 func hasFilename(f string) songCheck { return func(i *songInfo) { i.filename = &f } } 200 func hasImgTitle(t string) songCheck { return func(i *songInfo) { i.imgTitle = &t } } 201 func hasRating(r int) songCheck { return func(i *songInfo) { i.rating = &r } } 202 func hasTimeStr(s string) songCheck { return func(i *songInfo) { i.timeStr = &s } } 203 func hasSrvRating(r int) songCheck { return func(i *songInfo) { i.srvRating = &r } } 204 func hasSrvTags(t ...string) songCheck { return func(i *songInfo) { i.srvTags = t } } 205 206 // hasSrvPlay should be called once for each play (in ascending order). 207 func hasSrvPlay(lower, upper time.Time) songCheck { 208 return func(si *songInfo) { 209 si.srvPlays = append(si.srvPlays, [2]time.Time{lower, upper}) 210 } 211 } 212 213 // hasNoSrvPlays asserts that there are no recorded plays. 214 func hasNoSrvPlays() songCheck { return func(i *songInfo) { i.srvPlays = [][2]time.Time{} } } 215 216 // useTimeout sets a custom timeout to use when waiting for the condition. 217 func useTimeout(t time.Duration) songCheck { return func(i *songInfo) { i.timeout = &t } } 218 219 // songInfosEqual returns true if want and got have the same artist, title, and album 220 // and any additional optional fields specified in want also match. 221 func songInfosEqual(want, got songInfo) bool { 222 // Compare bools. 223 for _, t := range []struct { 224 want, got *bool 225 }{ 226 {want.active, got.active}, 227 {want.checked, got.checked}, 228 {want.ended, got.ended}, 229 {want.menu, got.menu}, 230 {want.paused, got.paused}, 231 } { 232 if t.want != nil && (t.got == nil || *t.got != *t.want) { 233 return false 234 } 235 } 236 237 // Compare strings. 238 for _, t := range []struct { 239 want, got *string 240 }{ 241 {&want.artist, &got.artist}, 242 {&want.title, &got.title}, 243 {&want.album, &got.album}, 244 {want.filename, got.filename}, 245 {want.imgTitle, got.imgTitle}, 246 {want.timeStr, got.timeStr}, 247 } { 248 if t.want != nil && (t.got == nil || *t.got != *t.want) { 249 return false 250 } 251 } 252 253 // Compare ints. 254 for _, t := range []struct { 255 want, got *int 256 }{ 257 {want.rating, got.rating}, 258 {want.srvRating, got.srvRating}, 259 } { 260 if t.want != nil && (t.got == nil || *t.got != *t.want) { 261 return false 262 } 263 } 264 265 if want.srvTags != nil && !reflect.DeepEqual(want.srvTags, got.srvTags) { 266 return false 267 } 268 if want.srvPlays != nil { 269 if len(want.srvPlays) != len(got.srvPlays) { 270 return false 271 } 272 for i, bounds := range want.srvPlays { 273 t := got.srvPlays[i][0] 274 if t.Before(bounds[0]) || t.After(bounds[1]) { 275 return false 276 } 277 } 278 } 279 280 return true 281 } 282 283 // songListCheck specifies a check to perform on a list of songs. 284 type songListCheck func([]songInfo) 285 286 // hasChecked checks that songs' checkboxes match vals. 287 func hasChecked(vals ...bool) songListCheck { 288 return func(infos []songInfo) { 289 for i := range infos { 290 infos[i].checked = &vals[i] 291 } 292 } 293 } 294 295 // hasActive indicates that the song at idx should be active. 296 func hasActive(idx int) songListCheck { 297 return func(infos []songInfo) { 298 for i := range infos { 299 v := i == idx 300 infos[i].active = &v 301 } 302 } 303 } 304 305 // hasMenu indicates that a context menu should be shown for the song at idx. 306 func hasMenu(idx int) songListCheck { 307 return func(infos []songInfo) { 308 for i := range infos { 309 v := i == idx 310 infos[i].menu = &v 311 } 312 } 313 } 314 315 // songInfoSlicesEqual returns true if want and got are the same length 316 // and songInfosEqual returns true for corresponding elements. 317 func songInfoSlicesEqual(want, got []songInfo) bool { 318 if len(want) != len(got) { 319 return false 320 } 321 for i := range want { 322 if !songInfosEqual(want[i], got[i]) { 323 return false 324 } 325 } 326 return true 327 }