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  }