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\Jobs\CheckBeatmapsetCovers;
11use App\Libraries\BeatmapsetDiscussion\Reply;
12use App\Models\Beatmap;
13use App\Models\BeatmapDiscussion;
14use App\Models\Beatmapset;
15use App\Models\Genre;
16use App\Models\Language;
17use App\Models\User;
18use Bus;
19use Carbon\CarbonImmutable;
20use Database\Factories\BeatmapsetFactory;
21use Tests\TestCase;
22
23class BeatmapsetRequalifyTest extends TestCase
24{
25 private const DISQUALIFIED_INTERVAL = 86400;
26
27 // User for disqualifying and resolving discussion.
28 private User $user;
29
30 public function testDoesNotResetQueue()
31 {
32 $disqualifiedDate = CarbonImmutable::now()->subDays(1);
33 $qualifiedDate = $disqualifiedDate->subSeconds(static::DISQUALIFIED_INTERVAL)->startOfSecond();
34
35 $this->travelTo($qualifiedDate);
36
37 $beatmapset = $this->beatmapsetFactory()->create();
38 $nominators = $beatmapset->beatmapsetNominations()->get()->pluck('user');
39
40 // sanity
41 $this->assertSame(0, $beatmapset->previous_queue_duration);
42 $this->assertEquals($qualifiedDate, $beatmapset->approved_date);
43
44 $this->travelTo($disqualifiedDate);
45
46 $discussion = $this->disqualifyOrResetNominations($beatmapset);
47 $beatmapset = $beatmapset->fresh();
48 $this->assertDiffUpToOneSecond(static::DISQUALIFIED_INTERVAL, $beatmapset->previous_queue_duration);
49 $this->assertNull($beatmapset->queued_at);
50
51 $this->travelBack();
52
53 $this->resolveDiscussionAndNominate($discussion, $nominators);
54 $beatmapset = $beatmapset->fresh();
55
56 $this->assertTrue($beatmapset->isQualified());
57 $this->assertEquals($beatmapset->approved_date->toImmutable()->subSeconds(static::DISQUALIFIED_INTERVAL), $beatmapset->queued_at);
58 }
59
60 public function testDifferentNominatorResetsQueue()
61 {
62 $disqualifiedDate = CarbonImmutable::now()->subDays(1);
63 $qualifiedDate = $disqualifiedDate->subSeconds(static::DISQUALIFIED_INTERVAL)->startOfSecond();
64
65 $this->travelTo($qualifiedDate);
66
67 $beatmapset = $this->beatmapsetFactory()->create();
68 $nominators = $beatmapset->beatmapsetNominations()->get()->pluck('user');
69 // replace 1 nominator with a different one.
70 $nominators[0] = $this->user;
71
72 $this->travelTo($disqualifiedDate);
73
74 $discussion = $this->disqualifyOrResetNominations($beatmapset);
75 $beatmapset = $beatmapset->fresh();
76
77 $this->travelBack();
78
79 $this->resolveDiscussionAndNominate($discussion, $nominators);
80 $beatmapset = $beatmapset->fresh();
81
82 $this->assertTrue($beatmapset->isQualified());
83 $this->assertEqualsUpToOneSecond(CarbonImmutable::now(), $beatmapset->queued_at);
84 $this->assertEquals($beatmapset->approved_date, $beatmapset->queued_at);
85 }
86
87 public function testDifferentNominatorBeforeNominationResetDoesNotResetQueue()
88 {
89 $disqualifiedDate = CarbonImmutable::now()->subDays(2);
90 $qualifiedDate = $disqualifiedDate->subSeconds(static::DISQUALIFIED_INTERVAL)->startOfSecond();
91
92 $this->travelTo($qualifiedDate);
93
94 $beatmapset = $this->beatmapsetFactory()->create();
95 $nominators = $beatmapset->beatmapsetNominations()->get()->pluck('user');
96
97 $this->travelTo($disqualifiedDate);
98
99 // disqualify
100 $discussion = $this->disqualifyOrResetNominations($beatmapset);
101 $beatmapset = $beatmapset->fresh();
102
103 // test nomination reset
104 $this->travelTo($disqualifiedDate->addSeconds(60));
105 $this->resolveDiscussionAndNominate($discussion, [$this->user]);
106 $discussion = $this->disqualifyOrResetnominations($beatmapset->fresh());
107 $beatmapset = $beatmapset->fresh();
108
109 $this->travelBack();
110
111 $this->resolveDiscussionAndNominate($discussion, $nominators);
112 $beatmapset = $beatmapset->fresh();
113
114 $this->assertTrue($beatmapset->isQualified());
115 $this->assertEquals($beatmapset->approved_date->toImmutable()->subSeconds(static::DISQUALIFIED_INTERVAL), $beatmapset->queued_at);
116 }
117
118 // tests nominators from previous qualification are considered as different nominators.
119 public function testNominatorFromPriorQualificationResetsQueue()
120 {
121 $disqualifiedDate = CarbonImmutable::now()->subDays(1);
122 $qualifiedDate = $disqualifiedDate->subSeconds(static::DISQUALIFIED_INTERVAL)->startOfSecond();
123
124 $this->travelTo($qualifiedDate);
125
126 $beatmapset = $this->beatmapsetFactory()->create();
127 $nominators = $beatmapset->beatmapsetNominations()->get()->pluck('user');
128
129 $this->travelTo($disqualifiedDate);
130
131 $discussion = $this->disqualifyOrResetNominations($beatmapset);
132 $beatmapset = $beatmapset->fresh();
133
134 // second qualification
135 $this->travelTo($disqualifiedDate->addSeconds(60));
136
137 $newNominators = User::factory()->withGroup('nat')->count($GLOBALS['cfg']['osu']['beatmapset']['required_nominations'])->create();
138 $this->resolveDiscussionAndNominate($discussion, $newNominators);
139 $beatmapset = $beatmapset->fresh();
140
141 $this->assertTrue($beatmapset->isQualified());
142 $this->assertEqualsUpToOneSecond(CarbonImmutable::now(), $beatmapset->queued_at);
143 $this->assertEquals($beatmapset->approved_date, $beatmapset->queued_at);
144
145 // second disqualification
146 $discussion = $this->disqualifyOrResetNominations($beatmapset);
147 $beatmapset = $beatmapset->fresh();
148 $this->assertTrue($beatmapset->isPending());
149
150 $this->travelBack();
151
152 $this->resolveDiscussionAndNominate($discussion, $nominators);
153 $beatmapset = $beatmapset->fresh();
154
155 $this->assertTrue($beatmapset->isQualified());
156 $this->assertEqualsUpToOneSecond(CarbonImmutable::now(), $beatmapset->queued_at);
157 $this->assertEquals($beatmapset->approved_date, $beatmapset->queued_at);
158 }
159
160 public function testNominatorFromRecentQualificationDoesNotResetQueue()
161 {
162 $disqualifiedDate = CarbonImmutable::now()->subDays(1);
163 $qualifiedDate = $disqualifiedDate->subSeconds(static::DISQUALIFIED_INTERVAL)->startOfSecond();
164
165 $this->travelTo($qualifiedDate);
166
167 $beatmapset = $this->beatmapsetFactory()->create();
168
169 $this->travelTo($disqualifiedDate);
170
171 $discussion = $this->disqualifyOrResetNominations($beatmapset);
172 $beatmapset = $beatmapset->fresh();
173
174 // second qualification
175 $this->travelTo($disqualifiedDate->addSeconds(60));
176
177 $newNominators = User::factory()->withGroup('nat')->count($GLOBALS['cfg']['osu']['beatmapset']['required_nominations'])->create();
178 $this->resolveDiscussionAndNominate($discussion, $newNominators);
179 $beatmapset = $beatmapset->fresh();
180
181 $this->assertTrue($beatmapset->isQualified());
182 $this->assertEqualsUpToOneSecond(CarbonImmutable::now(), $beatmapset->queued_at);
183 $this->assertEquals($beatmapset->approved_date, $beatmapset->queued_at);
184 $previousQueueDuration = $beatmapset->previous_queue_duration;
185
186 // second disqualification
187 $discussion = $this->disqualifyOrResetNominations($beatmapset);
188 $beatmapset = $beatmapset->fresh();
189 $this->assertTrue($beatmapset->isPending());
190
191 $this->travelBack();
192
193 $this->resolveDiscussionAndNominate($discussion, $newNominators);
194 $beatmapset = $beatmapset->fresh();
195
196 // queue should not reset.
197 $this->assertTrue($beatmapset->isQualified());
198 $this->assertDiffUpToOneSecond($previousQueueDuration, CarbonImmutable::now()->getTimestamp() - $beatmapset->queued_at->getTimestamp());
199 }
200
201 public function testNewDifficultyAddedResetsQueue()
202 {
203 $disqualifiedDate = CarbonImmutable::now()->subDays(1);
204 $qualifiedDate = $disqualifiedDate->subSeconds(static::DISQUALIFIED_INTERVAL)->startOfSecond();
205
206 $this->travelTo($qualifiedDate);
207
208 $beatmapset = $this->beatmapsetFactory()->create();
209 $nominators = $beatmapset->beatmapsetNominations()->get()->pluck('user');
210
211 $this->travelTo($disqualifiedDate);
212
213 $discussion = $this->disqualifyOrResetNominations($beatmapset);
214 $beatmapset = $beatmapset->fresh();
215
216 $this->travelBack();
217
218 $beatmapset->beatmaps()->save(Beatmap::factory()->ruleset('osu')->make());
219
220 $this->resolveDiscussionAndNominate($discussion, $nominators);
221 $beatmapset = $beatmapset->fresh();
222
223 $this->assertTrue($beatmapset->isQualified());
224 $this->assertEqualsUpToOneSecond(CarbonImmutable::now(), $beatmapset->queued_at);
225 $this->assertEquals($beatmapset->approved_date, $beatmapset->queued_at);
226 }
227
228 public function testTimerIncreasesWhileDisqualified()
229 {
230 $weeksDisqualified = 2;
231 $disqualifiedDate = CarbonImmutable::now()->subWeeks($weeksDisqualified);
232 $qualifiedDate = $disqualifiedDate->subSeconds(static::DISQUALIFIED_INTERVAL)->startOfSecond();
233
234 $this->travelTo($qualifiedDate);
235
236 $beatmapset = $this->beatmapsetFactory()->create();
237 $nominators = $beatmapset->beatmapsetNominations()->get()->pluck('user');
238
239 $this->travelTo($disqualifiedDate);
240
241 $discussion = $this->disqualifyOrResetNominations($beatmapset);
242 $beatmapset = $beatmapset->fresh();
243
244 $this->travelBack();
245
246 $this->resolveDiscussionAndNominate($discussion, $nominators);
247 $beatmapset = $beatmapset->fresh();
248
249 $this->assertTrue($beatmapset->isQualified());
250 $this->assertEquals($beatmapset->approved_date->toImmutable()->addDays($weeksDisqualified)->subSeconds(static::DISQUALIFIED_INTERVAL), $beatmapset->queued_at);
251 }
252
253 protected function setUp(): void
254 {
255 parent::setUp();
256
257 $this->user = User::factory()->withGroup('bng', ['osu'])->create()->markSessionVerified();
258
259 Genre::factory()->create(['genre_id' => Genre::UNSPECIFIED]);
260 Language::factory()->create(['language_id' => Language::UNSPECIFIED]);
261
262 Bus::fake([CheckBeatmapsetCovers::class]);
263 }
264
265 private function assertDiffUpToOneSecond(int $expected, int $actual)
266 {
267 $this->assertTrue(abs($actual - $expected) < 2);
268 }
269
270 private function beatmapsetFactory(): BeatmapsetFactory
271 {
272 return Beatmapset::factory()
273 ->owner()
274 ->qualified()
275 ->withBeatmaps('osu')
276 ->withNominations();
277 }
278
279 private function disqualifyOrResetnominations(Beatmapset $beatmapset)
280 {
281 $discussion = BeatmapDiscussion::factory()->general()->problem()->create(['beatmapset_id' => $beatmapset, 'user_id' => $this->user]);
282 $beatmapset->disqualifyOrResetNominations($this->user, $discussion);
283
284 return $discussion;
285 }
286
287 private function resolveDiscussionAndNominate(BeatmapDiscussion $discussion, iterable $nominators)
288 {
289 (new Reply($this->user, $discussion, 'resolve', true))->handle();
290 $beatmapset = $discussion->fresh()->beatmapset;
291
292 foreach ($nominators as $nominator) {
293 $beatmapset->nominate($nominator, ['osu']);
294 }
295 }
296}