1<?php
2
3// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0.
4// See the LICENCE file in the repository root for full licence text.
5
6declare(strict_types=1);
7
8namespace Tests\Models;
9
10use App\Models\DailyChallengeUserStats;
11use App\Models\Multiplayer\PlaylistItem;
12use App\Models\Multiplayer\Room;
13use App\Models\Multiplayer\ScoreLink;
14use App\Models\Multiplayer\UserScoreAggregate;
15use App\Models\User;
16use Carbon\CarbonImmutable;
17use Tests\TestCase;
18
19class DailyChallengeUserStatsTest extends TestCase
20{
21 protected static function roomAddPlay(User $user, PlaylistItem $playlistItem, array $scoreParams = []): ScoreLink
22 {
23 $room = $playlistItem->room;
24 $origEndsAt = $room->ends_at;
25 $room->update(['ends_at' => CarbonImmutable::now()->addDays(1)]);
26 try {
27 return parent::roomAddPlay($user, $playlistItem, ['passed' => true, ...$scoreParams]);
28 } finally {
29 $room->update(['ends_at' => $origEndsAt]);
30 }
31 }
32
33 private static function preparePlaylistItem(CarbonImmutable $playTime): PlaylistItem
34 {
35 return PlaylistItem::factory()->create([
36 'room_id' => Room::factory()->create([
37 'category' => 'daily_challenge',
38 'starts_at' => $playTime->startOfDay(),
39 'ends_at' => $playTime->endOfDay(),
40 ]),
41 ]);
42 }
43
44 private static function startOfWeek(): CarbonImmutable
45 {
46 return DailyChallengeUserStats::startOfWeek(CarbonImmutable::now()->subWeeks(1));
47 }
48
49 public function testCalculateFromStart(): void
50 {
51 $playTime = static::startOfWeek();
52 $playlistItem = static::preparePlaylistItem($playTime);
53
54 $user = User::factory()->create();
55 ScoreLink::factory()->passed()->create([
56 'playlist_item_id' => $playlistItem,
57 'user_id' => $user,
58 ]);
59 UserScoreAggregate::new($user, $playlistItem->room)->save();
60
61 $this->expectCountChange(fn () => DailyChallengeUserStats::count(), 1);
62
63 DailyChallengeUserStats::calculate($playTime);
64
65 $stats = DailyChallengeUserStats::find($user->getKey());
66 $this->assertSame(1, $stats->playcount);
67 $this->assertSame(1, $stats->daily_streak_current);
68 $this->assertSame(1, $stats->daily_streak_best);
69 $this->assertSame(1, $stats->weekly_streak_current);
70 $this->assertSame(1, $stats->weekly_streak_best);
71 $this->assertSame(1, $stats->top_10p_placements);
72 $this->assertSame(1, $stats->top_50p_placements);
73 $this->assertTrue($playTime->equalTo($stats->last_weekly_streak));
74 $this->assertTrue($playTime->equalTo($stats->last_update));
75 }
76
77 public function testCalculateNoPlaysBreaksDailyStreak(): void
78 {
79 $playTime = static::startOfWeek();
80 static::preparePlaylistItem($playTime);
81
82 $user = User::factory()->create();
83
84 $lastWeeklyStreak = $playTime->subWeeks(1);
85 DailyChallengeUserStats::create([
86 'daily_streak_best' => 3,
87 'daily_streak_current' => 3,
88 'last_update' => $playTime->subDays(1),
89 'last_weekly_streak' => $lastWeeklyStreak,
90 'user_id' => $user->getKey(),
91 'weekly_streak_best' => 3,
92 'weekly_streak_current' => 3,
93 ]);
94 DailyChallengeUserStats::calculate($playTime);
95
96 $stats = DailyChallengeUserStats::find($user->getKey());
97 $this->assertSame(0, $stats->daily_streak_current);
98 $this->assertSame(3, $stats->daily_streak_best);
99 $this->assertSame(3, $stats->weekly_streak_current);
100 $this->assertSame(3, $stats->weekly_streak_best);
101 $this->assertTrue($lastWeeklyStreak->equalTo($stats->last_weekly_streak));
102 }
103
104 public function testCalculateNoPlaysOverAWeekBreaksWeeklyStreak(): void
105 {
106 $playTime = static::startOfWeek();
107 $playlistItem = static::preparePlaylistItem($playTime);
108
109 $user = User::factory()->create();
110
111 $lastWeeklyStreak = $playTime->subWeeks(2);
112 DailyChallengeUserStats::create([
113 'user_id' => $user->getKey(),
114 'weekly_streak_current' => 3,
115 'weekly_streak_best' => 3,
116 'last_update' => $playTime->subDays(1),
117 'last_weekly_streak' => $lastWeeklyStreak,
118 ]);
119 DailyChallengeUserStats::calculate($playTime);
120
121 $stats = DailyChallengeUserStats::find($user->getKey());
122 $this->assertSame(0, $stats->weekly_streak_current);
123 $this->assertSame(3, $stats->weekly_streak_best);
124 $this->assertTrue($lastWeeklyStreak->equalTo($stats->last_weekly_streak));
125 }
126
127 public function testCalculateNoPlaysOverAWeekBreaksWeeklyStreakLastStreakOnStartOfWeek(): void
128 {
129 $user = User::factory()->create();
130 $lastWeeklyStreak = static::startOfWeek();
131 DailyChallengeUserStats::create([
132 'user_id' => $user->getKey(),
133 'weekly_streak_current' => 3,
134 'weekly_streak_best' => 3,
135 'last_update' => $lastWeeklyStreak->addDays(1),
136 'last_weekly_streak' => $lastWeeklyStreak,
137 ]);
138
139 // no break until the exact 14th day after last weekly streak
140 for ($i = 7; $i <= 14; $i++) {
141 $playTime = $lastWeeklyStreak->addDays($i);
142 $playlistItem = static::preparePlaylistItem($playTime);
143 DailyChallengeUserStats::calculate($playTime);
144
145 $stats = DailyChallengeUserStats::find($user->getKey());
146 $testHint = "After {$i} days";
147 $this->assertSame($i === 14 ? 0 : 3, $stats->weekly_streak_current, $testHint);
148 $this->assertSame(3, $stats->weekly_streak_best, $testHint);
149 $this->assertTrue($lastWeeklyStreak->equalTo($stats->last_weekly_streak), $testHint);
150 }
151 }
152
153 public function testCalculatePercentile(): void
154 {
155 $playTime = static::startOfWeek();
156 $playlistItem = static::preparePlaylistItem($playTime);
157
158 $totalScores = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100];
159 $scoreLinks = [];
160 foreach ($totalScores as $totalScore) {
161 $scoreLink = $scoreLinks[] = ScoreLink::factory()->completed([
162 'passed' => true,
163 'total_score' => $totalScore,
164 ])->create([
165 'playlist_item_id' => $playlistItem,
166 ]);
167 UserScoreAggregate::new($scoreLink->user, $playlistItem->room)->save();
168 }
169
170 $this->expectCountChange(fn () => DailyChallengeUserStats::count(), 10);
171
172 DailyChallengeUserStats::calculate($playTime);
173
174 foreach ($scoreLinks as $i => $scoreLink) {
175 [$count10p, $count50p] = match (true) {
176 // 100
177 $i === 9 => [1, 1],
178 // 60 - 90
179 $i >= 5 => [0, 1],
180 default => [0, 0],
181 };
182 $stats = DailyChallengeUserStats::find($scoreLink->user_id);
183 $this->assertSame($count10p, $stats->top_10p_placements, "i: {$i}");
184 $this->assertSame($count50p, $stats->top_50p_placements, "i: {$i}");
185 }
186 }
187
188 public function testFix(): void
189 {
190 $user = User::factory()->create();
191
192 foreach ([14, 13, 12, 11, 10, 9, 7, 6, 5] as $subDay) {
193 $playTime = static::startOfWeek()->subDays($subDay);
194 $playlistItem = static::preparePlaylistItem($playTime);
195 $this->roomAddPlay($user, $playlistItem);
196 DailyChallengeUserStats::calculate($playTime);
197 }
198 $this->travelTo($playTime->addDays(1));
199
200 $stats = DailyChallengeUserStats::find($user->getKey());
201 $expectedAttributes = $stats->getAttributes();
202 $stats->fill([...DailyChallengeUserStats::INITIAL_VALUES])->saveOrExplode();
203 $stats->fresh()->fix();
204
205 $stats->refresh();
206 $this->assertSame(9, $stats->playcount);
207 $this->assertSame(3, $stats->daily_streak_current);
208 $this->assertSame(6, $stats->daily_streak_best);
209 $this->assertSame(2, $stats->weekly_streak_current);
210 $this->assertSame(2, $stats->weekly_streak_best);
211 $this->assertSame(9, $stats->top_10p_placements);
212 $this->assertSame(9, $stats->top_50p_placements);
213
214 $this->travelBack();
215
216 $stats->fresh()->fix();
217
218 $stats->refresh();
219 $this->assertSame(9, $stats->playcount);
220 $this->assertSame(0, $stats->daily_streak_current);
221 $this->assertSame(6, $stats->daily_streak_best);
222 }
223
224 public function testFixZeroTotalScore(): void
225 {
226 $user = User::factory()->create();
227
228 foreach ([3 => 100, 2 => 0, 1 => 100] as $subDay => $score) {
229 $playTime = static::startOfWeek()->subDays($subDay);
230 $playlistItem = static::preparePlaylistItem($playTime);
231 $this->roomAddPlay($user, $playlistItem, ['total_score' => $score]);
232 }
233 $this->travelTo($playTime->addDays(1));
234
235 $stats = DailyChallengeUserStats::find($user->getKey());
236 $stats->fill([...DailyChallengeUserStats::INITIAL_VALUES])->saveOrExplode();
237 $stats->fix();
238
239 $stats->refresh();
240 $this->assertSame(2, $stats->playcount);
241 $this->assertSame(1, $stats->daily_streak_current);
242 $this->assertSame(1, $stats->daily_streak_best);
243 $this->assertSame(1, $stats->weekly_streak_current);
244 $this->assertSame(1, $stats->weekly_streak_best);
245 }
246
247 public function testFlowFromStart(): void
248 {
249 $playTime = static::startOfWeek();
250 $playlistItem = static::preparePlaylistItem($playTime);
251 $user = User::factory()->create();
252
253 $this->expectCountChange(fn () => DailyChallengeUserStats::count(), 1);
254
255 $this->roomAddPlay($user, $playlistItem);
256
257 $stats = DailyChallengeUserStats::find($user->getKey());
258 $this->assertSame(1, $stats->playcount);
259 $this->assertSame(1, $stats->daily_streak_current);
260 $this->assertSame(1, $stats->daily_streak_best);
261 $this->assertSame(1, $stats->weekly_streak_current);
262 $this->assertSame(1, $stats->weekly_streak_best);
263 $this->assertSame(0, $stats->top_10p_placements);
264 $this->assertSame(0, $stats->top_50p_placements);
265 $this->assertTrue($playTime->equalTo($stats->last_weekly_streak));
266 $this->assertTrue($playTime->equalTo($stats->last_update));
267
268 // increments percentile and nothing else
269 DailyChallengeUserStats::calculate($playTime);
270
271 $stats->refresh();
272 $this->assertSame(1, $stats->playcount);
273 $this->assertSame(1, $stats->daily_streak_current);
274 $this->assertSame(1, $stats->daily_streak_best);
275 $this->assertSame(1, $stats->weekly_streak_current);
276 $this->assertSame(1, $stats->weekly_streak_best);
277 $this->assertSame(1, $stats->top_10p_placements);
278 $this->assertSame(1, $stats->top_50p_placements);
279 $this->assertTrue($playTime->equalTo($stats->last_weekly_streak));
280 $this->assertTrue($playTime->equalTo($stats->last_update));
281 }
282
283 public function testFlowMultipleTimes(): void
284 {
285 $playTime = static::startOfWeek();
286 $playlistItem = static::preparePlaylistItem($playTime);
287 $user = User::factory()->create();
288
289 $this->roomAddPlay($user, $playlistItem);
290 $this->roomAddPlay($user, $playlistItem);
291
292 DailyChallengeUserStats::calculate($playTime);
293 DailyChallengeUserStats::calculate($playTime);
294
295 $stats = DailyChallengeUserStats::find($user->getKey());
296 $this->assertSame(1, $stats->playcount);
297 }
298
299 public function testFlowIncrementAll(): void
300 {
301 $playTime = static::startOfWeek();
302 $playlistItem = static::preparePlaylistItem($playTime);
303 $user = User::factory()->create();
304
305 DailyChallengeUserStats::create([
306 'user_id' => $user->getKey(),
307 'playcount' => 1,
308 'daily_streak_current' => 1,
309 'daily_streak_best' => 1,
310 'weekly_streak_current' => 1,
311 'weekly_streak_best' => 1,
312 'top_10p_placements' => 1,
313 'top_50p_placements' => 1,
314 'last_weekly_streak' => $playTime->subWeeks(1),
315 'last_update' => $playTime->subDays(1),
316 ]);
317
318 $this->expectCountChange(fn () => DailyChallengeUserStats::count(), 0);
319
320 $this->roomAddPlay($user, $playlistItem);
321 $stats = DailyChallengeUserStats::find($user->getKey());
322 $this->assertSame(2, $stats->daily_streak_current);
323 $this->assertSame(2, $stats->daily_streak_best);
324 $this->assertSame(2, $stats->weekly_streak_current);
325 $this->assertSame(2, $stats->weekly_streak_best);
326 $this->assertSame(1, $stats->top_10p_placements);
327 $this->assertSame(1, $stats->top_50p_placements);
328 $this->assertTrue($playTime->equalTo($stats->last_weekly_streak));
329 $this->assertTrue($playTime->equalTo($stats->last_update));
330
331 DailyChallengeUserStats::calculate($playTime);
332
333 $stats->refresh();
334 $this->assertSame(2, $stats->daily_streak_current);
335 $this->assertSame(2, $stats->daily_streak_best);
336 $this->assertSame(2, $stats->weekly_streak_current);
337 $this->assertSame(2, $stats->weekly_streak_best);
338 $this->assertSame(2, $stats->top_10p_placements);
339 $this->assertSame(2, $stats->top_50p_placements);
340 $this->assertTrue($playTime->equalTo($stats->last_weekly_streak));
341 $this->assertTrue($playTime->equalTo($stats->last_update));
342 }
343
344 public function testFlowIncrementWeeklyStreak(): void
345 {
346 $playTime = static::startOfWeek();
347 $playlistItem = static::preparePlaylistItem($playTime);
348 $user = User::factory()->create();
349
350 DailyChallengeUserStats::create([
351 'user_id' => $user->getKey(),
352 'weekly_streak_current' => 1,
353 'weekly_streak_best' => 1,
354 'last_weekly_streak' => $playTime->subWeeks(1),
355 'last_update' => $playTime->subDays(1),
356 ]);
357
358 $this->roomAddPlay($user, $playlistItem);
359
360 $stats = DailyChallengeUserStats::find($user->getKey());
361 $this->assertSame(2, $stats->weekly_streak_current);
362 $this->assertSame(2, $stats->weekly_streak_best);
363 $this->assertTrue($playTime->equalTo($stats->last_weekly_streak));
364
365 DailyChallengeUserStats::calculate($playTime);
366
367 $stats->refresh();
368 $this->assertSame(2, $stats->weekly_streak_current);
369 $this->assertSame(2, $stats->weekly_streak_best);
370 $this->assertTrue($playTime->equalTo($stats->last_weekly_streak));
371 }
372
373 /**
374 * 1. normal play and calculation (day 1)
375 * 2. no play (day 2)
376 * 3. play (day 3)
377 * 4. calculation (day 2)
378 * 5. play (day 3)
379 * 6. calculation (day 3)
380 * Streak should be broken on #3 and restarted streak should stay the same on #4, #5, and #6.
381 */
382 public function testFlowOneSkippedDayAndOnePlayBeforeLastCalculation(): void
383 {
384 $playTime = static::startOfWeek();
385 $playlistItem = static::preparePlaylistItem($playTime);
386
387 $user = User::factory()->create();
388 $this->roomAddPlay($user, $playlistItem, ['passed' => true]);
389 DailyChallengeUserStats::calculate($playTime);
390
391 $playTime = $playTime->addDays(1);
392 $playlistItem = static::preparePlaylistItem($playTime);
393
394 $playTime = $playTime->addDays(1);
395 $playlistItem = static::preparePlaylistItem($playTime);
396 $this->roomAddPlay($user, $playlistItem, ['passed' => true]);
397
398 // no changes expected
399 $assertValues = function () use ($user): void {
400 $stats = DailyChallengeUserStats::find($user->getKey());
401 $this->assertSame(1, $stats->daily_streak_current);
402 $this->assertSame(1, $stats->daily_streak_best);
403 $this->assertSame(1, $stats->weekly_streak_current);
404 $this->assertSame(1, $stats->weekly_streak_best);
405 };
406 $assertValues();
407
408 DailyChallengeUserStats::calculate($playTime->subDays(1));
409 $assertValues();
410
411 $this->roomAddPlay($user, $playlistItem, ['passed' => true]);
412 $assertValues();
413
414 DailyChallengeUserStats::calculate($playTime);
415 $assertValues();
416 }
417
418 protected function setUp(): void
419 {
420 parent::setUp();
421 // prevent storing percentile cache
422 config_set('cache.default', 'array');
423 }
424}