arnaucube
arnaucube/jellyfin-toolsPublic Self-hosted repository
jellyfin-tools/jellyfin-year-review/internal/generate/stats_test.go
jellyfin-year-review/internal/generate/stats_test.goRaw
  1package generate
  2
  3import (
  4	"encoding/json"
  5	"os"
  6	"path/filepath"
  7	"reflect"
  8	"testing"
  9	"time"
 10
 11	"reelreview/internal/model"
 12)
 13
 14const alice = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
 15const bob = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
 16
 17func instant(s string) time.Time {
 18	t, e := time.Parse(time.RFC3339, s)
 19	if e != nil {
 20		panic(e)
 21	}
 22	return t
 23}
 24func number(n float64) *float64 { return &n }
 25func fixture() model.Snapshot {
 26	return model.Snapshot{Version: 1, CapturedAt: instant("2026-01-01T00:00:00Z"), Source: "synthetic exact intervals", Coverage: "2025 full synthetic fixture", Users: []model.User{{ID: alice, Name: "Alice"}}, Items: []model.Item{{ID: "film", Title: "A Film", Type: "Movie", Runtime: 7200, Genres: []string{"Drama", "Adventure"}}}}
 27}
 28func play(id, start, end string) model.Playback {
 29	a, b := instant(start), instant(end)
 30	return model.Playback{ID: id, UserID: alice, ItemID: "film", Start: a, End: b, Exact: true, Seconds: b.Sub(a).Seconds(), PositionStart: number(0), PositionEnd: number(b.Sub(a).Seconds())}
 31}
 32func compute(t *testing.T, s model.Snapshot, c Config) []model.Review {
 33	t.Helper()
 34	r, e := Compute(s, c)
 35	if e != nil {
 36		t.Fatal(e)
 37	}
 38	return r
 39}
 40func TestResumedCoverageAndDuplicate(t *testing.T) {
 41	s := fixture()
 42	a := play("a", "2025-01-02T12:00:00Z", "2025-01-02T13:00:00Z")
 43	b := play("b", "2025-01-03T12:00:00Z", "2025-01-03T13:00:00Z")
 44	b.PositionStart = number(3600)
 45	b.PositionEnd = number(7200)
 46	s.Playback = []model.Playback{a, a, b}
 47	r := compute(t, s, DefaultConfig(2025))[0]
 48	if r.Seconds != 7200 || r.Records != 2 || len(r.Abandoned) != 0 || *r.Movies[0].Completion != 100 {
 49		t.Fatalf("resumed/duplicate result: %+v", r)
 50	}
 51	b.PositionStart = number(0)
 52	b.PositionEnd = number(3600)
 53	s.Playback = []model.Playback{a, b}
 54	r = compute(t, s, DefaultConfig(2025))[0]
 55	if len(r.Abandoned) != 1 || *r.Abandoned[0].Completion != 50 {
 56		t.Fatal("repeated positions inflated completion")
 57	}
 58	b.ID = a.ID
 59	s.Playback = []model.Playback{a, b}
 60	if _, e := Compute(s, DefaultConfig(2025)); e == nil {
 61		t.Fatal("conflicting duplicate accepted")
 62	}
 63}
 64func TestYearMidnightAndDST(t *testing.T) {
 65	tests := []struct {
 66		name, zone, start, end string
 67		seconds                float64
 68		hours                  map[int]float64
 69		days                   int
 70	}{
 71		{"new year", "UTC", "2024-12-31T23:30:00Z", "2025-01-01T00:30:00Z", 1800, map[int]float64{0: 30}, 1},
 72		{"local boundary", "America/New_York", "2025-01-01T04:30:00Z", "2025-01-01T05:30:00Z", 1800, map[int]float64{0: 30}, 1},
 73		{"spring", "America/New_York", "2025-03-09T06:30:00Z", "2025-03-09T07:30:00Z", 3600, map[int]float64{1: 30, 2: 0, 3: 30}, 1},
 74		{"fall", "America/New_York", "2025-11-02T05:30:00Z", "2025-11-02T06:30:00Z", 3600, map[int]float64{1: 60}, 1},
 75		{"midnight", "UTC", "2025-04-01T23:30:00Z", "2025-04-02T00:30:00Z", 3600, map[int]float64{23: 30, 0: 30}, 2},
 76	}
 77	for _, tt := range tests {
 78		t.Run(tt.name, func(t *testing.T) {
 79			s := fixture()
 80			s.Playback = []model.Playback{play("a", tt.start, tt.end)}
 81			c := DefaultConfig(2025)
 82			c.Timezone = tt.zone
 83			r := compute(t, s, c)[0]
 84			if r.Seconds != tt.seconds || r.ActiveDays != tt.days {
 85				t.Fatalf("bad boundary: %f, %d", r.Seconds, r.ActiveDays)
 86			}
 87			for h, want := range tt.hours {
 88				if r.Hourly[h] != want {
 89					t.Fatalf("hour %d got %v want %v", h, r.Hourly[h], want)
 90				}
 91			}
 92		})
 93	}
 94}
 95func TestMarathonOverlapGapAndTie(t *testing.T) {
 96	v := []span{{instant("2025-01-01T00:00:00Z"), instant("2025-01-01T02:00:00Z")}, {instant("2025-01-01T01:00:00Z"), instant("2025-01-01T03:00:00Z")}, {instant("2025-01-01T03:45:00Z"), instant("2025-01-01T04:45:00Z")}, {instant("2025-01-02T00:00:00Z"), instant("2025-01-02T04:00:00Z")}}
 97	r := marathon(v, 45*time.Minute)
 98	if r.Watched != 14400 || r.Elapsed != 17100 || r.Records != 3 || !r.Start.Equal(v[0].start) {
 99		t.Fatalf("overlap/gap/tie: %+v", r)
100	}
101	r = marathon(v, 44*time.Minute)
102	if r.Records != 1 || !r.Start.Equal(v[3].start) {
103		t.Fatal("gap was not enforced")
104	}
105}
106func TestRankingTiesAndSmallCohort(t *testing.T) {
107	rs := []model.Review{{Seconds: 7200, Availability: map[string]model.Availability{}}, {Seconds: 7200, Availability: map[string]model.Availability{}}, {Seconds: 3600, Availability: map[string]model.Availability{}}, {Seconds: 100, Availability: map[string]model.Availability{}}}
108	rank(rs)
109	if rs[0].Ranking.Rank != 1 || rs[1].Ranking.Rank != 1 || rs[2].Ranking.Rank != 3 || rs[0].Ranking.Percentile != 50 || rs[3].Ranking != nil {
110		t.Fatal("ranking ties wrong")
111	}
112	rank(rs[:1])
113	if rs[0].Ranking.Percentile != 0 || rs[0].Ranking.Cohort != 1 {
114		t.Fatal("solo cohort")
115	}
116}
117func TestMissingMetadataAndAggregateAvailability(t *testing.T) {
118	s := fixture()
119	s.Playback = []model.Playback{{ID: "aggregate", UserID: alice, ItemID: "film", ObservedAt: instant("2025-02-03T19:00:00Z"), Seconds: 3600}, {ID: "deleted", UserID: alice, ItemID: "deleted", ObservedAt: instant("2025-01-01T00:00:00Z"), Seconds: 900}}
120	r := compute(t, s, DefaultConfig(2025))[0]
121	if r.Seconds != 3600 || r.Hourly[19] != 1 || r.Marathon != nil || r.Personality != nil || len(r.Abandoned) != 0 || r.Availability["completion"].Available {
122		t.Fatalf("invented unavailable metrics: %+v", r)
123	}
124	s.Playback = nil
125	r = compute(t, s, DefaultConfig(2025))[0]
126	if r.Seconds != 0 || r.Records != 0 || r.Ranking != nil || r.Marathon != nil {
127		t.Fatal("empty history")
128	}
129	s.Items[0].Runtime = 0
130	s.Playback = []model.Playback{play("a", "2025-01-02T12:00:00Z", "2025-01-02T13:00:00Z")}
131	r = compute(t, s, DefaultConfig(2025))[0]
132	if r.Movies[0].Completion != nil {
133		t.Fatal("missing runtime fabricated completion")
134	}
135}
136func TestPersonalityGateAndPriority(t *testing.T) {
137	r := model.Review{Records: 10, Seconds: 36000, MovieSeconds: 36000}
138	r.Hourly[0] = 600
139	r.Weekdays[0] = 600
140	p := personality(r, true)
141	if p == nil || p.Name != "Night Owl" {
142		t.Fatal("deterministic first-match priority")
143	}
144	if personality(r, false) != nil {
145		t.Fatal("aggregate personality invented")
146	}
147	r.Records = 9
148	if personality(r, true) != nil {
149		t.Fatal("minimum data gate")
150	}
151}
152func TestDeterministicAndAtomicPublication(t *testing.T) {
153	s := fixture()
154	s.Playback = []model.Playback{play("a", "2025-01-02T12:00:00Z", "2025-01-02T13:00:00Z")}
155	c := DefaultConfig(2025)
156	a := compute(t, s, c)
157	b := compute(t, s, c)
158	ja, _ := json.Marshal(a)
159	jb, _ := json.Marshal(b)
160	if !reflect.DeepEqual(ja, jb) {
161		t.Fatal("nondeterministic computation")
162	}
163	root := t.TempDir()
164	if e := Publish(root, a, nil); e != nil {
165		t.Fatal(e)
166	}
167	before, _ := os.ReadFile(filepath.Join(root, "CURRENT"))
168	if e := Publish(root, a, map[string][]byte{"../unsafe": {1}}); e == nil {
169		t.Fatal("unsafe asset accepted")
170	}
171	after, _ := os.ReadFile(filepath.Join(root, "CURRENT"))
172	if string(before) != string(after) {
173		t.Fatal("failed generation changed pointer")
174	}
175	if e := Publish(root, a, nil); e != nil {
176		t.Fatal(e)
177	}
178	after, _ = os.ReadFile(filepath.Join(root, "CURRENT"))
179	if string(before) != string(after) {
180		t.Fatal("non deterministic publication")
181	}
182	c.Year = 2024
183	if e := Publish(root, compute(t, s, c), nil); e != nil {
184		t.Fatal(e)
185	}
186	m, _, e := Manifest(root)
187	if e != nil || !reflect.DeepEqual(m.Users[alice], []int{2024, 2025}) {
188		t.Fatal("year was lost")
189	}
190	if e := os.WriteFile(filepath.Join(root, ".generate.lock"), nil, 0600); e != nil {
191		t.Fatal(e)
192	}
193	if e := Publish(root, a, nil); e == nil {
194		t.Fatal("concurrent writer allowed")
195	}
196}
197
198func TestPerUserTimezoneThresholdAndGenreWeight(t *testing.T) {
199	s := fixture()
200	s.Users = append(s.Users, model.User{ID: bob, Name: "Bob"})
201	s.Items[0].Genres = []string{"Drama", "drama", "Adventure"}
202	a := play("a", "2025-01-01T00:00:00Z", "2025-01-01T01:00:00Z")
203	b := a
204	b.UserID = bob
205	tiny := play("tiny", "2025-01-02T12:00:00Z", "2025-01-02T12:00:30Z")
206	s.Playback = []model.Playback{a, b, tiny}
207	c := DefaultConfig(2025)
208	c.UserTimezones = map[string]string{bob: "America/New_York"}
209	rs := compute(t, s, c)
210	if rs[0].Seconds != 3600 || rs[0].Records != 1 || rs[0].Genres[0].Seconds != 1800 || rs[0].Genres[1].Seconds != 1800 {
211		t.Fatal("minimum threshold or distinct genre weighting")
212	}
213	if rs[1].Seconds != 0 || rs[1].Timezone != "America/New_York" {
214		t.Fatal("per-user local year not honored")
215	}
216}
217
218func TestAllFourteenPersonalityRulesReachable(t *testing.T) {
219	base := func(seconds float64) model.Review {
220		r := model.Review{Seconds: seconds, MovieSeconds: seconds / 2, TVSeconds: seconds / 2, Records: 100, UniqueTitles: 100, ActiveDays: 100}
221		for i := range r.Hourly {
222			r.Hourly[i] = seconds / 60 / 24
223		}
224		for i := range r.Weekdays {
225			r.Weekdays[i] = seconds / 60 / 7
226		}
227		for i := range r.Monthly {
228			r.Monthly[i] = seconds / 60 / 12
229		}
230		return r
231	}
232	cases := []struct {
233		name   string
234		change func(*model.Review)
235	}{
236		{"Night Owl", func(r *model.Review) { r.Hourly = [24]float64{0: r.Seconds / 60} }},
237		{"Early Bird", func(r *model.Review) { r.Hourly = [24]float64{6: r.Seconds / 60} }},
238		{"Weekend Warrior", func(r *model.Review) { r.Weekdays = [7]float64{0: r.Seconds / 60} }},
239		{"Marathoner", func(r *model.Review) { r.Marathon = &model.Marathon{Watched: 21600} }},
240		{"Binge Watcher", func(r *model.Review) {
241			r.TVSeconds = r.Seconds * .7
242			r.MovieSeconds = r.Seconds * .3
243			r.Marathon = &model.Marathon{Watched: 10800, Records: 3}
244		}},
245		{"Movie Buff", func(r *model.Review) { r.MovieSeconds = r.Seconds * .8 }},
246		{"Series Devotee", func(r *model.Review) { r.TVSeconds = r.Seconds * .8 }},
247		{"Comfort Rewatcher", func(r *model.Review) { r.UniqueTitles = 40 }},
248		{"Genre Explorer", func(r *model.Review) {
249			for _, n := range []string{"A", "B", "C", "D", "E", "F"} {
250				r.Genres = append(r.Genres, model.Named{Name: n, Seconds: r.Seconds * .05})
251			}
252		}},
253		{"Prime-Time Regular", func(r *model.Review) { r.Hourly = [24]float64{20: r.Seconds / 60} }},
254		{"Daily Viewer", func(r *model.Review) { r.ActiveDays = 180 }},
255		{"Seasonal Viewer", func(r *model.Review) { r.Monthly = [12]float64{0: r.Seconds / 60} }},
256		{"Short-Session Viewer", func(r *model.Review) { *r = base(60000) }},
257		{"Balanced Viewer", func(r *model.Review) {}},
258	}
259	for _, tt := range cases {
260		t.Run(tt.name, func(t *testing.T) {
261			r := base(360000)
262			tt.change(&r)
263			p := personality(r, true)
264			if p == nil || p.Name != tt.name {
265				t.Fatalf("got %+v", p)
266			}
267		})
268	}
269}
270
271func TestContentViewingMonths(t *testing.T) {
272	cases := []struct {
273		name, zone string
274		playback   []model.Playback
275		months     []int
276	}{
277		{"month boundary", "UTC", []model.Playback{play("a", "2025-01-31T23:30:00Z", "2025-02-01T00:30:00Z")}, []int{1, 2}},
278		{"exclusive end", "UTC", []model.Playback{play("a", "2025-01-31T23:00:00Z", "2025-02-01T00:00:00Z")}, []int{1}},
279		{"local month", "America/New_York", []model.Playback{play("a", "2025-02-01T01:00:00Z", "2025-02-01T02:00:00Z")}, []int{1}},
280		{"year clipping", "UTC", []model.Playback{play("a", "2024-12-31T23:30:00Z", "2025-01-01T00:30:00Z"), play("b", "2025-12-31T23:30:00Z", "2026-01-01T00:30:00Z")}, []int{1, 12}},
281		{"resumed and repeated", "UTC", []model.Playback{play("c", "2025-03-01T12:00:00Z", "2025-03-01T13:00:00Z"), play("a", "2025-01-01T12:00:00Z", "2025-01-01T13:00:00Z"), play("b", "2025-01-02T12:00:00Z", "2025-01-02T13:00:00Z")}, []int{1, 3}},
282		{"aggregate observed month", "America/New_York", []model.Playback{{ID: "a", UserID: alice, ItemID: "film", Seconds: 3600, ObservedAt: instant("2025-03-01T02:00:00Z")}}, []int{2}},
283	}
284	for _, tc := range cases {
285		t.Run(tc.name, func(t *testing.T) {
286			s := fixture()
287			s.Playback = tc.playback
288			c := DefaultConfig(2025)
289			c.Timezone = tc.zone
290			r := compute(t, s, c)[0]
291			if len(r.Movies) != 1 || !reflect.DeepEqual(r.Movies[0].Months, tc.months) {
292				t.Fatalf("unexpected movie months: %+v", r.Movies)
293			}
294		})
295	}
296}
297
298func TestSeriesViewingMonthsCombineEpisodes(t *testing.T) {
299	s := fixture()
300	s.Items = append(s.Items,
301		model.Item{ID: "series", Title: "A Series", Type: "Series"},
302		model.Item{ID: "episode1", Title: "Episode One", Type: "Episode", SeriesID: "series"},
303		model.Item{ID: "episode2", Title: "Episode Two", Type: "Episode", SeriesID: "series"})
304	for i, date := range []string{"2025-01-01", "2025-01-02", "2025-03-01"} {
305		p := play(date, date+"T12:00:00Z", date+"T13:00:00Z")
306		p.ItemID = "episode1"
307		if i > 0 {
308			p.ItemID = "episode2"
309		}
310		s.Playback = append(s.Playback, p)
311	}
312	r := compute(t, s, DefaultConfig(2025))[0]
313	if len(r.Shows) != 1 || r.Shows[0].ID != "series" || r.Shows[0].Records != 3 || !reflect.DeepEqual(r.Shows[0].Months, []int{1, 3}) {
314		t.Fatalf("episodes did not combine into monthly series entries: %+v", r.Shows)
315	}
316}