101 lines
3.7 KiB
Go
101 lines
3.7 KiB
Go
package store
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"testing"
|
||
|
|
"time"
|
||
|
|
)
|
||
|
|
|
||
|
|
// A shadow id must never be mistaken for an Emby user id: the two are substituted for one
|
||
|
|
// another in twenty tables that cannot tell the difference, so telling them apart by
|
||
|
|
// inspection is the safety property the whole scheme rests on.
|
||
|
|
func TestShadowViewerIDIsDistinguishableFromEmbyUserID(t *testing.T) {
|
||
|
|
id, err := NewShadowViewerID()
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("mint shadow id: %v", err)
|
||
|
|
}
|
||
|
|
if !IsShadowViewerID(id) {
|
||
|
|
t.Fatalf("minted id %q not recognised as a shadow id", id)
|
||
|
|
}
|
||
|
|
// Emby's are 32 hex characters with no prefix.
|
||
|
|
if IsShadowViewerID("8f14e45fceea167a5a36dedd4bea2543") {
|
||
|
|
t.Fatal("an Emby user id was read as a shadow viewer")
|
||
|
|
}
|
||
|
|
if IsShadowViewerID("") || IsShadowViewerID("v") || IsShadowViewerID("viewer") {
|
||
|
|
t.Fatal("a short string was read as a shadow viewer")
|
||
|
|
}
|
||
|
|
other, err := NewShadowViewerID()
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("mint second shadow id: %v", err)
|
||
|
|
}
|
||
|
|
if other == id {
|
||
|
|
t.Fatal("two minted ids collided")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestPlayedFromPosition(t *testing.T) {
|
||
|
|
const hour = int64(36_000_000_000) // one hour in Emby ticks
|
||
|
|
|
||
|
|
for _, tc := range []struct {
|
||
|
|
name string
|
||
|
|
position int64
|
||
|
|
runtime int64
|
||
|
|
want bool
|
||
|
|
}{
|
||
|
|
{"finished", hour, hour, true},
|
||
|
|
{"at the threshold", hour * 9 / 10, hour, true},
|
||
|
|
{"just short of it", hour*9/10 - 1, hour, false},
|
||
|
|
{"barely started", hour / 100, hour, false},
|
||
|
|
// A runtime of zero means the length was not known, not that the title is zero
|
||
|
|
// long. Reading it the other way marks everything watched at the first second.
|
||
|
|
{"unknown runtime", hour, 0, false},
|
||
|
|
{"nothing watched", 0, hour, false},
|
||
|
|
{"negative position", -hour, hour, false},
|
||
|
|
// Playing past the stated runtime is ordinary — a container whose duration is a
|
||
|
|
// little short of its own last frame.
|
||
|
|
{"past the end", hour * 2, hour, true},
|
||
|
|
} {
|
||
|
|
t.Run(tc.name, func(t *testing.T) {
|
||
|
|
if got := PlayedFromPosition(tc.position, tc.runtime); got != tc.want {
|
||
|
|
t.Fatalf("PlayedFromPosition(%d, %d) = %v, want %v",
|
||
|
|
tc.position, tc.runtime, got, tc.want)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// The rollup a series card's tick and count are built from. It is done in Go rather than in
|
||
|
|
// SQL — the query groups by the season/series pair and this folds it twice — so it is worth
|
||
|
|
// pinning that both directions add up and that the date is the latest of them.
|
||
|
|
func TestViewerAggregateRollup(t *testing.T) {
|
||
|
|
earlier := time.Date(2026, 8, 1, 20, 0, 0, 0, time.UTC)
|
||
|
|
later := time.Date(2026, 8, 18, 21, 30, 0, 0, time.UTC)
|
||
|
|
aggregates := map[string]ViewerAggregate{}
|
||
|
|
|
||
|
|
// Two seasons of one show, folded into the series and kept apart per season.
|
||
|
|
addViewerAggregate(aggregates, "show-1", 10, 10, &earlier)
|
||
|
|
addViewerAggregate(aggregates, "season-1", 10, 10, &earlier)
|
||
|
|
addViewerAggregate(aggregates, "show-1", 8, 3, &later)
|
||
|
|
addViewerAggregate(aggregates, "season-2", 8, 3, &later)
|
||
|
|
|
||
|
|
series := aggregates["show-1"]
|
||
|
|
if series.Total != 18 || series.Played != 13 {
|
||
|
|
t.Errorf("series rollup = %d of %d, want 13 of 18", series.Played, series.Total)
|
||
|
|
}
|
||
|
|
if series.LastPlayedAt == nil || !series.LastPlayedAt.Equal(later) {
|
||
|
|
t.Errorf("series last played = %v, want the later of the two", series.LastPlayedAt)
|
||
|
|
}
|
||
|
|
if got := aggregates["season-1"]; got.Total != 10 || got.Played != 10 {
|
||
|
|
t.Errorf("season one = %d of %d, want 10 of 10", got.Played, got.Total)
|
||
|
|
}
|
||
|
|
if got := aggregates["season-2"]; got.Total != 8 || got.Played != 3 {
|
||
|
|
t.Errorf("season two = %d of %d, want 3 of 8", got.Played, got.Total)
|
||
|
|
}
|
||
|
|
|
||
|
|
// An episode filed under no series or no season contributes to neither, rather than to
|
||
|
|
// a row keyed on the empty string — which would be an aggregate about nothing.
|
||
|
|
addViewerAggregate(aggregates, "", 5, 5, &later)
|
||
|
|
if _, ok := aggregates[""]; ok {
|
||
|
|
t.Error("an unfiled episode produced an aggregate")
|
||
|
|
}
|
||
|
|
}
|