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}