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
6namespace Tests\Libraries\Search;
7
8use App\Libraries\Search\BeatmapsetQueryParser;
9use App\Models\Beatmapset;
10use Carbon\CarbonImmutable;
11use Tests\TestCase;
12
13class BeatmapsetQueryParserTest extends TestCase
14{
15 public static function queryDataProvider()
16 {
17 return [
18 // basic options
19 ['stars=1', ['keywords' => null, 'options' => ['stars' => ['gte' => 0.995, 'lte' => 1.005]]]],
20 ['star=1', ['keywords' => null, 'options' => ['stars' => ['gte' => 0.995, 'lte' => 1.005]]]],
21 ['ar=2', ['keywords' => null, 'options' => ['ar' => ['gte' => 1.95, 'lte' => 2.05]]]],
22 ['dr=3', ['keywords' => null, 'options' => ['dr' => ['gte' => 2.95, 'lte' => 3.05]]]],
23 ['hp<4', ['keywords' => null, 'options' => ['dr' => ['lte' => 3.95]]]],
24 ['cs>5', ['keywords' => null, 'options' => ['cs' => ['gte' => 5.05]]]],
25 ['od>=9', ['keywords' => null, 'options' => ['od' => ['gte' => 8.95]]]],
26 ['bpm<=6', ['keywords' => null, 'options' => ['bpm' => ['lte' => 6.005]]]],
27 ['length<70000ms', ['keywords' => null, 'options' => ['length' => ['lte' => 69.9995]]]],
28 ['length>=70', ['keywords' => null, 'options' => ['length' => ['gte' => 69.5]]]],
29 ['length>=70s', ['keywords' => null, 'options' => ['length' => ['gte' => 69.5]]]],
30 ['length:8m', ['keywords' => null, 'options' => ['length' => ['gte' => 450, 'lte' => 510]]]],
31 ['length:0.9h', ['keywords' => null, 'options' => ['length' => ['gte' => (0.9 * 3600 - 1800), 'lte' => (0.9 * 3600 + 1800)]]]],
32 ['keys=10', ['keywords' => null, 'options' => ['keys' => ['gte' => 10, 'lte' => 10]]]],
33 ['divisor>0', ['keywords' => null, 'options' => ['divisor' => ['gt' => 0]]]],
34 ['status<ranked', ['keywords' => null, 'options' => ['status' => ['lt' => Beatmapset::STATES['ranked']]]]],
35 ['status=graveyard', ['keywords' => null, 'options' => ['status' => ['gte' => Beatmapset::STATES['graveyard'], 'lte' => Beatmapset::STATES['graveyard']]]]],
36 ['creator=hello', ['keywords' => null, 'options' => ['creator' => 'hello']]],
37 ['artist=hello', ['keywords' => null, 'options' => ['artist' => 'hello']]],
38 ['artist="hello world"', ['keywords' => null, 'options' => ['artist' => 'hello world']]],
39 ['created=2017', ['keywords' => null, 'options' => ['created' => ['gte' => static::parseTime('2017-01-01'), 'lt' => static::parseTime('2018-01-01')]]]],
40 ['ranked>2018', ['keywords' => null, 'options' => ['ranked' => ['gte' => static::parseTime('2019-01-01')]]]],
41 ['ranked<2018-05', ['keywords' => null, 'options' => ['ranked' => ['lt' => static::parseTime('2018-05-01')]]]],
42 ['ranked<=2018.05', ['keywords' => null, 'options' => ['ranked' => ['lt' => static::parseTime('2018-06-01')]]]],
43 ['ranked=2018/05', ['keywords' => null, 'options' => ['ranked' => ['gte' => static::parseTime('2018-05-01'), 'lt' => static::parseTime('2018-06-01')]]]],
44 ['ranked=2018.05.01', ['keywords' => null, 'options' => ['ranked' => ['gte' => static::parseTime('2018-05-01'), 'lt' => static::parseTime('2018-05-02')]]]],
45 ['ranked>2018/05/01', ['keywords' => null, 'options' => ['ranked' => ['gte' => static::parseTime('2018-05-02')]]]],
46 ['ranked>="2020-07-21 12:30:30 +09:00"', ['keywords' => null, 'options' => ['ranked' => ['gte' => static::parseTime('2020-07-21 03:30:30')]]]],
47 ['ranked="2020-07-21 12:30:30 +09:00"', ['keywords' => null, 'options' => ['ranked' => ['gte' => static::parseTime('2020-07-21 03:30:30'), 'lt' => static::parseTime('2020-07-21 03:30:31')]]]],
48 ['ranked="invalid date format"', ['keywords' => 'ranked="invalid date format"', 'options' => []]],
49
50 // multiple options
51 ['artist=hello creator:world', ['keywords' => null, 'options' => ['artist' => 'hello', 'creator' => 'world']]],
52
53 // last option overrides previous ones
54 ['dr=1 dr=9', ['keywords' => null, 'options' => ['dr' => ['gte' => 8.95, 'lte' => 9.05]]]],
55 ['artist=hello artist:world', ['keywords' => null, 'options' => ['artist' => 'world']]],
56
57 // last option overrides previous ones, including with different names
58 ['dr=1 hp<9', ['keywords' => null, 'options' => ['dr' => ['gte' => 0.95, 'lte' => 8.95]]]],
59
60 // keyword with options
61 ['hello stars>=1 stars<4', ['keywords' => 'hello', 'options' => ['stars' => ['gte' => 0.995, 'lte' => 3.995]]]],
62
63 // keywords with option in between
64 ['hello ar<:1 world', ['keywords' => 'hello world', 'options' => ['ar' => ['lte' => 1.05]]]],
65
66 // option with invalid operator is ignored (and becomes keyword)
67 ['artist>a', ['keywords' => 'artist>a', 'options' => []]],
68 ['dr=a', ['keywords' => 'dr=a', 'options' => []]],
69
70 // taken from https://github.com/ppy/osu/blob/b3e96c8385fdfec3ea1bb3899f74763ccafa055c/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs
71 ['stars<4 easy', ['keywords' => 'easy', 'options' => ['stars' => ['lte' => 3.995]]]],
72 ['ar>=9 difficult', ['keywords' => 'difficult', 'options' => ['ar' => ['gte' => 8.95]]]],
73 ['dr>2 quite specific dr<:6', ['keywords' => 'quite specific', 'options' => ['dr' => ['gte' => 2.05, 'lte' => 6.05]]]],
74 ['hp>2 quite specific hp<=6', ['keywords' => 'quite specific', 'options' => ['dr' => ['gte' => 2.05, 'lte' => 6.05]]]],
75 ['od>4 easy od<8', ['keywords' => 'easy', 'options' => ['od' => ['gte' => 4.05, 'lte' => 7.95]]]],
76 ['bpm>:200 gotta go fast', ['keywords' => 'gotta go fast', 'options' => ['bpm' => ['gte' => 199.995]]]],
77 ['length=6ms time', ['keywords' => 'time', 'options' => ['length' => ['gte' => (6 / 1000 - 1 / 2000), 'lte' => (6 / 1000 + 1 / 2000)]]]],
78 ['length=23s time', ['keywords' => 'time', 'options' => ['length' => ['gte' => 22.5, 'lte' => 23.5]]]],
79 ['length=9m time', ['keywords' => 'time', 'options' => ['length' => ['gte' => (9 * 60 - 30), 'lte' => (9 * 60 + 30)]]]],
80 ['length=0.25h time', ['keywords' => 'time', 'options' => ['length' => ['gte' => (0.25 * 3600 - 1800), 'lte' => (0.25 * 3600 + 1800)]]]],
81 ['length=70 time', ['keywords' => 'time', 'options' => ['length' => ['gte' => 69.5, 'lte' => 70.5]]]],
82 ["that's a time signature alright! divisor:12", ['keywords' => "that's a time signature alright!", 'options' => ['divisor' => ['gte' => 12, 'lte' => 12]]]],
83 ['I want the pp status=ranked', ['keywords' => 'I want the pp', 'options' => ['status' => ['gte' => Beatmapset::STATES['ranked'], 'lte' => Beatmapset::STATES['ranked']]]]],
84 ['beatmap specifically by creator=my_fav', ['keywords' => 'beatmap specifically by', 'options' => ['creator' => 'my_fav']]],
85 ['find me songs by artist=singer please', ['keywords' => 'find me songs by please', 'options' => ['artist' => 'singer']]],
86 ['really like artist="name with space" yes', ['keywords' => 'really like yes', 'options' => ['artist' => 'name with space']]],
87 ['weird artist=double"quote', ['keywords' => 'weird', 'options' => ['artist' => 'double"quote']]],
88 ['weird artist="nested "quote"" thing', ['keywords' => 'weird thing', 'options' => ['artist' => 'nested "quote"']]],
89 ['artist=><something', ['keywords' => null, 'options' => ['artist' => '><something']]],
90 ['unrecognised=keyword', ['keywords' => 'unrecognised=keyword', 'options' => []]],
91 ['cs=nope', ['keywords' => 'cs=nope', 'options' => []]],
92 ['bpm=bad', ['keywords' => 'bpm=bad', 'options' => []]],
93 ['divisor<nah', ['keywords' => 'divisor<nah', 'options' => []]],
94 ['status=noidea', ['keywords' => 'status=noidea', 'options' => []]],
95 ['status=l', ['keywords' => null, 'options' => ['status' => ['gte' => Beatmapset::STATES['loved'], 'lte' => Beatmapset::STATES['loved']]]]],
96 ['status=lo', ['keywords' => null, 'options' => ['status' => ['gte' => Beatmapset::STATES['loved'], 'lte' => Beatmapset::STATES['loved']]]]],
97 ];
98 }
99
100 private static function parseTime(string $timeString): int
101 {
102 return CarbonImmutable::parse($timeString)->getTimestampMs();
103 }
104
105 /**
106 * @dataProvider queryDataProvider
107 */
108 public function testParse(?string $query, ?array $expected)
109 {
110 $this->assertSame(json_encode($expected), json_encode(BeatmapsetQueryParser::parse($query)));
111 }
112}