Compare commits
2
Commits
8b71723a34
...
a21b6e9b22
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a21b6e9b22 | ||
|
|
f7619baf34 |
No files matched your search
+23
-3
@@ -26,8 +26,8 @@ pass.
|
||||
|
||||
| Folder | Contents |
|
||||
|---|---|
|
||||
| `tests/unit/` | Logic without scenes: playlist, songs, player scoring, time formatting, search, the server client |
|
||||
| `tests/integration/` | Real scenes (`MainWindow.tscn`, `SyncPopup.tscn`) run against a fake server, including the point keybinds |
|
||||
| `tests/unit/` | Logic without scenes: playlist, songs, player scoring, time formatting, search, the server client and the API classes |
|
||||
| `tests/integration/` | Real scenes (`MainWindow.tscn`, `SyncPopup.tscn`) run against a fake server, including the point keybinds and the Statistics tab |
|
||||
| `tests/fakes/` | `FakeHttpTransport`, `AutoloadSnapshot` and the `MainWindowTestSuite` base class |
|
||||
| `tests/data/` | Files the fake server sends, e.g. a short MP3 |
|
||||
|
||||
@@ -48,7 +48,27 @@ assert_int(server.count("/music/rand")).is_equal(1)
|
||||
server.uninstall()
|
||||
```
|
||||
|
||||
Paths without a response get a 404, so the code under test sees a failed request.
|
||||
Paths are relative to the API (`/music/list` is `<server>/api/v1/music/list`). Paths
|
||||
without a response get a 404, so the code under test sees a failed request.
|
||||
|
||||
Like the real server, the fake hands out tokens on `POST /token` and answers 401 to
|
||||
requests without a valid one, so the client's token handling runs in every test. Token
|
||||
requests are counted in `server.token_requests`, not in `requested_paths`. Use
|
||||
`server.expire_tokens()` to make the client renew its token, `server.hold("/token")` to
|
||||
keep it waiting for one, and `server.respond_json("/token", {...}, 401)` for a refused
|
||||
password.
|
||||
|
||||
## API classes
|
||||
|
||||
The classes in `server/generated/` (`SongInfo`, `StatisticsSummary`, ...) are generated
|
||||
from the server's OpenAPI spec, committed as `server/openapi.json`. To update them, copy
|
||||
a new spec there (`just openapi` in MusicServer, or `<server>/openapi.json`) and run:
|
||||
|
||||
```bash
|
||||
godot --headless --path . -s tools/generate_api_types.gd
|
||||
```
|
||||
|
||||
`tests/unit/test_api_types.gd` parses a sample response with each class.
|
||||
|
||||
## Writing tests
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
class_name SoundtrackSearch
|
||||
extends RefCounted
|
||||
## Finds soundtrack names matching a search text. Matches ignore case, spaces and
|
||||
## special characters, and also find the letters in order with others in between.
|
||||
##
|
||||
## Used by the Search popup and the statistics view, so both find the same soundtracks.
|
||||
|
||||
## Each name as a Dictionary with its original, lowercase and cleaned form.
|
||||
var _names: Array[Dictionary] = []
|
||||
|
||||
## Compiled patterns for the letters-in-order search, by cleaned search term.
|
||||
var _regex_cache: Dictionary = {}
|
||||
|
||||
|
||||
## Replaces the names to search in.
|
||||
func set_names(names: Array) -> void:
|
||||
_names.clear()
|
||||
for soundtrack: String in names:
|
||||
_names.append({
|
||||
"original": soundtrack,
|
||||
"lowercase": soundtrack.to_lower(),
|
||||
"cleaned": clean_term(soundtrack),
|
||||
})
|
||||
|
||||
|
||||
## Returns the names matching [param search_text], best matches first: plain
|
||||
## case-insensitive matches, then matches ignoring spaces and special characters,
|
||||
## then matches with the letters in order but not next to each other.
|
||||
func find_matches(search_text: String) -> Array[String]:
|
||||
var seen: Dictionary = {}
|
||||
var search_text_lower: String = search_text.to_lower()
|
||||
var clean_search_text: String = clean_term(search_text)
|
||||
var regex: RegEx = _compile_regex_cached(clean_search_text)
|
||||
var passes: Array[Callable] = [
|
||||
func(entry: Dictionary) -> bool: return entry.lowercase.contains(search_text_lower),
|
||||
func(entry: Dictionary) -> bool: return entry.cleaned.contains(clean_search_text),
|
||||
func(entry: Dictionary) -> bool: return regex.search(entry.cleaned) != null,
|
||||
]
|
||||
|
||||
var matches: Array[String] = []
|
||||
for matches_pass: Callable in passes:
|
||||
for entry: Dictionary in _names:
|
||||
if !seen.has(entry.original) && matches_pass.call(entry):
|
||||
seen[entry.original] = true
|
||||
matches.append(entry.original)
|
||||
return matches
|
||||
|
||||
|
||||
## Cleans a term by removing spaces and replacing special characters.
|
||||
## Matches MusicServer's clean_term() function.
|
||||
static func clean_term(term: String) -> String:
|
||||
return term.replace(" ", "").replace("é", "e").replace("+", "plus").replace("&", "and") \
|
||||
.replace("'n", "and").to_lower()
|
||||
|
||||
|
||||
# Compiles the letters-in-order pattern for [param search_term], once per term
|
||||
func _compile_regex_cached(search_term: String) -> RegEx:
|
||||
if not search_term in _regex_cache:
|
||||
var regex: RegEx = RegEx.new()
|
||||
var regex_text: String = ".*"
|
||||
for letter: String in search_term:
|
||||
# Escape regex metacharacters so searches like "Q*bert" or "(Remix)" work
|
||||
if "\\^$.|?*+()[]{}".contains(letter):
|
||||
regex_text += "\\"
|
||||
regex_text += letter + ".*"
|
||||
regex.compile(regex_text)
|
||||
_regex_cache[search_term] = regex
|
||||
return _regex_cache[search_term]
|
||||
@@ -0,0 +1 @@
|
||||
uid://df8ox6xddobu2
|
||||
@@ -6,6 +6,9 @@ extends Node
|
||||
const MAX_NUMBER_OF_PLAYERS: int = 7
|
||||
|
||||
var default_path: String = "https://music.sanplex.xyz"
|
||||
## Password for getting an API token from the server. Any password works when the
|
||||
## server has none set.
|
||||
var api_password: String = ""
|
||||
var selected_server: int = 0
|
||||
var is_local: bool = false
|
||||
var is_debug: bool = false
|
||||
|
||||
+14
-23
@@ -9,6 +9,8 @@
|
||||
[ext_resource type="PackedScene" uid="uid://btupbowehiyyu" path="res://popups/QRPopup.tscn" id="15_qr_window"]
|
||||
[ext_resource type="PackedScene" uid="uid://cp54idaudb8dp" path="res://popups/AboutPopup.tscn" id="16_about_window"]
|
||||
[ext_resource type="PackedScene" uid="uid://bijh5h5yrivm3" path="res://popups/LogPopup.tscn" id="14_26rwn"]
|
||||
[ext_resource type="PackedScene" path="res://statistics/StatisticsView.tscn" id="statistics_view_scene"]
|
||||
[ext_resource type="PackedScene" path="res://popups/SoundtrackStatsPopup.tscn" id="soundtrack_stats_popup_scene"]
|
||||
|
||||
[node name="MainWindow" type="Control" unique_id=407149696]
|
||||
layout_mode = 3
|
||||
@@ -33,6 +35,10 @@ theme_type_variation = &"WindowBackground"
|
||||
|
||||
[node name="Quiz" parent="." unique_id=1357908642 instance=ExtResource("quiz_scene")]
|
||||
|
||||
[node name="StatisticsView" parent="." unique_id=1212999001 instance=ExtResource("statistics_view_scene")]
|
||||
visible = false
|
||||
layout_mode = 1
|
||||
|
||||
[node name="SearchPopup" parent="." unique_id=1661250375 instance=ExtResource("9_5ijvr")]
|
||||
visible = false
|
||||
layout_mode = 1
|
||||
@@ -68,29 +74,6 @@ offset_top = 160.0
|
||||
offset_right = 1262.0
|
||||
offset_bottom = 860.0
|
||||
|
||||
[node name="StatisticsPopupPanel" type="PopupPanel" parent="." unique_id=298311407]
|
||||
oversampling_override = 1.0
|
||||
initial_position = 2
|
||||
size = Vector2i(450, 100)
|
||||
|
||||
[node name="Label" type="Label" parent="StatisticsPopupPanel" unique_id=548316493]
|
||||
offset_left = 4.0
|
||||
offset_top = 4.0
|
||||
offset_right = 446.0
|
||||
offset_bottom = 96.0
|
||||
text = "Statistics"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="StatisticsLabel" type="Label" parent="StatisticsPopupPanel" unique_id=58226477]
|
||||
unique_name_in_owner = true
|
||||
offset_left = 4.0
|
||||
offset_top = 4.0
|
||||
offset_right = 446.0
|
||||
offset_bottom = 96.0
|
||||
text = "Total amount of soundtracks in the playlist: 9000"
|
||||
horizontal_alignment = 1
|
||||
vertical_alignment = 1
|
||||
|
||||
[node name="AboutPopup" parent="." unique_id=141261008 instance=ExtResource("16_about_window")]
|
||||
unique_name_in_owner = true
|
||||
visible = false
|
||||
@@ -108,3 +91,11 @@ offset_left = 562.0
|
||||
offset_top = 132.0
|
||||
offset_right = 1262.0
|
||||
offset_bottom = 832.0
|
||||
|
||||
[node name="SoundtrackStatsPopup" parent="." unique_id=1213999001 instance=ExtResource("soundtrack_stats_popup_scene")]
|
||||
visible = false
|
||||
layout_mode = 1
|
||||
offset_left = 610.0
|
||||
offset_top = 140.0
|
||||
offset_right = 610.0
|
||||
offset_bottom = 140.0
|
||||
+29
-10
@@ -10,6 +10,8 @@ keycode = 83
|
||||
[sub_resource type="Shortcut" id="Shortcut_fbju4"]
|
||||
events = [SubResource("InputEventKey_ujjlu")]
|
||||
|
||||
[sub_resource type="ButtonGroup" id="ButtonGroup_tabs"]
|
||||
|
||||
[node name="TopBar" type="Control" unique_id=1489162753]
|
||||
layout_mode = 3
|
||||
anchors_preset = 10
|
||||
@@ -55,16 +57,6 @@ action_mode = 0
|
||||
shortcut = SubResource("Shortcut_fbju4")
|
||||
text = "Search"
|
||||
|
||||
[node name="StatisticsButton" type="Button" parent="." unique_id=2045021269]
|
||||
layout_mode = 0
|
||||
offset_left = 1530.0
|
||||
offset_top = 8.0
|
||||
offset_right = 1600.0
|
||||
offset_bottom = 48.0
|
||||
focus_mode = 0
|
||||
action_mode = 0
|
||||
text = "Statistics"
|
||||
|
||||
[node name="ShortcutsButton" type="Button" parent="." unique_id=2066498226]
|
||||
layout_mode = 0
|
||||
offset_left = 1626.0
|
||||
@@ -94,3 +86,30 @@ offset_bottom = 48.0
|
||||
focus_mode = 0
|
||||
action_mode = 0
|
||||
text = "QR"
|
||||
|
||||
[node name="QuizTab" type="Button" parent="." unique_id=1903412671]
|
||||
layout_mode = 0
|
||||
offset_left = 840.0
|
||||
offset_top = 8.0
|
||||
offset_right = 955.0
|
||||
offset_bottom = 48.0
|
||||
focus_mode = 0
|
||||
theme_type_variation = &"TabButton"
|
||||
toggle_mode = true
|
||||
button_pressed = true
|
||||
action_mode = 0
|
||||
button_group = SubResource("ButtonGroup_tabs")
|
||||
text = "Quiz"
|
||||
|
||||
[node name="StatisticsTab" type="Button" parent="." unique_id=1903412672]
|
||||
layout_mode = 0
|
||||
offset_left = 965.0
|
||||
offset_top = 8.0
|
||||
offset_right = 1080.0
|
||||
offset_bottom = 48.0
|
||||
focus_mode = 0
|
||||
theme_type_variation = &"TabButton"
|
||||
toggle_mode = true
|
||||
action_mode = 0
|
||||
button_group = SubResource("ButtonGroup_tabs")
|
||||
text = "Statistics"
|
||||
+20
-12
@@ -1,9 +1,9 @@
|
||||
class_name MainWindow
|
||||
extends Control
|
||||
## The main window: the top bar, the popups opened from it and the quiz below it.
|
||||
## The main window: the top bar, the popups opened from it, and below it the quiz or the
|
||||
## statistics, depending on the chosen tab.
|
||||
##
|
||||
## Everything that is not part of a quiz lives here, so other views can be added next
|
||||
## to the quiz later.
|
||||
## Everything that is not part of a quiz lives here.
|
||||
|
||||
## Dims the window behind an open popup and keeps clicks from reaching the buttons under it.
|
||||
var popup_backdrop: PopupBackdrop
|
||||
@@ -16,8 +16,8 @@ var popup_backdrop: PopupBackdrop
|
||||
@onready var qr_popup: QRPopup = %QRPopup
|
||||
@onready var about_popup: AboutPopup = %AboutPopup
|
||||
@onready var log_popup: LogPopup = %LogPopup
|
||||
@onready var statistic_popup: PopupPanel = $StatisticsPopupPanel
|
||||
@onready var statistic_label: Label = %StatisticsLabel
|
||||
@onready var statistics_view: StatisticsView = $StatisticsView
|
||||
@onready var soundtrack_stats_popup: SoundtrackStatsPopup = $SoundtrackStatsPopup
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -26,7 +26,7 @@ func _ready() -> void:
|
||||
GameLog.debug("is_mac")
|
||||
top_bar.search_pressed.connect(show_search)
|
||||
top_bar.sync_pressed.connect(sync_soundtracks)
|
||||
top_bar.statistics_pressed.connect(get_statistics)
|
||||
top_bar.tab_selected.connect(show_tab)
|
||||
top_bar.shortcuts_pressed.connect(show_shortcuts)
|
||||
top_bar.about_pressed.connect(show_about)
|
||||
top_bar.qr_pressed.connect(show_qr)
|
||||
@@ -35,6 +35,7 @@ func _ready() -> void:
|
||||
sync_popup.sync_finished.connect(_on_sync_finished)
|
||||
quiz.server_changed.connect(search_popup.get_list_of_soundtracks)
|
||||
quiz.event_logged.connect(log_popup.add_log_row)
|
||||
statistics_view.soundtrack_selected.connect(soundtrack_stats_popup.show_soundtrack)
|
||||
|
||||
search_popup.get_list_of_soundtracks()
|
||||
|
||||
@@ -55,7 +56,8 @@ func _input(event: InputEvent) -> void:
|
||||
|
||||
|
||||
func _popups() -> Array[BasePopup]:
|
||||
return [search_popup, sync_popup, qr_popup, shortcuts_popup, about_popup, log_popup]
|
||||
return [search_popup, sync_popup, qr_popup, shortcuts_popup, about_popup, log_popup,
|
||||
soundtrack_stats_popup]
|
||||
|
||||
|
||||
func show_search() -> void:
|
||||
@@ -78,11 +80,15 @@ func show_qr() -> void:
|
||||
qr_popup.open()
|
||||
|
||||
|
||||
func get_statistics() -> void:
|
||||
GameLog.debug("get_statistics")
|
||||
statistic_popup.visible = true
|
||||
statistic_label.text = "Total amount of soundtracks in the playlist: " \
|
||||
+ str(search_popup.soundtracks.size())
|
||||
## Shows the quiz for [code]&"quiz"[/code] or the statistics for [code]&"statistics"[/code].
|
||||
## A hidden quiz keeps playing its song.
|
||||
func show_tab(tab: StringName) -> void:
|
||||
GameLog.debug("show_tab ", tab)
|
||||
top_bar.select_tab(tab)
|
||||
statistics_view.visible = tab == &"statistics"
|
||||
quiz.visible = !statistics_view.visible
|
||||
if statistics_view.visible:
|
||||
statistics_view.refresh()
|
||||
|
||||
|
||||
func sync_soundtracks() -> void:
|
||||
@@ -100,3 +106,5 @@ func _on_sync_finished() -> void:
|
||||
GameLog.debug("_on_sync_finished")
|
||||
search_popup.get_list_of_soundtracks()
|
||||
quiz.on_sync_finished()
|
||||
if statistics_view.visible:
|
||||
statistics_view.refresh()
|
||||
+14
-4
@@ -1,22 +1,32 @@
|
||||
class_name TopBar
|
||||
extends Control
|
||||
## The bar along the top of the main window, with the title and the buttons for things
|
||||
## that are not part of a quiz.
|
||||
## The bar along the top of the main window, with the title, the Quiz and Statistics tabs
|
||||
## and the buttons for things that are not part of a quiz.
|
||||
|
||||
signal search_pressed
|
||||
signal sync_pressed
|
||||
signal statistics_pressed
|
||||
signal shortcuts_pressed
|
||||
signal about_pressed
|
||||
signal qr_pressed
|
||||
## Emitted when a tab is chosen: [code]&"quiz"[/code] or [code]&"statistics"[/code].
|
||||
signal tab_selected(tab: StringName)
|
||||
|
||||
@onready var sync_button: Button = $SyncButton
|
||||
@onready var quiz_tab: Button = $QuizTab
|
||||
@onready var statistics_tab: Button = $StatisticsTab
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
$SearchButton.pressed.connect(search_pressed.emit)
|
||||
sync_button.pressed.connect(sync_pressed.emit)
|
||||
$StatisticsButton.pressed.connect(statistics_pressed.emit)
|
||||
$ShortcutsButton.pressed.connect(shortcuts_pressed.emit)
|
||||
$AboutButton.pressed.connect(about_pressed.emit)
|
||||
$QRButton.pressed.connect(qr_pressed.emit)
|
||||
quiz_tab.pressed.connect(tab_selected.emit.bind(&"quiz"))
|
||||
statistics_tab.pressed.connect(tab_selected.emit.bind(&"statistics"))
|
||||
|
||||
|
||||
## Shows [param tab] as the chosen tab, without emitting [signal tab_selected].
|
||||
func select_tab(tab: StringName) -> void:
|
||||
quiz_tab.set_pressed_no_signal(tab == &"quiz")
|
||||
statistics_tab.set_pressed_no_signal(tab == &"statistics")
|
||||
@@ -0,0 +1,53 @@
|
||||
[gd_scene format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://popups/soundtrack_stats_popup.gd" id="popup_script"]
|
||||
|
||||
[node name="SoundtrackStatsPopup" type="Control" unique_id=1213000001 node_paths=PackedStringArray("panel")]
|
||||
layout_mode = 3
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
script = ExtResource("popup_script")
|
||||
panel = NodePath("StatsPanel")
|
||||
|
||||
[node name="StatsPanel" type="Panel" parent="." unique_id=1213000002]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 0
|
||||
offset_right = 700.0
|
||||
offset_bottom = 800.0
|
||||
theme_type_variation = &"PopupBackground"
|
||||
|
||||
[node name="TitleLabel" type="Label" parent="StatsPanel" unique_id=1213000003]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 0
|
||||
offset_left = 20.0
|
||||
offset_top = 12.0
|
||||
offset_right = 680.0
|
||||
offset_bottom = 50.0
|
||||
theme_type_variation = &"Title"
|
||||
horizontal_alignment = 1
|
||||
text_overrun_behavior = 3
|
||||
|
||||
[node name="SummaryLabel" type="Label" parent="StatsPanel" unique_id=1213000004]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 0
|
||||
offset_left = 20.0
|
||||
offset_top = 54.0
|
||||
offset_right = 680.0
|
||||
offset_bottom = 80.0
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="ScrollContainer" type="ScrollContainer" parent="StatsPanel" unique_id=1213000005]
|
||||
layout_mode = 0
|
||||
offset_left = 20.0
|
||||
offset_top = 92.0
|
||||
offset_right = 680.0
|
||||
offset_bottom = 784.0
|
||||
horizontal_scroll_mode = 0
|
||||
|
||||
[node name="SongList" type="VBoxContainer" parent="StatsPanel/ScrollContainer" unique_id=1213000006]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
+6
-78
@@ -1,18 +1,12 @@
|
||||
class_name SearchPopup
|
||||
extends BasePopup
|
||||
## Searches every soundtrack on the server as you type. Matches ignore case, spaces and
|
||||
## special characters, and also find the letters in order with others in between.
|
||||
## Searches every soundtrack on the server as you type, with [SoundtrackSearch].
|
||||
|
||||
var soundtracks: Array = []
|
||||
|
||||
## Each soundtrack as a Dictionary with its original, lowercase and cleaned name.
|
||||
var precomputed_soundtracks: Array = []
|
||||
|
||||
## Compiled patterns for the letters-in-order search, by cleaned search term.
|
||||
var regex_cache: Dictionary = {}
|
||||
|
||||
var label_pool: Array[Label] = []
|
||||
|
||||
var _matcher: SoundtrackSearch = SoundtrackSearch.new()
|
||||
|
||||
@onready var search_list: VBoxContainer = %SearchResults
|
||||
@onready var search_panel: Panel = %SearchPanel
|
||||
@onready var clear_button: Button = $ClearButton
|
||||
@@ -67,70 +61,10 @@ func perform_search(search_text: String) -> void:
|
||||
child.visible = false
|
||||
label_pool.append(child)
|
||||
|
||||
for soundtrack: String in find_matches(search_text):
|
||||
for soundtrack: String in _matcher.find_matches(search_text):
|
||||
add_soundtrack_reuse(soundtrack)
|
||||
|
||||
|
||||
## Returns the soundtracks matching [param search_text], best matches first: plain
|
||||
## case-insensitive matches, then matches ignoring spaces and special characters,
|
||||
## then matches with the letters in order but not next to each other.
|
||||
func find_matches(search_text: String) -> Array[String]:
|
||||
var seen: Dictionary = {}
|
||||
var search_text_lower: String = search_text.to_lower()
|
||||
var clean_search_text: String = clean_term(search_text)
|
||||
|
||||
var matches: Array[String] = []
|
||||
|
||||
# Pass 1: Exact case-insensitive contains match on lowercase versions
|
||||
for i: int in range(precomputed_soundtracks.size()):
|
||||
var st_data: Dictionary = precomputed_soundtracks[i]
|
||||
if st_data["lowercase"].contains(search_text_lower):
|
||||
if not st_data["original"] in seen:
|
||||
seen[st_data["original"]] = true
|
||||
matches.append(st_data["original"])
|
||||
|
||||
# Pass 2: Contains match on cleaned terms
|
||||
for i: int in range(precomputed_soundtracks.size()):
|
||||
var st_data: Dictionary = precomputed_soundtracks[i]
|
||||
if st_data["cleaned"].contains(clean_search_text):
|
||||
if not st_data["original"] in seen:
|
||||
seen[st_data["original"]] = true
|
||||
matches.append(st_data["original"])
|
||||
|
||||
# Pass 3: Regex match on cleaned terms
|
||||
var regex: RegEx = compile_regex_cached(clean_search_text)
|
||||
for i: int in range(precomputed_soundtracks.size()):
|
||||
var st_data: Dictionary = precomputed_soundtracks[i]
|
||||
if regex.search(st_data["cleaned"]):
|
||||
if not st_data["original"] in seen:
|
||||
seen[st_data["original"]] = true
|
||||
matches.append(st_data["original"])
|
||||
|
||||
return matches
|
||||
|
||||
|
||||
## Cleans a term by removing spaces and replacing special characters.
|
||||
## Matches MusicServer's clean_term() function.
|
||||
func clean_term(term: String) -> String:
|
||||
return term.replace(" ", "").replace("é", "e").replace("+", "plus").replace("&", "and") \
|
||||
.replace("'n", "and").to_lower()
|
||||
|
||||
|
||||
## Compiles the letters-in-order pattern for [param search_term], once per term.
|
||||
func compile_regex_cached(search_term: String) -> RegEx:
|
||||
if not search_term in regex_cache:
|
||||
var regex: RegEx = RegEx.new()
|
||||
var regex_text: String = ".*"
|
||||
for letter: String in search_term:
|
||||
# Escape regex metacharacters so searches like "Q*bert" or "(Remix)" work
|
||||
if "\\^$.|?*+()[]{}".contains(letter):
|
||||
regex_text += "\\"
|
||||
regex_text += letter + ".*"
|
||||
regex.compile(regex_text)
|
||||
regex_cache[search_term] = regex
|
||||
return regex_cache[search_term]
|
||||
|
||||
|
||||
func add_soundtrack(soundtrack: String) -> void:
|
||||
# Backward compatibility - delegates to reuse version
|
||||
add_soundtrack_reuse(soundtrack)
|
||||
@@ -177,17 +111,11 @@ func get_list_of_soundtracks() -> void:
|
||||
MusicServerClient.fetch_all_soundtracks_ordered(handle_soundtracks)
|
||||
|
||||
|
||||
## Replaces the searchable soundtracks and precomputes their search forms.
|
||||
## Replaces the searchable soundtracks.
|
||||
func set_soundtracks(list: Array) -> void:
|
||||
soundtracks = []
|
||||
precomputed_soundtracks = []
|
||||
soundtracks.append_array(list)
|
||||
for soundtrack: String in soundtracks:
|
||||
precomputed_soundtracks.append({
|
||||
"original": soundtrack,
|
||||
"lowercase": soundtrack.to_lower(),
|
||||
"cleaned": clean_term(soundtrack)
|
||||
})
|
||||
_matcher.set_names(soundtracks)
|
||||
|
||||
|
||||
func clear() -> void:
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
class_name SoundtrackStatsPopup
|
||||
extends BasePopup
|
||||
## Shows a soundtrack's play statistics and every song in it, most played first.
|
||||
## Opened by clicking a card in the statistics view.
|
||||
|
||||
@onready var title_label: Label = %TitleLabel
|
||||
@onready var summary_label: Label = %SummaryLabel
|
||||
@onready var song_list: VBoxContainer = %SongList
|
||||
|
||||
|
||||
## Shows [param soundtrack] and opens the popup.
|
||||
func show_soundtrack(soundtrack: SoundtrackWithSongs) -> void:
|
||||
title_label.text = soundtrack.soundtrack_name
|
||||
var summary: PackedStringArray = [SoundtrackCard.played_text(soundtrack.soundtrack_played)]
|
||||
var last_played: String = SoundtrackCard.last_played_text(soundtrack.soundtrack_last_played)
|
||||
if !last_played.is_empty():
|
||||
summary.append(last_played)
|
||||
summary.append("%d %s" % [soundtrack.songs.size(),
|
||||
"song" if soundtrack.songs.size() == 1 else "songs"])
|
||||
summary_label.text = " · ".join(summary)
|
||||
|
||||
Settings.delete_children(song_list)
|
||||
var songs: Array[SongInfoForStats] = SoundtrackCard.songs_by_plays(soundtrack.songs)
|
||||
for row: int in songs.size():
|
||||
song_list.add_child(ListRow.create(SoundtrackCard.song_text(songs[row]), row,
|
||||
&"TextLarge"))
|
||||
open()
|
||||
@@ -0,0 +1 @@
|
||||
uid://sjteu2abyr0f
|
||||
+9
-12
@@ -22,19 +22,16 @@ var retry_seconds: float = 5.0
|
||||
## Preloading waits for the list, because loading the list clears the playlist.
|
||||
func load_song_list() -> void:
|
||||
GameLog.debug("load_song_list")
|
||||
var song_list_fetched: Callable = func song_list_fetched(data: Variant) -> void:
|
||||
var song_list_fetched: Callable = func song_list_fetched(songs: Array[SongInfo]) -> void:
|
||||
Playlist.clear_song_list()
|
||||
if typeof(data) == TYPE_ARRAY:
|
||||
for d: Dictionary in data:
|
||||
for info: SongInfo in songs:
|
||||
var new_song_object: SongObject = SongObject.new()
|
||||
new_song_object.song_number = d.SongNo
|
||||
new_song_object.soundtrack_title = d.Soundtrack
|
||||
new_song_object.song_title = d.Song
|
||||
new_song_object.song_number = info.song_no
|
||||
new_song_object.soundtrack_title = info.soundtrack
|
||||
new_song_object.song_title = info.song
|
||||
new_song_object.is_answered = true
|
||||
new_song_object.has_played = true
|
||||
Playlist.add_to_playlist(new_song_object)
|
||||
elif data != null:
|
||||
GameLog.warning("Unexpected data")
|
||||
Playlist.update_last_played_song()
|
||||
song_list_loaded.emit()
|
||||
fetch_song_if_idle()
|
||||
@@ -80,11 +77,11 @@ func _add_song_to_queue(new_song_object: SongObject) -> void:
|
||||
|
||||
|
||||
func _fetch_song_info(new_song_object: SongObject) -> void:
|
||||
var song_info_fetched: Callable = func song_info_fetched(data_received: Dictionary) -> void:
|
||||
var song_info_fetched: Callable = func song_info_fetched(info: SongInfo) -> void:
|
||||
GameLog.debug("song_info_fetched")
|
||||
new_song_object.song_number = data_received.SongNo
|
||||
new_song_object.soundtrack_title = data_received.Soundtrack
|
||||
new_song_object.song_title = data_received.Song
|
||||
new_song_object.song_number = info.song_no
|
||||
new_song_object.soundtrack_title = info.soundtrack
|
||||
new_song_object.song_title = info.song
|
||||
GameLog.debug("new_song_object: ", new_song_object.to_string())
|
||||
Playlist.add_to_playlist(new_song_object)
|
||||
if Playlist.should_preload_more_songs():
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# Generated by tools/generate_api_types.gd from server/openapi.json.
|
||||
# Do not edit; change the server and generate again.
|
||||
class_name ApiError
|
||||
extends RefCounted
|
||||
## The [code]APIError[/code] schema of the MusicServer API.
|
||||
|
||||
## What went wrong
|
||||
var error: String
|
||||
|
||||
|
||||
## Reads the fields from parsed JSON. Missing fields keep their defaults.
|
||||
static func from_dict(data: Dictionary) -> ApiError:
|
||||
var result: ApiError = ApiError.new()
|
||||
result.error = ApiJson.get_string(data, "error", "")
|
||||
return result
|
||||
|
||||
|
||||
## Returns the fields under their JSON names, ready for [method JSON.stringify].
|
||||
func to_dict() -> Dictionary:
|
||||
return {
|
||||
"error": error,
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://4ak2e86vltp2
|
||||
@@ -0,0 +1,36 @@
|
||||
# Generated by tools/generate_api_types.gd from server/openapi.json.
|
||||
# Do not edit; change the server and generate again.
|
||||
class_name ApiJson
|
||||
extends RefCounted
|
||||
## Reads fields from parsed JSON for the generated API classes. A field that is
|
||||
## missing, null or of the wrong type gives the default instead.
|
||||
|
||||
|
||||
static func get_string(data: Dictionary, key: String, default: String = "") -> String:
|
||||
var value: Variant = data.get(key)
|
||||
return value if value is String else default
|
||||
|
||||
|
||||
static func get_int(data: Dictionary, key: String, default: int = 0) -> int:
|
||||
var value: Variant = data.get(key)
|
||||
return int(value) if value is int || value is float else default
|
||||
|
||||
|
||||
static func get_float(data: Dictionary, key: String, default: float = 0.0) -> float:
|
||||
var value: Variant = data.get(key)
|
||||
return float(value) if value is int || value is float else default
|
||||
|
||||
|
||||
static func get_bool(data: Dictionary, key: String, default: bool = false) -> bool:
|
||||
var value: Variant = data.get(key)
|
||||
return value if value is bool else default
|
||||
|
||||
|
||||
static func get_array(data: Dictionary, key: String) -> Array:
|
||||
var value: Variant = data.get(key)
|
||||
return value if value is Array else []
|
||||
|
||||
|
||||
static func get_dictionary(data: Dictionary, key: String) -> Dictionary:
|
||||
var value: Variant = data.get(key)
|
||||
return value if value is Dictionary else {}
|
||||
@@ -0,0 +1 @@
|
||||
uid://kpihxs4wsldn
|
||||
@@ -0,0 +1,24 @@
|
||||
# Generated by tools/generate_api_types.gd from server/openapi.json.
|
||||
# Do not edit; change the server and generate again.
|
||||
class_name ProgressResponse
|
||||
extends RefCounted
|
||||
## The [code]ProgressResponse[/code] schema of the MusicServer API.
|
||||
|
||||
var progress: String
|
||||
var time_spent: String
|
||||
|
||||
|
||||
## Reads the fields from parsed JSON. Missing fields keep their defaults.
|
||||
static func from_dict(data: Dictionary) -> ProgressResponse:
|
||||
var result: ProgressResponse = ProgressResponse.new()
|
||||
result.progress = ApiJson.get_string(data, "progress", "")
|
||||
result.time_spent = ApiJson.get_string(data, "time_spent", "")
|
||||
return result
|
||||
|
||||
|
||||
## Returns the fields under their JSON names, ready for [method JSON.stringify].
|
||||
func to_dict() -> Dictionary:
|
||||
return {
|
||||
"progress": progress,
|
||||
"time_spent": time_spent,
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://blka8xmsytbw1
|
||||
@@ -0,0 +1,36 @@
|
||||
# Generated by tools/generate_api_types.gd from server/openapi.json.
|
||||
# Do not edit; change the server and generate again.
|
||||
class_name Song
|
||||
extends RefCounted
|
||||
## The [code]Song[/code] schema of the MusicServer API.
|
||||
|
||||
var file_name: String
|
||||
var name: String
|
||||
var soundtrack_name: String
|
||||
var soundtrack_uuid: String
|
||||
var times_played: int
|
||||
var uuid: String
|
||||
|
||||
|
||||
## Reads the fields from parsed JSON. Missing fields keep their defaults.
|
||||
static func from_dict(data: Dictionary) -> Song:
|
||||
var result: Song = Song.new()
|
||||
result.file_name = ApiJson.get_string(data, "file_name", "")
|
||||
result.name = ApiJson.get_string(data, "name", "")
|
||||
result.soundtrack_name = ApiJson.get_string(data, "soundtrack_name", "")
|
||||
result.soundtrack_uuid = ApiJson.get_string(data, "soundtrack_uuid", "")
|
||||
result.times_played = ApiJson.get_int(data, "times_played", 0)
|
||||
result.uuid = ApiJson.get_string(data, "uuid", "")
|
||||
return result
|
||||
|
||||
|
||||
## Returns the fields under their JSON names, ready for [method JSON.stringify].
|
||||
func to_dict() -> Dictionary:
|
||||
return {
|
||||
"file_name": file_name,
|
||||
"name": name,
|
||||
"soundtrack_name": soundtrack_name,
|
||||
"soundtrack_uuid": soundtrack_uuid,
|
||||
"times_played": times_played,
|
||||
"uuid": uuid,
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://cmsr3kl148gth
|
||||
@@ -0,0 +1,42 @@
|
||||
# Generated by tools/generate_api_types.gd from server/openapi.json.
|
||||
# Do not edit; change the server and generate again.
|
||||
class_name SongInfo
|
||||
extends RefCounted
|
||||
## The [code]SongInfo[/code] schema of the MusicServer API.
|
||||
|
||||
var currently_playing: bool
|
||||
var song: String
|
||||
var song_no: int
|
||||
var song_played: int
|
||||
var song_uuid: String
|
||||
var soundtrack: String
|
||||
var soundtrack_played: int
|
||||
var soundtrack_uuid: String
|
||||
|
||||
|
||||
## Reads the fields from parsed JSON. Missing fields keep their defaults.
|
||||
static func from_dict(data: Dictionary) -> SongInfo:
|
||||
var result: SongInfo = SongInfo.new()
|
||||
result.currently_playing = ApiJson.get_bool(data, "CurrentlyPlaying", false)
|
||||
result.song = ApiJson.get_string(data, "Song", "")
|
||||
result.song_no = ApiJson.get_int(data, "SongNo", 0)
|
||||
result.song_played = ApiJson.get_int(data, "SongPlayed", 0)
|
||||
result.song_uuid = ApiJson.get_string(data, "SongUuid", "")
|
||||
result.soundtrack = ApiJson.get_string(data, "Soundtrack", "")
|
||||
result.soundtrack_played = ApiJson.get_int(data, "SoundtrackPlayed", 0)
|
||||
result.soundtrack_uuid = ApiJson.get_string(data, "SoundtrackUuid", "")
|
||||
return result
|
||||
|
||||
|
||||
## Returns the fields under their JSON names, ready for [method JSON.stringify].
|
||||
func to_dict() -> Dictionary:
|
||||
return {
|
||||
"CurrentlyPlaying": currently_playing,
|
||||
"Song": song,
|
||||
"SongNo": song_no,
|
||||
"SongPlayed": song_played,
|
||||
"SongUuid": song_uuid,
|
||||
"Soundtrack": soundtrack,
|
||||
"SoundtrackPlayed": soundtrack_played,
|
||||
"SoundtrackUuid": soundtrack_uuid,
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://blybmr7a2rm6a
|
||||
@@ -0,0 +1,42 @@
|
||||
# Generated by tools/generate_api_types.gd from server/openapi.json.
|
||||
# Do not edit; change the server and generate again.
|
||||
class_name SongInfoForStats
|
||||
extends RefCounted
|
||||
## The [code]SongInfoForStats[/code] schema of the MusicServer API.
|
||||
|
||||
var file_name: String
|
||||
var path: String
|
||||
var song_name: String
|
||||
var song_uuid: String
|
||||
var soundtrack_id: int
|
||||
var soundtrack_name: String
|
||||
var soundtrack_uuid: String
|
||||
var times_played: int
|
||||
|
||||
|
||||
## Reads the fields from parsed JSON. Missing fields keep their defaults.
|
||||
static func from_dict(data: Dictionary) -> SongInfoForStats:
|
||||
var result: SongInfoForStats = SongInfoForStats.new()
|
||||
result.file_name = ApiJson.get_string(data, "file_name", "")
|
||||
result.path = ApiJson.get_string(data, "path", "")
|
||||
result.song_name = ApiJson.get_string(data, "song_name", "")
|
||||
result.song_uuid = ApiJson.get_string(data, "song_uuid", "")
|
||||
result.soundtrack_id = ApiJson.get_int(data, "soundtrack_id", 0)
|
||||
result.soundtrack_name = ApiJson.get_string(data, "soundtrack_name", "")
|
||||
result.soundtrack_uuid = ApiJson.get_string(data, "soundtrack_uuid", "")
|
||||
result.times_played = ApiJson.get_int(data, "times_played", 0)
|
||||
return result
|
||||
|
||||
|
||||
## Returns the fields under their JSON names, ready for [method JSON.stringify].
|
||||
func to_dict() -> Dictionary:
|
||||
return {
|
||||
"file_name": file_name,
|
||||
"path": path,
|
||||
"song_name": song_name,
|
||||
"song_uuid": song_uuid,
|
||||
"soundtrack_id": soundtrack_id,
|
||||
"soundtrack_name": soundtrack_name,
|
||||
"soundtrack_uuid": soundtrack_uuid,
|
||||
"times_played": times_played,
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://chexehl48yklr
|
||||
@@ -0,0 +1,33 @@
|
||||
# Generated by tools/generate_api_types.gd from server/openapi.json.
|
||||
# Do not edit; change the server and generate again.
|
||||
class_name Soundtrack
|
||||
extends RefCounted
|
||||
## The [code]Soundtrack[/code] schema of the MusicServer API.
|
||||
|
||||
var added: String
|
||||
var last_played: String
|
||||
var name: String
|
||||
var times_played: int
|
||||
var uuid: String
|
||||
|
||||
|
||||
## Reads the fields from parsed JSON. Missing fields keep their defaults.
|
||||
static func from_dict(data: Dictionary) -> Soundtrack:
|
||||
var result: Soundtrack = Soundtrack.new()
|
||||
result.added = ApiJson.get_string(data, "added", "")
|
||||
result.last_played = ApiJson.get_string(data, "last_played", "")
|
||||
result.name = ApiJson.get_string(data, "name", "")
|
||||
result.times_played = ApiJson.get_int(data, "times_played", 0)
|
||||
result.uuid = ApiJson.get_string(data, "uuid", "")
|
||||
return result
|
||||
|
||||
|
||||
## Returns the fields under their JSON names, ready for [method JSON.stringify].
|
||||
func to_dict() -> Dictionary:
|
||||
return {
|
||||
"added": added,
|
||||
"last_played": last_played,
|
||||
"name": name,
|
||||
"times_played": times_played,
|
||||
"uuid": uuid,
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://bdbaxmn66tuv7
|
||||
@@ -0,0 +1,38 @@
|
||||
# Generated by tools/generate_api_types.gd from server/openapi.json.
|
||||
# Do not edit; change the server and generate again.
|
||||
class_name SoundtrackWithSongList
|
||||
extends RefCounted
|
||||
## The [code]SoundtrackWithSongList[/code] schema of the MusicServer API.
|
||||
|
||||
var added: String
|
||||
var last_played: String
|
||||
var name: String
|
||||
var songs: Array[Song]
|
||||
var times_played: int
|
||||
var uuid: String
|
||||
|
||||
|
||||
## Reads the fields from parsed JSON. Missing fields keep their defaults.
|
||||
static func from_dict(data: Dictionary) -> SoundtrackWithSongList:
|
||||
var result: SoundtrackWithSongList = SoundtrackWithSongList.new()
|
||||
result.added = ApiJson.get_string(data, "added", "")
|
||||
result.last_played = ApiJson.get_string(data, "last_played", "")
|
||||
result.name = ApiJson.get_string(data, "name", "")
|
||||
for item: Variant in ApiJson.get_array(data, "songs"):
|
||||
if item is Dictionary:
|
||||
result.songs.append(Song.from_dict(item))
|
||||
result.times_played = ApiJson.get_int(data, "times_played", 0)
|
||||
result.uuid = ApiJson.get_string(data, "uuid", "")
|
||||
return result
|
||||
|
||||
|
||||
## Returns the fields under their JSON names, ready for [method JSON.stringify].
|
||||
func to_dict() -> Dictionary:
|
||||
return {
|
||||
"added": added,
|
||||
"last_played": last_played,
|
||||
"name": name,
|
||||
"songs": songs.map(func(item: Song) -> Dictionary: return item.to_dict()),
|
||||
"times_played": times_played,
|
||||
"uuid": uuid,
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://cj4rx6m53oarm
|
||||
@@ -0,0 +1,38 @@
|
||||
# Generated by tools/generate_api_types.gd from server/openapi.json.
|
||||
# Do not edit; change the server and generate again.
|
||||
class_name SoundtrackWithSongs
|
||||
extends RefCounted
|
||||
## The [code]SoundtrackWithSongs[/code] schema of the MusicServer API.
|
||||
|
||||
var songs: Array[SongInfoForStats]
|
||||
var soundtrack_id: int
|
||||
var soundtrack_last_played: String
|
||||
var soundtrack_name: String
|
||||
var soundtrack_played: int
|
||||
var soundtrack_uuid: String
|
||||
|
||||
|
||||
## Reads the fields from parsed JSON. Missing fields keep their defaults.
|
||||
static func from_dict(data: Dictionary) -> SoundtrackWithSongs:
|
||||
var result: SoundtrackWithSongs = SoundtrackWithSongs.new()
|
||||
for item: Variant in ApiJson.get_array(data, "songs"):
|
||||
if item is Dictionary:
|
||||
result.songs.append(SongInfoForStats.from_dict(item))
|
||||
result.soundtrack_id = ApiJson.get_int(data, "soundtrack_id", 0)
|
||||
result.soundtrack_last_played = ApiJson.get_string(data, "soundtrack_last_played", "")
|
||||
result.soundtrack_name = ApiJson.get_string(data, "soundtrack_name", "")
|
||||
result.soundtrack_played = ApiJson.get_int(data, "soundtrack_played", 0)
|
||||
result.soundtrack_uuid = ApiJson.get_string(data, "soundtrack_uuid", "")
|
||||
return result
|
||||
|
||||
|
||||
## Returns the fields under their JSON names, ready for [method JSON.stringify].
|
||||
func to_dict() -> Dictionary:
|
||||
return {
|
||||
"songs": songs.map(func(item: SongInfoForStats) -> Dictionary: return item.to_dict()),
|
||||
"soundtrack_id": soundtrack_id,
|
||||
"soundtrack_last_played": soundtrack_last_played,
|
||||
"soundtrack_name": soundtrack_name,
|
||||
"soundtrack_played": soundtrack_played,
|
||||
"soundtrack_uuid": soundtrack_uuid,
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://yxxvcbbr5ioi
|
||||
@@ -0,0 +1,39 @@
|
||||
# Generated by tools/generate_api_types.gd from server/openapi.json.
|
||||
# Do not edit; change the server and generate again.
|
||||
class_name StatisticsSummary
|
||||
extends RefCounted
|
||||
## The [code]StatisticsSummary[/code] schema of the MusicServer API.
|
||||
|
||||
var avg_soundtrack_plays: float
|
||||
var max_soundtrack_plays: int
|
||||
var min_soundtrack_plays: int
|
||||
var never_played_soundtracks: int
|
||||
var played_soundtracks: int
|
||||
var total_soundtrack_plays: int
|
||||
var total_soundtracks: int
|
||||
|
||||
|
||||
## Reads the fields from parsed JSON. Missing fields keep their defaults.
|
||||
static func from_dict(data: Dictionary) -> StatisticsSummary:
|
||||
var result: StatisticsSummary = StatisticsSummary.new()
|
||||
result.avg_soundtrack_plays = ApiJson.get_float(data, "avg_soundtrack_plays", 0.0)
|
||||
result.max_soundtrack_plays = ApiJson.get_int(data, "max_soundtrack_plays", 0)
|
||||
result.min_soundtrack_plays = ApiJson.get_int(data, "min_soundtrack_plays", 0)
|
||||
result.never_played_soundtracks = ApiJson.get_int(data, "never_played_soundtracks", 0)
|
||||
result.played_soundtracks = ApiJson.get_int(data, "played_soundtracks", 0)
|
||||
result.total_soundtrack_plays = ApiJson.get_int(data, "total_soundtrack_plays", 0)
|
||||
result.total_soundtracks = ApiJson.get_int(data, "total_soundtracks", 0)
|
||||
return result
|
||||
|
||||
|
||||
## Returns the fields under their JSON names, ready for [method JSON.stringify].
|
||||
func to_dict() -> Dictionary:
|
||||
return {
|
||||
"avg_soundtrack_plays": avg_soundtrack_plays,
|
||||
"max_soundtrack_plays": max_soundtrack_plays,
|
||||
"min_soundtrack_plays": min_soundtrack_plays,
|
||||
"never_played_soundtracks": never_played_soundtracks,
|
||||
"played_soundtracks": played_soundtracks,
|
||||
"total_soundtrack_plays": total_soundtrack_plays,
|
||||
"total_soundtracks": total_soundtracks,
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://d2acyameyuhv2
|
||||
@@ -0,0 +1,21 @@
|
||||
# Generated by tools/generate_api_types.gd from server/openapi.json.
|
||||
# Do not edit; change the server and generate again.
|
||||
class_name StatusMessage
|
||||
extends RefCounted
|
||||
## The [code]StatusMessage[/code] schema of the MusicServer API.
|
||||
|
||||
var status: String
|
||||
|
||||
|
||||
## Reads the fields from parsed JSON. Missing fields keep their defaults.
|
||||
static func from_dict(data: Dictionary) -> StatusMessage:
|
||||
var result: StatusMessage = StatusMessage.new()
|
||||
result.status = ApiJson.get_string(data, "status", "")
|
||||
return result
|
||||
|
||||
|
||||
## Returns the fields under their JSON names, ready for [method JSON.stringify].
|
||||
func to_dict() -> Dictionary:
|
||||
return {
|
||||
"status": status,
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://5inwlixefu0m
|
||||
@@ -0,0 +1,41 @@
|
||||
# Generated by tools/generate_api_types.gd from server/openapi.json.
|
||||
# Do not edit; change the server and generate again.
|
||||
class_name SyncResponse
|
||||
extends RefCounted
|
||||
## The [code]SyncResponse[/code] schema of the MusicServer API.
|
||||
|
||||
var catched_errors: Array[String]
|
||||
var soundtracks_added: Array[String]
|
||||
var soundtracks_changed_content: Array[String]
|
||||
var soundtracks_changed_title: Dictionary[String, String]
|
||||
var soundtracks_re_added: Array[String]
|
||||
var soundtracks_removed: Array[String]
|
||||
var total_time: String
|
||||
|
||||
|
||||
## Reads the fields from parsed JSON. Missing fields keep their defaults.
|
||||
static func from_dict(data: Dictionary) -> SyncResponse:
|
||||
var result: SyncResponse = SyncResponse.new()
|
||||
result.catched_errors.assign(ApiJson.get_array(data, "catched_errors"))
|
||||
result.soundtracks_added.assign(ApiJson.get_array(data, "soundtracks_added"))
|
||||
result.soundtracks_changed_content.assign(
|
||||
ApiJson.get_array(data, "soundtracks_changed_content"))
|
||||
result.soundtracks_changed_title.assign(
|
||||
ApiJson.get_dictionary(data, "soundtracks_changed_title"))
|
||||
result.soundtracks_re_added.assign(ApiJson.get_array(data, "soundtracks_re_added"))
|
||||
result.soundtracks_removed.assign(ApiJson.get_array(data, "soundtracks_removed"))
|
||||
result.total_time = ApiJson.get_string(data, "total_time", "")
|
||||
return result
|
||||
|
||||
|
||||
## Returns the fields under their JSON names, ready for [method JSON.stringify].
|
||||
func to_dict() -> Dictionary:
|
||||
return {
|
||||
"catched_errors": catched_errors,
|
||||
"soundtracks_added": soundtracks_added,
|
||||
"soundtracks_changed_content": soundtracks_changed_content,
|
||||
"soundtracks_changed_title": soundtracks_changed_title,
|
||||
"soundtracks_re_added": soundtracks_re_added,
|
||||
"soundtracks_removed": soundtracks_removed,
|
||||
"total_time": total_time,
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://d2fcfuusfbhah
|
||||
@@ -0,0 +1,26 @@
|
||||
# Generated by tools/generate_api_types.gd from server/openapi.json.
|
||||
# Do not edit; change the server and generate again.
|
||||
class_name TokenRequest
|
||||
extends RefCounted
|
||||
## The [code]TokenRequest[/code] schema of the MusicServer API.
|
||||
|
||||
## Kind of client, for example web, godot or api
|
||||
var client_type: String = "web"
|
||||
## API password, required when the server has one configured
|
||||
var password: String
|
||||
|
||||
|
||||
## Reads the fields from parsed JSON. Missing fields keep their defaults.
|
||||
static func from_dict(data: Dictionary) -> TokenRequest:
|
||||
var result: TokenRequest = TokenRequest.new()
|
||||
result.client_type = ApiJson.get_string(data, "client_type", "web")
|
||||
result.password = ApiJson.get_string(data, "password", "")
|
||||
return result
|
||||
|
||||
|
||||
## Returns the fields under their JSON names, ready for [method JSON.stringify].
|
||||
func to_dict() -> Dictionary:
|
||||
return {
|
||||
"client_type": client_type,
|
||||
"password": password,
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://c83b66bi6vpmp
|
||||
@@ -0,0 +1,28 @@
|
||||
# Generated by tools/generate_api_types.gd from server/openapi.json.
|
||||
# Do not edit; change the server and generate again.
|
||||
class_name TokenResponse
|
||||
extends RefCounted
|
||||
## The [code]TokenResponse[/code] schema of the MusicServer API.
|
||||
|
||||
var client_type: String
|
||||
var expires_at: String
|
||||
## Send as Authorization: Bearer <token>
|
||||
var token: String
|
||||
|
||||
|
||||
## Reads the fields from parsed JSON. Missing fields keep their defaults.
|
||||
static func from_dict(data: Dictionary) -> TokenResponse:
|
||||
var result: TokenResponse = TokenResponse.new()
|
||||
result.client_type = ApiJson.get_string(data, "client_type", "")
|
||||
result.expires_at = ApiJson.get_string(data, "expires_at", "")
|
||||
result.token = ApiJson.get_string(data, "token", "")
|
||||
return result
|
||||
|
||||
|
||||
## Returns the fields under their JSON names, ready for [method JSON.stringify].
|
||||
func to_dict() -> Dictionary:
|
||||
return {
|
||||
"client_type": client_type,
|
||||
"expires_at": expires_at,
|
||||
"token": token,
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://4a44v85naqw0
|
||||
@@ -0,0 +1,24 @@
|
||||
# Generated by tools/generate_api_types.gd from server/openapi.json.
|
||||
# Do not edit; change the server and generate again.
|
||||
class_name VersionData
|
||||
extends RefCounted
|
||||
## The [code]VersionData[/code] schema of the MusicServer API.
|
||||
|
||||
var changelog: Array[String]
|
||||
var version: String
|
||||
|
||||
|
||||
## Reads the fields from parsed JSON. Missing fields keep their defaults.
|
||||
static func from_dict(data: Dictionary) -> VersionData:
|
||||
var result: VersionData = VersionData.new()
|
||||
result.changelog.assign(ApiJson.get_array(data, "changelog"))
|
||||
result.version = ApiJson.get_string(data, "version", "")
|
||||
return result
|
||||
|
||||
|
||||
## Returns the fields under their JSON names, ready for [method JSON.stringify].
|
||||
func to_dict() -> Dictionary:
|
||||
return {
|
||||
"changelog": changelog,
|
||||
"version": version,
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://no7af2fycku5
|
||||
@@ -17,8 +17,15 @@ func _init(owner: Node) -> void:
|
||||
|
||||
## Requests [param url] and calls [param on_complete] with
|
||||
## [code](response_code: int, body: PackedByteArray)[/code]. Connection errors and
|
||||
## timeouts are reported with response code 0.
|
||||
func request(url: String, method: int, on_complete: Callable) -> void:
|
||||
## timeouts are reported with response code 0. [param headers] are sent as-is; a
|
||||
## [param body] is sent as JSON.
|
||||
func request(
|
||||
url: String,
|
||||
method: int,
|
||||
on_complete: Callable,
|
||||
headers: PackedStringArray = PackedStringArray(),
|
||||
body: String = "",
|
||||
) -> void:
|
||||
var http_request: HTTPRequest = HTTPRequest.new()
|
||||
http_request.timeout = timeout_seconds
|
||||
_owner.add_child(http_request)
|
||||
@@ -27,15 +34,16 @@ func request(url: String, method: int, on_complete: Callable) -> void:
|
||||
result: int,
|
||||
response_code: int,
|
||||
_headers: PackedStringArray,
|
||||
body: PackedByteArray,
|
||||
response_body: PackedByteArray,
|
||||
) -> void:
|
||||
http_request.queue_free()
|
||||
on_complete.call(response_code if result == HTTPRequest.RESULT_SUCCESS else 0, body)
|
||||
on_complete.call(response_code if result == HTTPRequest.RESULT_SUCCESS else 0,
|
||||
response_body)
|
||||
)
|
||||
var headers: PackedStringArray = []
|
||||
if method == HTTPClient.METHOD_PUT:
|
||||
headers.append("Content-Type: application/json")
|
||||
var error: int = http_request.request(url, headers, method)
|
||||
var all_headers: PackedStringArray = headers.duplicate()
|
||||
if method == HTTPClient.METHOD_PUT || !body.is_empty():
|
||||
all_headers.append("Content-Type: application/json")
|
||||
var error: int = http_request.request(url, all_headers, method, body)
|
||||
if error != OK:
|
||||
http_request.queue_free()
|
||||
push_error("Could not send request to %s (error %d)" % [url, error])
|
||||
|
||||
+386
-24
@@ -1,25 +1,47 @@
|
||||
extends Node
|
||||
## Client for the MusicServer REST API.
|
||||
## Client for the MusicServer REST API ([code]/api/v1[/code]).
|
||||
##
|
||||
## Registered as the [code]MusicServerClient[/code] autoload, so every script can call it
|
||||
## directly, e.g. [code]MusicServerClient.fetch_song_list(on_done)[/code]. All HTTP traffic
|
||||
## goes through [member transport]; tests replace it with a fake to answer requests
|
||||
## without a running server.
|
||||
##
|
||||
## Requests need a token, which the client gets with [code]Settings.api_password[/code]
|
||||
## the first time it is needed and then reuses. When the server answers 401 (the token
|
||||
## expired, or the server restarted), it gets a new token and sends the request once
|
||||
## more. Callers never see the token.
|
||||
##
|
||||
## JSON objects are handed to [code]on_done[/code] as the classes generated from the
|
||||
## server's OpenAPI spec (see [code]server/generated/[/code]), lists of them as typed
|
||||
## Arrays. [code]on_failed[/code] is called with no arguments when a request fails,
|
||||
## including when no token could be had.
|
||||
|
||||
## Path of the API on the server, added between [code]Settings.default_path[/code] and
|
||||
## each endpoint.
|
||||
const API_PATH: String = "/api/v1"
|
||||
## Sent when asking for a token, so the server can tell its clients apart.
|
||||
const CLIENT_TYPE: String = "godot"
|
||||
|
||||
## Sends the actual HTTP requests. Swap it out in tests.
|
||||
|
||||
var transport: HttpTransport
|
||||
|
||||
var _token: String = ""
|
||||
# The server the token is for; switching server in the settings needs a new token
|
||||
var _token_server: String = ""
|
||||
# [on_token, on_failed] pairs waiting for the token request that is under way
|
||||
var _token_waiters: Array[Array] = []
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
if transport == null:
|
||||
transport = HttpTransport.new(self)
|
||||
|
||||
|
||||
## Fetches the songs already in the server playlist, as an Array of
|
||||
## [code]{SongNo, Soundtrack, Song}[/code] dictionaries.
|
||||
# Queue
|
||||
|
||||
## Fetches the songs already in the server playlist as an [code]Array[SongInfo][/code].
|
||||
func fetch_song_list(on_done: Callable, on_failed: Callable = Callable()) -> void:
|
||||
get_json("/music/list", on_done, on_failed)
|
||||
get_json_list("/music/list", SongInfo, on_done, on_failed)
|
||||
|
||||
|
||||
## Downloads a random song as MP3 bytes. With [param low_played] the server prefers
|
||||
@@ -29,14 +51,20 @@ func fetch_random_song(
|
||||
get_bytes("/music/rand/low" if low_played else "/music/rand", on_done, on_failed)
|
||||
|
||||
|
||||
## Downloads a random song as MP3 bytes, picked the way the server did before low played
|
||||
## mode existed: any soundtrack is as likely as any other.
|
||||
func fetch_random_song_classic(on_done: Callable, on_failed: Callable = Callable()) -> void:
|
||||
get_bytes("/music/rand/classic", on_done, on_failed)
|
||||
|
||||
|
||||
## Adds the most recently fetched random song to the server playlist.
|
||||
func add_to_queue(on_done: Callable, on_failed: Callable = Callable()) -> void:
|
||||
send("/music/addQue", on_done, on_failed)
|
||||
|
||||
|
||||
## Fetches [code]{SongNo, Soundtrack, Song}[/code] for the most recently queued song.
|
||||
## Fetches the [SongInfo] of the most recently queued song.
|
||||
func fetch_song_info(on_done: Callable, on_failed: Callable = Callable()) -> void:
|
||||
get_json("/music/info", on_done, on_failed)
|
||||
get_json_object("/music/info", SongInfo, on_done, on_failed)
|
||||
|
||||
|
||||
## Downloads a song from the server playlist as MP3 bytes.
|
||||
@@ -44,6 +72,16 @@ func fetch_song(song_no: int, on_done: Callable, on_failed: Callable = Callable(
|
||||
get_bytes("/music?song=" + str(song_no), on_done, on_failed)
|
||||
|
||||
|
||||
## Downloads the song after the current one in the server playlist as MP3 bytes.
|
||||
func fetch_next_song(on_done: Callable, on_failed: Callable = Callable()) -> void:
|
||||
get_bytes("/music/next", on_done, on_failed)
|
||||
|
||||
|
||||
## Downloads the song before the current one in the server playlist as MP3 bytes.
|
||||
func fetch_previous_song(on_done: Callable, on_failed: Callable = Callable()) -> void:
|
||||
get_bytes("/music/previous", on_done, on_failed)
|
||||
|
||||
|
||||
## Downloads the sound test song as MP3 bytes.
|
||||
func fetch_sound_test_song(on_done: Callable, on_failed: Callable = Callable()) -> void:
|
||||
get_bytes("/music/soundTest", on_done, on_failed)
|
||||
@@ -54,7 +92,19 @@ func reset_playlist(on_done: Callable, on_failed: Callable = Callable()) -> void
|
||||
send("/music/reset", on_done, on_failed)
|
||||
|
||||
|
||||
## Fetches every soundtrack name as an Array of Strings.
|
||||
## Records in the server statistics that a song has been played.
|
||||
func mark_song_played(song_no: int) -> void:
|
||||
send("/music/played?song=" + str(song_no), Callable(), Callable(), HTTPClient.METHOD_PUT)
|
||||
|
||||
|
||||
## Records in the server statistics that the current song has been played.
|
||||
func mark_current_song_played(on_done: Callable, on_failed: Callable = Callable()) -> void:
|
||||
send("/music/addPlayed", on_done, on_failed)
|
||||
|
||||
|
||||
# Soundtracks and songs
|
||||
|
||||
## Fetches every soundtrack name, shuffled, as an Array of Strings.
|
||||
func fetch_all_soundtracks(on_done: Callable, on_failed: Callable = Callable()) -> void:
|
||||
get_json("/music/all", on_done, on_failed)
|
||||
|
||||
@@ -64,14 +114,99 @@ func fetch_all_soundtracks_ordered(on_done: Callable, on_failed: Callable = Call
|
||||
get_json("/music/all/order", on_done, on_failed)
|
||||
|
||||
|
||||
## Records in the server statistics that a song has been played.
|
||||
func mark_song_played(song_no: int) -> void:
|
||||
send("/music/played?song=" + str(song_no), Callable(), Callable(), HTTPClient.METHOD_PUT)
|
||||
## Fetches every soundtrack as an [code]Array[Soundtrack][/code].
|
||||
func fetch_soundtracks(on_done: Callable, on_failed: Callable = Callable()) -> void:
|
||||
get_json_list("/soundtracks", Soundtrack, on_done, on_failed)
|
||||
|
||||
|
||||
## Fetches a soundtrack and its songs as a [SoundtrackWithSongList].
|
||||
func fetch_soundtrack(
|
||||
uuid: String, on_done: Callable, on_failed: Callable = Callable()) -> void:
|
||||
get_json_object("/soundtracks/" + uuid.uri_encode(), SoundtrackWithSongList, on_done,
|
||||
on_failed)
|
||||
|
||||
|
||||
## Fetches a song as a [Song].
|
||||
func fetch_song_by_uuid(
|
||||
uuid: String, on_done: Callable, on_failed: Callable = Callable()) -> void:
|
||||
get_json_object("/songs/" + uuid.uri_encode(), Song, on_done, on_failed)
|
||||
|
||||
|
||||
## Downloads a song by its UUID as MP3 bytes.
|
||||
func fetch_song_file(uuid: String, on_done: Callable, on_failed: Callable = Callable()) -> void:
|
||||
get_bytes("/songs/%s/file" % uuid.uri_encode(), on_done, on_failed)
|
||||
|
||||
|
||||
# Statistics
|
||||
|
||||
## Fetches the play statistics of the whole library as a [StatisticsSummary].
|
||||
func fetch_statistics_summary(on_done: Callable, on_failed: Callable = Callable()) -> void:
|
||||
get_json_object("/statistics/summary", StatisticsSummary, on_done, on_failed)
|
||||
|
||||
|
||||
## Fetches every soundtrack with its songs and play counts, sorted by name, as an
|
||||
## [code]Array[SoundtrackWithSongs][/code].
|
||||
func fetch_soundtrack_statistics(on_done: Callable, on_failed: Callable = Callable()) -> void:
|
||||
get_json_list("/statistics/soundtracks", SoundtrackWithSongs, on_done, on_failed)
|
||||
|
||||
|
||||
## Fetches the [param limit] most played soundtracks as an
|
||||
## [code]Array[SoundtrackWithSongs][/code]. The server allows a limit of 1 to 100.
|
||||
func fetch_most_played_soundtracks(
|
||||
limit: int, on_done: Callable, on_failed: Callable = Callable()) -> void:
|
||||
_fetch_soundtrack_statistics("most-played", limit, on_done, on_failed)
|
||||
|
||||
|
||||
## Fetches the [param limit] least played soundtracks as an
|
||||
## [code]Array[SoundtrackWithSongs][/code].
|
||||
func fetch_least_played_soundtracks(
|
||||
limit: int, on_done: Callable, on_failed: Callable = Callable()) -> void:
|
||||
_fetch_soundtrack_statistics("least-played", limit, on_done, on_failed)
|
||||
|
||||
|
||||
## Fetches the [param limit] most recently played soundtracks as an
|
||||
## [code]Array[SoundtrackWithSongs][/code].
|
||||
func fetch_last_played_soundtracks(
|
||||
limit: int, on_done: Callable, on_failed: Callable = Callable()) -> void:
|
||||
_fetch_soundtrack_statistics("last-played", limit, on_done, on_failed)
|
||||
|
||||
|
||||
## Fetches the [param limit] soundtracks that were played longest ago, leaving out those
|
||||
## never played, as an [code]Array[SoundtrackWithSongs][/code].
|
||||
func fetch_oldest_played_soundtracks(
|
||||
limit: int, on_done: Callable, on_failed: Callable = Callable()) -> void:
|
||||
_fetch_soundtrack_statistics("oldest-played", limit, on_done, on_failed)
|
||||
|
||||
|
||||
## Fetches every soundtrack that has never been played as an
|
||||
## [code]Array[SoundtrackWithSongs][/code].
|
||||
func fetch_never_played_soundtracks(
|
||||
on_done: Callable, on_failed: Callable = Callable()) -> void:
|
||||
get_json_list("/statistics/soundtracks/never-played", SoundtrackWithSongs, on_done,
|
||||
on_failed)
|
||||
|
||||
|
||||
## Fetches the [param limit] most played songs as an
|
||||
## [code]Array[SongInfoForStats][/code].
|
||||
func fetch_most_played_songs(
|
||||
limit: int, on_done: Callable, on_failed: Callable = Callable()) -> void:
|
||||
get_json_list("/statistics/songs/most-played?limit=%d" % limit, SongInfoForStats,
|
||||
on_done, on_failed)
|
||||
|
||||
|
||||
## Fetches the [param limit] least played songs as an
|
||||
## [code]Array[SongInfoForStats][/code].
|
||||
func fetch_least_played_songs(
|
||||
limit: int, on_done: Callable, on_failed: Callable = Callable()) -> void:
|
||||
get_json_list("/statistics/songs/least-played?limit=%d" % limit, SongInfoForStats,
|
||||
on_done, on_failed)
|
||||
|
||||
|
||||
# Characters
|
||||
|
||||
## Fetches the names of all character images.
|
||||
func fetch_character_list(on_done: Callable, on_failed: Callable = Callable()) -> void:
|
||||
get_json("/characters", on_done, on_failed)
|
||||
get_json("/character/list", on_done, on_failed)
|
||||
|
||||
|
||||
## Downloads a character image as PNG bytes.
|
||||
@@ -80,9 +215,17 @@ func fetch_character_image(
|
||||
get_bytes("/character?name=" + character_name.uri_encode(), on_done, on_failed)
|
||||
|
||||
|
||||
## Starts syncing the server's soundtrack library with its music folder.
|
||||
# Sync
|
||||
|
||||
## Starts syncing the server's soundtrack library with its music folder, looking only
|
||||
## at what changed. The server answers 423 if a sync is already running.
|
||||
func start_sync(on_done: Callable, on_failed: Callable = Callable()) -> void:
|
||||
send("/sync", on_done, on_failed)
|
||||
send("/sync", on_done, on_failed, HTTPClient.METHOD_POST)
|
||||
|
||||
|
||||
## Starts syncing every soundtrack again, changed or not.
|
||||
func start_full_sync(on_done: Callable, on_failed: Callable = Callable()) -> void:
|
||||
send("/sync/full", on_done, on_failed, HTTPClient.METHOD_POST)
|
||||
|
||||
|
||||
## Fetches the sync state: [code]{progress, time_spent}[/code] while running, or the
|
||||
@@ -91,17 +234,115 @@ func fetch_sync_progress(on_done: Callable, on_failed: Callable = Callable()) ->
|
||||
get_json("/sync/progress", on_done, on_failed)
|
||||
|
||||
|
||||
# Server
|
||||
|
||||
## Checks that the server is up, without a token. Calls [param on_done] with its status
|
||||
## Dictionary.
|
||||
func fetch_health(on_done: Callable, on_failed: Callable = Callable()) -> void:
|
||||
_send_once("/health", HTTPClient.METHOD_GET, "",
|
||||
_parse_json.bind("/health", on_done, on_failed), on_failed)
|
||||
|
||||
|
||||
## Fetches the server version and its changes as a [VersionData].
|
||||
func fetch_version(on_done: Callable, on_failed: Callable = Callable()) -> void:
|
||||
get_json_object("/version", VersionData, on_done, on_failed)
|
||||
|
||||
|
||||
## Fetches every server version as an [code]Array[VersionData][/code].
|
||||
func fetch_version_history(on_done: Callable, on_failed: Callable = Callable()) -> void:
|
||||
get_json_list("/version/history", VersionData, on_done, on_failed)
|
||||
|
||||
|
||||
# Game downloads
|
||||
|
||||
## Fetches the name of the newest MusicPlayer release.
|
||||
func fetch_latest_client_version(on_done: Callable, on_failed: Callable = Callable()) -> void:
|
||||
get_json("/download", on_done, on_failed)
|
||||
|
||||
|
||||
## Fetches the file names in the newest MusicPlayer release.
|
||||
func fetch_client_assets(on_done: Callable, on_failed: Callable = Callable()) -> void:
|
||||
get_json("/download/list", on_done, on_failed)
|
||||
|
||||
|
||||
## Downloads the newest MusicPlayer for Windows.
|
||||
func download_client_windows(on_done: Callable, on_failed: Callable = Callable()) -> void:
|
||||
get_bytes("/download/windows", on_done, on_failed)
|
||||
|
||||
|
||||
## Downloads the newest MusicPlayer for Linux.
|
||||
func download_client_linux(on_done: Callable, on_failed: Callable = Callable()) -> void:
|
||||
get_bytes("/download/linux", on_done, on_failed)
|
||||
|
||||
|
||||
# Tokens
|
||||
|
||||
## Tells the server to forget the token, e.g. when quitting. The next request gets a
|
||||
## new one.
|
||||
func delete_token(on_done: Callable = Callable(), on_failed: Callable = Callable()) -> void:
|
||||
if _token.is_empty():
|
||||
_call_if_valid(on_done)
|
||||
return
|
||||
var token: String = _token
|
||||
clear_token()
|
||||
_send_once("/token", HTTPClient.METHOD_DELETE, token, func(_body: PackedByteArray) -> void:
|
||||
_call_if_valid(on_done)
|
||||
, on_failed)
|
||||
|
||||
|
||||
## Asks the server to remove expired tokens of all clients.
|
||||
func cleanup_tokens(on_done: Callable, on_failed: Callable = Callable()) -> void:
|
||||
send("/token/cleanup", on_done, on_failed, HTTPClient.METHOD_POST)
|
||||
|
||||
|
||||
## Forgets the token without telling the server, so the next request gets a new one.
|
||||
func clear_token() -> void:
|
||||
_token = ""
|
||||
_token_server = ""
|
||||
|
||||
|
||||
# Generic requests
|
||||
|
||||
## Requests [param path] and calls [param on_done] with the parsed JSON.
|
||||
## [param on_failed] is called with no arguments if the request fails or the body is
|
||||
## not valid JSON.
|
||||
func get_json(path: String, on_done: Callable, on_failed: Callable = Callable()) -> void:
|
||||
_request(path, HTTPClient.METHOD_GET, func(body: PackedByteArray) -> void:
|
||||
var json: JSON = JSON.new()
|
||||
if json.parse(body.get_string_from_utf8()) != OK:
|
||||
push_warning("Invalid JSON from %s: %s" % [path, json.get_error_message()])
|
||||
_request(path, HTTPClient.METHOD_GET, _parse_json.bind(path, on_done, on_failed),
|
||||
on_failed)
|
||||
|
||||
|
||||
## Requests [param path], which answers with a JSON object, and calls [param on_done]
|
||||
## with it read into [param type], one of the generated API classes.
|
||||
func get_json_object(
|
||||
path: String, type: GDScript, on_done: Callable, on_failed: Callable = Callable(),
|
||||
) -> void:
|
||||
get_json(path, func(data: Variant) -> void:
|
||||
if !(data is Dictionary):
|
||||
push_warning("Expected a JSON object from " + path)
|
||||
_call_if_valid(on_failed)
|
||||
return
|
||||
on_done.call(json.data)
|
||||
on_done.call(type.from_dict(data))
|
||||
, on_failed)
|
||||
|
||||
|
||||
## Requests [param path], which answers with a JSON array of objects, and calls
|
||||
## [param on_done] with a typed Array of [param type], one of the generated API classes.
|
||||
## The server sends [code]null[/code] for an empty list, which gives an empty Array.
|
||||
func get_json_list(
|
||||
path: String, type: GDScript, on_done: Callable, on_failed: Callable = Callable(),
|
||||
) -> void:
|
||||
get_json(path, func(data: Variant) -> void:
|
||||
if data == null:
|
||||
data = []
|
||||
if !(data is Array):
|
||||
push_warning("Expected a JSON array from " + path)
|
||||
_call_if_valid(on_failed)
|
||||
return
|
||||
var items: Array = Array([], TYPE_OBJECT, "RefCounted", type)
|
||||
for item: Variant in data:
|
||||
if item is Dictionary:
|
||||
items.append(type.from_dict(item))
|
||||
on_done.call(items)
|
||||
, on_failed)
|
||||
|
||||
|
||||
@@ -124,15 +365,136 @@ func send(
|
||||
, on_failed)
|
||||
|
||||
|
||||
func _request(path: String, method: int, on_success: Callable, on_failed: Callable) -> void:
|
||||
var url: String = Settings.default_path + path
|
||||
transport.request(url, method, func(response_code: int, body: PackedByteArray) -> void:
|
||||
func _fetch_soundtrack_statistics(
|
||||
list_name: String, limit: int, on_done: Callable, on_failed: Callable) -> void:
|
||||
get_json_list("/statistics/soundtracks/%s?limit=%d" % [list_name, limit],
|
||||
SoundtrackWithSongs, on_done, on_failed)
|
||||
|
||||
|
||||
func _parse_json(
|
||||
body: PackedByteArray, path: String, on_done: Callable, on_failed: Callable) -> void:
|
||||
var json: JSON = JSON.new()
|
||||
if json.parse(body.get_string_from_utf8()) != OK:
|
||||
push_warning("Invalid JSON from %s: %s" % [path, json.get_error_message()])
|
||||
_call_if_valid(on_failed)
|
||||
return
|
||||
on_done.call(json.data)
|
||||
|
||||
|
||||
# Sends the request with the token, and once more with a new token if the server
|
||||
# answers 401
|
||||
func _request(
|
||||
path: String,
|
||||
method: int,
|
||||
on_success: Callable,
|
||||
on_failed: Callable,
|
||||
may_retry: bool = true,
|
||||
) -> void:
|
||||
_with_token(func(token: String) -> void:
|
||||
_send(path, method, token, func(response_code: int, body: PackedByteArray) -> void:
|
||||
if response_code == 401 && may_retry:
|
||||
GameLog.debug("Token refused by the server, getting a new one")
|
||||
if _token == token:
|
||||
clear_token()
|
||||
_request(path, method, on_success, on_failed, false)
|
||||
return
|
||||
_handle_response(path, response_code, body, on_success, on_failed)
|
||||
)
|
||||
, on_failed)
|
||||
|
||||
|
||||
# Sends the request once, with [param token] if it is not empty
|
||||
func _send_once(
|
||||
path: String, method: int, token: String, on_success: Callable, on_failed: Callable,
|
||||
) -> void:
|
||||
_send(path, method, token, func(response_code: int, body: PackedByteArray) -> void:
|
||||
_handle_response(path, response_code, body, on_success, on_failed)
|
||||
)
|
||||
|
||||
|
||||
func _send(
|
||||
path: String, method: int, token: String, on_complete: Callable, body: String = "",
|
||||
) -> void:
|
||||
var headers: PackedStringArray = []
|
||||
if !token.is_empty():
|
||||
headers.append("Authorization: Bearer " + token)
|
||||
transport.request(Settings.default_path + API_PATH + path, method, on_complete, headers,
|
||||
body)
|
||||
|
||||
|
||||
func _handle_response(
|
||||
path: String,
|
||||
response_code: int,
|
||||
body: PackedByteArray,
|
||||
on_success: Callable,
|
||||
on_failed: Callable,
|
||||
) -> void:
|
||||
if response_code != 200:
|
||||
push_warning("Request to %s failed with response code %d" % [url, response_code])
|
||||
push_warning("Request to %s failed with response code %d%s"
|
||||
% [path, response_code, _error_message(body)])
|
||||
_call_if_valid(on_failed)
|
||||
return
|
||||
on_success.call(body)
|
||||
)
|
||||
|
||||
|
||||
# Calls [param on_token] with the token, asking the server for one first if needed.
|
||||
# Requests made while asking wait for the same token instead of asking again.
|
||||
func _with_token(on_token: Callable, on_failed: Callable) -> void:
|
||||
if _token_server != Settings.default_path:
|
||||
clear_token()
|
||||
if !_token.is_empty():
|
||||
on_token.call(_token)
|
||||
return
|
||||
_token_waiters.append([on_token, on_failed])
|
||||
if _token_waiters.size() == 1:
|
||||
_request_token()
|
||||
|
||||
|
||||
func _request_token() -> void:
|
||||
var token_request: TokenRequest = TokenRequest.new()
|
||||
token_request.password = Settings.api_password
|
||||
token_request.client_type = CLIENT_TYPE
|
||||
var server: String = Settings.default_path
|
||||
_send("/token", HTTPClient.METHOD_POST, "",
|
||||
func(response_code: int, body: PackedByteArray) -> void:
|
||||
var waiters: Array[Array] = _token_waiters.duplicate()
|
||||
_token_waiters.clear()
|
||||
var token: String = _read_token(response_code, body)
|
||||
if token.is_empty():
|
||||
for waiter: Array in waiters:
|
||||
_call_if_valid(waiter[1])
|
||||
return
|
||||
_token = token
|
||||
_token_server = server
|
||||
for waiter: Array in waiters:
|
||||
waiter[0].call(token)
|
||||
, JSON.stringify(token_request.to_dict()))
|
||||
|
||||
|
||||
func _read_token(response_code: int, body: PackedByteArray) -> String:
|
||||
if response_code != 200:
|
||||
push_warning("Could not get an API token, response code %d%s"
|
||||
% [response_code, _error_message(body)])
|
||||
return ""
|
||||
var data: Variant = _parse_or_null(body)
|
||||
var token: String = TokenResponse.from_dict(data).token if data is Dictionary else ""
|
||||
if token.is_empty():
|
||||
push_warning("The server answered without an API token")
|
||||
return token
|
||||
|
||||
|
||||
# The server's {"error": "..."} message, for logging
|
||||
static func _error_message(body: PackedByteArray) -> String:
|
||||
var data: Variant = _parse_or_null(body)
|
||||
if data is Dictionary && data.has("error"):
|
||||
return ": " + ApiError.from_dict(data).error
|
||||
return ""
|
||||
|
||||
|
||||
# Error pages are not always JSON, so failing to parse is not worth a warning here
|
||||
static func _parse_or_null(body: PackedByteArray) -> Variant:
|
||||
var json: JSON = JSON.new()
|
||||
return json.data if json.parse(body.get_string_from_utf8()) == OK else null
|
||||
|
||||
|
||||
static func _call_if_valid(callback: Callable) -> void:
|
||||
|
||||
+3335
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,57 @@
|
||||
[gd_scene format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://statistics/soundtrack_card.gd" id="card_script"]
|
||||
|
||||
[node name="SoundtrackCard" type="PanelContainer" unique_id=1211000001]
|
||||
custom_minimum_size = Vector2(290, 340)
|
||||
mouse_default_cursor_shape = 2
|
||||
theme_type_variation = &"StatisticsCard"
|
||||
script = ExtResource("card_script")
|
||||
|
||||
[node name="VBox" type="VBoxContainer" parent="." unique_id=1211000002]
|
||||
layout_mode = 2
|
||||
mouse_filter = 2
|
||||
|
||||
[node name="NameLabel" type="Label" parent="VBox" unique_id=1211000003]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 62)
|
||||
layout_mode = 2
|
||||
mouse_filter = 2
|
||||
theme_type_variation = &"TextLarge"
|
||||
autowrap_mode = 3
|
||||
text_overrun_behavior = 3
|
||||
max_lines_visible = 2
|
||||
|
||||
[node name="PlayedLabel" type="Label" parent="VBox" unique_id=1211000004]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
mouse_filter = 2
|
||||
|
||||
[node name="LastPlayedLabel" type="Label" parent="VBox" unique_id=1211000005]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
mouse_filter = 2
|
||||
|
||||
[node name="MostPlayedHeader" type="Label" parent="VBox" unique_id=1211000006]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
mouse_filter = 2
|
||||
theme_type_variation = &"HighlightMedium"
|
||||
text = "Most played"
|
||||
|
||||
[node name="MostPlayedList" type="VBoxContainer" parent="VBox" unique_id=1211000007]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
mouse_filter = 2
|
||||
|
||||
[node name="LeastPlayedHeader" type="Label" parent="VBox" unique_id=1211000008]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
mouse_filter = 2
|
||||
theme_type_variation = &"HighlightMedium"
|
||||
text = "Least played"
|
||||
|
||||
[node name="LeastPlayedList" type="VBoxContainer" parent="VBox" unique_id=1211000009]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
mouse_filter = 2
|
||||
@@ -0,0 +1,132 @@
|
||||
[gd_scene format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://statistics/statistics_view.gd" id="view_script"]
|
||||
|
||||
[node name="StatisticsView" type="Panel" unique_id=1212000001]
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
offset_top = 56.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
theme_type_variation = &"WindowBackground"
|
||||
script = ExtResource("view_script")
|
||||
|
||||
[node name="Toolbar" type="HBoxContainer" parent="." unique_id=1212000002]
|
||||
layout_mode = 1
|
||||
anchors_preset = 10
|
||||
anchor_right = 1.0
|
||||
offset_left = 32.0
|
||||
offset_top = 16.0
|
||||
offset_right = -32.0
|
||||
offset_bottom = 56.0
|
||||
grow_horizontal = 2
|
||||
theme_override_constants/separation = 12
|
||||
|
||||
[node name="SearchField" type="LineEdit" parent="Toolbar" unique_id=1212000003]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(480, 0)
|
||||
layout_mode = 2
|
||||
placeholder_text = "Search soundtracks"
|
||||
clear_button_enabled = true
|
||||
|
||||
[node name="ClearButton" type="Button" parent="Toolbar" unique_id=1212000004]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
text = "Clear"
|
||||
|
||||
[node name="CountLabel" type="Label" parent="Toolbar" unique_id=1212000005]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="ShowLabel" type="Label" parent="Toolbar" unique_id=1212000006]
|
||||
layout_mode = 2
|
||||
text = "Show"
|
||||
|
||||
[node name="ShowOption" type="OptionButton" parent="Toolbar" unique_id=1212000007]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(160, 0)
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
selected = 0
|
||||
item_count = 3
|
||||
popup/item_0/text = "All"
|
||||
popup/item_0/id = 0
|
||||
popup/item_1/text = "Played"
|
||||
popup/item_1/id = 1
|
||||
popup/item_2/text = "Never played"
|
||||
popup/item_2/id = 2
|
||||
|
||||
[node name="SortLabel" type="Label" parent="Toolbar" unique_id=1212000008]
|
||||
layout_mode = 2
|
||||
text = "Sort"
|
||||
|
||||
[node name="SortOption" type="OptionButton" parent="Toolbar" unique_id=1212000009]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(160, 0)
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
selected = 0
|
||||
item_count = 4
|
||||
popup/item_0/text = "A-Z"
|
||||
popup/item_0/id = 0
|
||||
popup/item_1/text = "Z-A"
|
||||
popup/item_1/id = 1
|
||||
popup/item_2/text = "Most played"
|
||||
popup/item_2/id = 2
|
||||
popup/item_3/text = "Least played"
|
||||
popup/item_3/id = 3
|
||||
|
||||
[node name="ScrollContainer" type="ScrollContainer" parent="." unique_id=1212000010]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
offset_left = 32.0
|
||||
offset_top = 72.0
|
||||
offset_right = -32.0
|
||||
offset_bottom = -16.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
horizontal_scroll_mode = 0
|
||||
|
||||
[node name="CardGrid" type="GridContainer" parent="ScrollContainer" unique_id=1212000011]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 0
|
||||
theme_type_variation = &"StatisticsGrid"
|
||||
columns = 6
|
||||
|
||||
[node name="StatusLabel" type="Label" parent="." unique_id=1212000012]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 1
|
||||
anchors_preset = 14
|
||||
anchor_top = 0.5
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 0.5
|
||||
offset_top = -40.0
|
||||
offset_bottom = -4.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
theme_type_variation = &"Title"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="RetryButton" type="Button" parent="." unique_id=1212000013]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 1
|
||||
anchors_preset = 8
|
||||
anchor_left = 0.5
|
||||
anchor_top = 0.5
|
||||
anchor_right = 0.5
|
||||
anchor_bottom = 0.5
|
||||
offset_left = -50.0
|
||||
offset_top = 8.0
|
||||
offset_right = 50.0
|
||||
offset_bottom = 48.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
focus_mode = 0
|
||||
text = "Retry"
|
||||
@@ -0,0 +1,104 @@
|
||||
class_name SoundtrackCard
|
||||
extends PanelContainer
|
||||
## One soundtrack in the statistics view: its name, how often and when it was last
|
||||
## played, and its most and least played songs. Clicking it asks for all its songs.
|
||||
|
||||
## Emitted when the card is clicked.
|
||||
signal pressed(soundtrack: SoundtrackWithSongs)
|
||||
|
||||
## How many of the most and least played songs the card lists.
|
||||
const SONGS_SHOWN: int = 3
|
||||
|
||||
## The soundtrack the card shows.
|
||||
var soundtrack: SoundtrackWithSongs
|
||||
|
||||
@onready var name_label: Label = %NameLabel
|
||||
@onready var played_label: Label = %PlayedLabel
|
||||
@onready var last_played_label: Label = %LastPlayedLabel
|
||||
@onready var most_played_header: Label = %MostPlayedHeader
|
||||
@onready var most_played_list: VBoxContainer = %MostPlayedList
|
||||
@onready var least_played_header: Label = %LeastPlayedHeader
|
||||
@onready var least_played_list: VBoxContainer = %LeastPlayedList
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
mouse_entered.connect(_set_hovered.bind(true))
|
||||
mouse_exited.connect(_set_hovered.bind(false))
|
||||
gui_input.connect(_on_gui_input)
|
||||
|
||||
|
||||
## Shows [param data] on the card.
|
||||
func show_soundtrack(data: SoundtrackWithSongs) -> void:
|
||||
soundtrack = data
|
||||
name_label.text = data.soundtrack_name
|
||||
tooltip_text = data.soundtrack_name
|
||||
played_label.text = played_text(data.soundtrack_played)
|
||||
last_played_label.text = last_played_text(data.soundtrack_last_played)
|
||||
last_played_label.visible = !data.soundtrack_last_played.is_empty()
|
||||
|
||||
var most_played: Array[SongInfoForStats] = songs_by_plays(data.songs).slice(0, SONGS_SHOWN)
|
||||
# Without the songs already shown as most played
|
||||
var least_played: Array[SongInfoForStats] = songs_by_plays(data.songs, true).filter(
|
||||
func(song: SongInfoForStats) -> bool: return !most_played.has(song)
|
||||
).slice(0, SONGS_SHOWN)
|
||||
|
||||
most_played_header.text = "Most played" if !data.songs.is_empty() else "No songs"
|
||||
_fill_song_list(most_played_list, most_played)
|
||||
least_played_header.visible = !least_played.is_empty()
|
||||
_fill_song_list(least_played_list, least_played)
|
||||
|
||||
|
||||
## "Played 3 times", or "Never played" for [code]0[/code].
|
||||
static func played_text(times_played: int) -> String:
|
||||
if times_played == 0:
|
||||
return "Never played"
|
||||
return "Played %d %s" % [times_played, "time" if times_played == 1 else "times"]
|
||||
|
||||
|
||||
## "Last played 2026-10-01" from the server's date and time, or an empty String when
|
||||
## there is none.
|
||||
static func last_played_text(last_played: String) -> String:
|
||||
if last_played.is_empty():
|
||||
return ""
|
||||
return "Last played " + last_played.substr(0, 10)
|
||||
|
||||
|
||||
## One line of a song list: how often the song was played, then its name.
|
||||
static func song_text(song: SongInfoForStats) -> String:
|
||||
return "%d × %s" % [song.times_played, song.song_name]
|
||||
|
||||
|
||||
## [param songs] sorted with the most played first, or the least played first with
|
||||
## [param least_first], and by name when played as often.
|
||||
static func songs_by_plays(
|
||||
songs: Array[SongInfoForStats], least_first: bool = false) -> Array[SongInfoForStats]:
|
||||
var sorted: Array[SongInfoForStats] = songs.duplicate()
|
||||
sorted.sort_custom(func(a: SongInfoForStats, b: SongInfoForStats) -> bool:
|
||||
if a.times_played != b.times_played:
|
||||
return (a.times_played < b.times_played) == least_first
|
||||
return a.song_name.naturalnocasecmp_to(b.song_name) < 0
|
||||
)
|
||||
return sorted
|
||||
|
||||
|
||||
func _fill_song_list(list: VBoxContainer, songs: Array[SongInfoForStats]) -> void:
|
||||
while list.get_child_count() < songs.size():
|
||||
var label: Label = Label.new()
|
||||
label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
|
||||
list.add_child(label)
|
||||
for i: int in list.get_child_count():
|
||||
var label: Label = list.get_child(i)
|
||||
label.visible = i < songs.size()
|
||||
if label.visible:
|
||||
label.text = song_text(songs[i])
|
||||
|
||||
|
||||
func _set_hovered(hovered: bool) -> void:
|
||||
theme_type_variation = &"StatisticsCardHover" if hovered else &"StatisticsCard"
|
||||
|
||||
|
||||
func _on_gui_input(event: InputEvent) -> void:
|
||||
if event is InputEventMouseButton && event.pressed \
|
||||
&& event.button_index == MOUSE_BUTTON_LEFT:
|
||||
accept_event()
|
||||
pressed.emit(soundtrack)
|
||||
@@ -0,0 +1 @@
|
||||
uid://beeibc7xpftq1
|
||||
@@ -0,0 +1,139 @@
|
||||
class_name StatisticsView
|
||||
extends Panel
|
||||
## Shows every soundtrack on the server as a card with its play statistics. The cards
|
||||
## can be searched, filtered on whether they have been played, and sorted.
|
||||
##
|
||||
## Shown instead of the quiz when the Statistics tab is chosen.
|
||||
|
||||
## Emitted when a card is clicked, to show all songs of [param soundtrack].
|
||||
signal soundtrack_selected(soundtrack: SoundtrackWithSongs)
|
||||
|
||||
## The orders the cards can be sorted in, in the order of the sort menu.
|
||||
enum SortOrder { A_TO_Z, Z_TO_A, MOST_PLAYED, LEAST_PLAYED }
|
||||
|
||||
## Which soundtracks are shown, in the order of the show menu.
|
||||
enum ShowFilter { ALL, PLAYED, NEVER_PLAYED }
|
||||
|
||||
const CARD_SCENE: PackedScene = preload("res://statistics/SoundtrackCard.tscn")
|
||||
|
||||
## The cards in the order they are shown, hidden ones included.
|
||||
var cards: Array[SoundtrackCard] = []
|
||||
|
||||
var _search: SoundtrackSearch = SoundtrackSearch.new()
|
||||
var _loading: bool = false
|
||||
|
||||
@onready var search_field: LineEdit = %SearchField
|
||||
@onready var clear_button: Button = %ClearButton
|
||||
@onready var show_option: OptionButton = %ShowOption
|
||||
@onready var sort_option: OptionButton = %SortOption
|
||||
@onready var count_label: Label = %CountLabel
|
||||
@onready var status_label: Label = %StatusLabel
|
||||
@onready var retry_button: Button = %RetryButton
|
||||
@onready var card_grid: GridContainer = %CardGrid
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
search_field.text_changed.connect(func(_text: String) -> void: apply_filter())
|
||||
clear_button.pressed.connect(clear_search)
|
||||
show_option.item_selected.connect(func(_index: int) -> void: apply_filter())
|
||||
sort_option.item_selected.connect(func(_index: int) -> void: apply_sort())
|
||||
retry_button.pressed.connect(refresh)
|
||||
retry_button.visible = false
|
||||
|
||||
|
||||
## Loads the statistics from the server. The cards already shown stay until the new
|
||||
## ones arrive.
|
||||
func refresh() -> void:
|
||||
if _loading:
|
||||
return
|
||||
_loading = true
|
||||
retry_button.visible = false
|
||||
if cards.is_empty():
|
||||
status_label.text = "Loading statistics…"
|
||||
MusicServerClient.fetch_soundtrack_statistics(_on_loaded, _on_load_failed)
|
||||
|
||||
|
||||
## Shows every card again, keeping the show filter and the sort order.
|
||||
func clear_search() -> void:
|
||||
search_field.text = ""
|
||||
apply_filter()
|
||||
|
||||
|
||||
## Puts the cards in the order chosen in the sort menu.
|
||||
func apply_sort() -> void:
|
||||
var order: SortOrder = sort_option.selected as SortOrder
|
||||
cards.sort_custom(func(a: SoundtrackCard, b: SoundtrackCard) -> bool:
|
||||
return _comes_before(a.soundtrack, b.soundtrack, order))
|
||||
for i: int in cards.size():
|
||||
card_grid.move_child(cards[i], i)
|
||||
|
||||
|
||||
## Shows only the cards that match the search text and the show menu.
|
||||
func apply_filter() -> void:
|
||||
var search_text: String = search_field.text.strip_edges()
|
||||
var matching: Dictionary = {}
|
||||
for soundtrack_name: String in _search.find_matches(search_text) \
|
||||
if !search_text.is_empty() else []:
|
||||
matching[soundtrack_name] = true
|
||||
var filter: ShowFilter = show_option.selected as ShowFilter
|
||||
var shown: int = 0
|
||||
for card: SoundtrackCard in cards:
|
||||
card.visible = (search_text.is_empty() || matching.has(card.soundtrack.soundtrack_name)) \
|
||||
&& _passes_filter(card.soundtrack, filter)
|
||||
if card.visible:
|
||||
shown += 1
|
||||
if shown == cards.size():
|
||||
count_label.text = "%d soundtracks" % cards.size()
|
||||
else:
|
||||
count_label.text = "%d of %d soundtracks" % [shown, cards.size()]
|
||||
|
||||
|
||||
func _on_loaded(soundtracks: Array[SoundtrackWithSongs]) -> void:
|
||||
_loading = false
|
||||
status_label.text = ""
|
||||
# Cards are reused, so opening the tab again only updates their text
|
||||
while cards.size() < soundtracks.size():
|
||||
var card: SoundtrackCard = CARD_SCENE.instantiate()
|
||||
card_grid.add_child(card)
|
||||
card.pressed.connect(soundtrack_selected.emit)
|
||||
cards.append(card)
|
||||
while cards.size() > soundtracks.size():
|
||||
cards.pop_back().free()
|
||||
var names: Array[String] = []
|
||||
for i: int in soundtracks.size():
|
||||
cards[i].show_soundtrack(soundtracks[i])
|
||||
names.append(soundtracks[i].soundtrack_name)
|
||||
_search.set_names(names)
|
||||
apply_sort()
|
||||
apply_filter()
|
||||
|
||||
|
||||
func _on_load_failed() -> void:
|
||||
_loading = false
|
||||
if cards.is_empty():
|
||||
status_label.text = "Could not load statistics"
|
||||
retry_button.visible = true
|
||||
|
||||
|
||||
static func _passes_filter(soundtrack: SoundtrackWithSongs, filter: ShowFilter) -> bool:
|
||||
match filter:
|
||||
ShowFilter.PLAYED:
|
||||
return soundtrack.soundtrack_played > 0
|
||||
ShowFilter.NEVER_PLAYED:
|
||||
return soundtrack.soundtrack_played == 0
|
||||
return true
|
||||
|
||||
|
||||
static func _comes_before(
|
||||
a: SoundtrackWithSongs, b: SoundtrackWithSongs, order: SortOrder) -> bool:
|
||||
var by_name: int = a.soundtrack_name.naturalnocasecmp_to(b.soundtrack_name)
|
||||
match order:
|
||||
SortOrder.Z_TO_A:
|
||||
return by_name > 0
|
||||
SortOrder.MOST_PLAYED:
|
||||
if a.soundtrack_played != b.soundtrack_played:
|
||||
return a.soundtrack_played > b.soundtrack_played
|
||||
SortOrder.LEAST_PLAYED:
|
||||
if a.soundtrack_played != b.soundtrack_played:
|
||||
return a.soundtrack_played < b.soundtrack_played
|
||||
return by_name < 0
|
||||
@@ -0,0 +1 @@
|
||||
uid://bdh0vdom0jenq
|
||||
+24115
-2147
File diff suppressed because it is too large.
Load diff
@@ -5,16 +5,33 @@ extends HttpTransport
|
||||
## Register canned responses per path with [method respond_json] or
|
||||
## [method respond_bytes], then install it with [method install]. Requests to paths
|
||||
## without a response get a 404. Responses are delivered immediately.
|
||||
##
|
||||
## Paths are relative to the API, e.g. [code]/music/list[/code] for
|
||||
## [code]<Settings.default_path>/api/v1/music/list[/code].
|
||||
##
|
||||
## Like the real server it hands out tokens on [code]POST /token[/code] and answers 401
|
||||
## to requests without a valid one. Token requests are counted in
|
||||
## [member token_requests], not in [member requested_paths].
|
||||
|
||||
## Paths requested so far, relative to [code]Settings.default_path[/code].
|
||||
## Paths that need no token, like on the real server.
|
||||
const OPEN_PATHS: Array[String] = ["/token", "/health"]
|
||||
|
||||
## Paths requested so far, relative to the API.
|
||||
var requested_paths: Array[String] = []
|
||||
## HTTP method of each request, in the same order as [member requested_paths].
|
||||
var requested_methods: Array[int] = []
|
||||
## Headers of each request, in the same order as [member requested_paths].
|
||||
var requested_headers: Array[PackedStringArray] = []
|
||||
## How many tokens have been asked for.
|
||||
var token_requests: int = 0
|
||||
## The JSON body of the last token request.
|
||||
var last_token_request: Dictionary = {}
|
||||
|
||||
var _responses: Dictionary = {}
|
||||
var _successes_left: Dictionary = {}
|
||||
var _held_paths: Dictionary = {}
|
||||
var _previous_transport: HttpTransport
|
||||
var _valid_tokens: Array[String] = []
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
@@ -25,12 +42,14 @@ func _init() -> void:
|
||||
func install() -> FakeHttpTransport:
|
||||
_previous_transport = MusicServerClient.transport
|
||||
MusicServerClient.transport = self
|
||||
MusicServerClient.clear_token()
|
||||
return self
|
||||
|
||||
|
||||
## Restores the transport that was active before [method install].
|
||||
func uninstall() -> void:
|
||||
MusicServerClient.transport = _previous_transport
|
||||
MusicServerClient.clear_token()
|
||||
|
||||
|
||||
## Answers requests to [param path] with [param data] encoded as JSON.
|
||||
@@ -69,10 +88,32 @@ func count(path: String) -> int:
|
||||
return requested_paths.count(path)
|
||||
|
||||
|
||||
func request(url: String, method: int, on_complete: Callable) -> void:
|
||||
var path: String = url.trim_prefix(Settings.default_path)
|
||||
## Makes every token handed out so far invalid, like after 24 hours or a server restart.
|
||||
func expire_tokens() -> void:
|
||||
_valid_tokens.clear()
|
||||
|
||||
|
||||
func request(
|
||||
url: String,
|
||||
method: int,
|
||||
on_complete: Callable,
|
||||
headers: PackedStringArray = PackedStringArray(),
|
||||
body: String = "",
|
||||
) -> void:
|
||||
var path: String = url.trim_prefix(Settings.default_path + MusicServerClient.API_PATH)
|
||||
if path == "/token" && method == HTTPClient.METHOD_POST:
|
||||
token_requests += 1
|
||||
if _held_paths.has(path):
|
||||
_held_paths[path].append(_answer_token_request.bind(body, on_complete))
|
||||
return
|
||||
_answer_token_request(body, on_complete)
|
||||
return
|
||||
requested_paths.append(path)
|
||||
requested_methods.append(method)
|
||||
requested_headers.append(headers)
|
||||
if !OPEN_PATHS.has(path.get_slice("?", 0)) && !_has_valid_token(headers):
|
||||
on_complete.call(401, JSON.stringify({"error": "Unauthorized"}).to_utf8_buffer())
|
||||
return
|
||||
if _held_paths.has(path):
|
||||
_held_paths[path].append(_answer.bind(path, on_complete))
|
||||
return
|
||||
@@ -89,3 +130,28 @@ func _answer(path: String, on_complete: Callable) -> void:
|
||||
on_complete.call(_responses[path].code, _responses[path].body)
|
||||
else:
|
||||
on_complete.call(404, PackedByteArray())
|
||||
|
||||
|
||||
func _answer_token_request(body: String, on_complete: Callable) -> void:
|
||||
var data: Variant = JSON.parse_string(body)
|
||||
last_token_request = data if data is Dictionary else {}
|
||||
if _responses.has("/token"):
|
||||
# A token from a registered response is valid too
|
||||
var registered: Dictionary = _responses["/token"]
|
||||
var response: Variant = JSON.parse_string(registered.body.get_string_from_utf8())
|
||||
if registered.code == 200 && response is Dictionary:
|
||||
_valid_tokens.append(str(response.get("token", "")))
|
||||
_answer("/token", on_complete)
|
||||
return
|
||||
var token: String = "fake-token-%d" % token_requests
|
||||
_valid_tokens.append(token)
|
||||
on_complete.call(200, JSON.stringify({
|
||||
"token": token, "expires_at": "2026-10-07T12:00:00Z", "client_type": "godot",
|
||||
}).to_utf8_buffer())
|
||||
|
||||
|
||||
func _has_valid_token(headers: PackedStringArray) -> bool:
|
||||
for header: String in headers:
|
||||
if header.begins_with("Authorization: Bearer "):
|
||||
return _valid_tokens.has(header.trim_prefix("Authorization: Bearer "))
|
||||
return false
|
||||
@@ -30,7 +30,7 @@ func before_test() -> void:
|
||||
_server.respond_json("/music/all", ["Chrono Trigger", "Final Fantasy VI"])
|
||||
_server.respond_json("/music/all/order", ["Chrono Trigger", "Final Fantasy VI"])
|
||||
_server.respond_json("/music/list", [])
|
||||
_server.respond_json("/characters", [])
|
||||
_server.respond_json("/character/list", [])
|
||||
_server.respond_bytes("/music/rand", mp3)
|
||||
_server.respond_bytes("/music/addQue", PackedByteArray())
|
||||
_server.respond_json("/music/info", SONG_INFO[0])
|
||||
|
||||
+57
-5
@@ -1,7 +1,8 @@
|
||||
class_name UiTour
|
||||
extends RefCounted
|
||||
## Walks the main window through a fixed set of states against a fake MusicServer:
|
||||
## startup, players added, a song playing, every popup open and a sync.
|
||||
## startup, players added, a song playing, every popup open, a sync and the statistics
|
||||
## tab.
|
||||
##
|
||||
## Used for screenshots ([code]tools/take_screenshots.gd[/code]) and for the layout
|
||||
## test, so both look at exactly the same states. Nodes are looked up by name in the
|
||||
@@ -38,7 +39,6 @@ const POPUPS: Array[Array] = [
|
||||
["SettingsWindow", "SettingsPopup"], ["QRWindow", "QRPopup"],
|
||||
["NewCharacterSelect", "CharacterSelectPopup"], ["ShortcutsWindow", "ShortcutsPopup"],
|
||||
["AboutWindow", "AboutPopup"], ["WinnerWindow", "WinnerPopup"], ["Log", "LogPopup"],
|
||||
["StatisticsPopupPanel", "StatisticsPopupPanel"],
|
||||
]
|
||||
|
||||
var _main: Control
|
||||
@@ -57,7 +57,7 @@ static func make_server() -> FakeHttpTransport:
|
||||
server.respond_json("/music/all/order", SOUNDTRACKS)
|
||||
server.respond_json("/music/list", [])
|
||||
var characters: Array[String] = character_names()
|
||||
server.respond_json("/characters", characters)
|
||||
server.respond_json("/character/list", characters)
|
||||
for character_name: String in characters:
|
||||
server.respond_bytes("/character?name=" + character_name.uri_encode(),
|
||||
FileAccess.get_file_as_bytes(CHARACTERS_DIR.path_join(character_name)))
|
||||
@@ -68,9 +68,37 @@ static func make_server() -> FakeHttpTransport:
|
||||
server.respond_bytes("/music/played?song=0", PackedByteArray())
|
||||
server.respond_bytes("/sync", PackedByteArray())
|
||||
server.respond_json("/sync/progress", SYNC_PROGRESS)
|
||||
server.respond_json("/statistics/soundtracks", soundtrack_statistics())
|
||||
return server
|
||||
|
||||
|
||||
## Play statistics for every soundtrack in [constant SOUNDTRACKS], the same on every
|
||||
## run. Some have never been played.
|
||||
static func soundtrack_statistics() -> Array[Dictionary]:
|
||||
var statistics: Array[Dictionary] = []
|
||||
for i: int in SOUNDTRACKS.size():
|
||||
var played: int = (i * 7) % 11
|
||||
var songs: Array[Dictionary] = []
|
||||
for song: int in 5:
|
||||
songs.append({
|
||||
"song_uuid": "song-%d-%d" % [i, song],
|
||||
"soundtrack_uuid": "soundtrack-%d" % i,
|
||||
"soundtrack_name": SOUNDTRACKS[i],
|
||||
"song_name": "Track %d" % (song + 1),
|
||||
"times_played": maxi(0, played - song * 2),
|
||||
})
|
||||
var soundtrack: Dictionary = {
|
||||
"soundtrack_uuid": "soundtrack-%d" % i,
|
||||
"soundtrack_name": SOUNDTRACKS[i],
|
||||
"soundtrack_played": played,
|
||||
"songs": songs,
|
||||
}
|
||||
if played > 0:
|
||||
soundtrack.soundtrack_last_played = "2026-09-%02dT20:00:00Z" % (i + 1)
|
||||
statistics.append(soundtrack)
|
||||
return statistics
|
||||
|
||||
|
||||
## The pictures in [constant CHARACTERS_DIR], sorted by name.
|
||||
static func character_names() -> Array[String]:
|
||||
var names: Array[String] = []
|
||||
@@ -162,6 +190,32 @@ func run(on_state: Callable) -> void:
|
||||
sync_popup.close()
|
||||
await _settle()
|
||||
|
||||
var statistics_view: StatisticsView = _node("StatisticsView")
|
||||
_main.show_tab(&"statistics")
|
||||
await _settle()
|
||||
await on_state.call("statistics", statistics_view)
|
||||
statistics_view.show_option.select(StatisticsView.ShowFilter.NEVER_PLAYED)
|
||||
statistics_view.apply_filter()
|
||||
await _settle()
|
||||
await on_state.call("statistics_never_played", statistics_view)
|
||||
statistics_view.show_option.select(StatisticsView.ShowFilter.ALL)
|
||||
statistics_view.sort_option.select(StatisticsView.SortOrder.MOST_PLAYED)
|
||||
statistics_view.apply_sort()
|
||||
statistics_view.search_field.text = "chrono"
|
||||
statistics_view.apply_filter()
|
||||
await _settle()
|
||||
await on_state.call("statistics_search", statistics_view)
|
||||
var stats_popup: SoundtrackStatsPopup = _node("SoundtrackStatsPopup")
|
||||
for card: SoundtrackCard in statistics_view.cards:
|
||||
if card.visible:
|
||||
stats_popup.show_soundtrack(card.soundtrack)
|
||||
break
|
||||
await _settle()
|
||||
await on_state.call("popup_SoundtrackStats", stats_popup)
|
||||
stats_popup.close()
|
||||
_main.show_tab(&"quiz")
|
||||
await _settle()
|
||||
|
||||
|
||||
## Returns where every Control and Window in [param subtree] is on screen, keyed by its
|
||||
## path from [param main]: [code]{"rect": [x, y, width, height], "visible": bool}[/code].
|
||||
@@ -270,8 +324,6 @@ func _open_popup(popup_name: String) -> Node:
|
||||
popup.show_grid("Crono")
|
||||
"WinnerPopup":
|
||||
popup.show_winner(Settings.player_array[0])
|
||||
"StatisticsPopupPanel":
|
||||
_call("get_statistics")
|
||||
_:
|
||||
popup.open()
|
||||
return popup
|
||||
|
||||
@@ -6,7 +6,7 @@ extends MainWindowTestSuite
|
||||
|
||||
const MAIN_WINDOW_CHILDREN: Array[String] = [
|
||||
"Background", "TopBar", "Quiz", "PopupBackdrop", "SearchPopup", "SyncPopup", "QRPopup",
|
||||
"ShortcutsPopup", "StatisticsPopupPanel", "AboutPopup", "LogPopup",
|
||||
"ShortcutsPopup", "StatisticsView", "AboutPopup", "LogPopup", "SoundtrackStatsPopup",
|
||||
]
|
||||
|
||||
## The parts of the quiz and the scene each one comes from.
|
||||
@@ -27,15 +27,14 @@ const LOCKED_BY_SYNC: Array[String] = [
|
||||
]
|
||||
|
||||
const TOP_BAR_CHILDREN: Array[String] = [
|
||||
"TopPanelContainer", "Title", "SyncButton", "SearchButton", "StatisticsButton",
|
||||
"ShortcutsButton", "AboutButton", "QRButton",
|
||||
"TopPanelContainer", "Title", "SyncButton", "SearchButton", "ShortcutsButton",
|
||||
"AboutButton", "QRButton", "QuizTab", "StatisticsTab",
|
||||
]
|
||||
|
||||
## Each top bar button and the popup it opens in the main window.
|
||||
const TOP_BAR_POPUPS: Dictionary = {
|
||||
"SearchButton": "SearchPopup",
|
||||
"SyncButton": "SyncPopup",
|
||||
"StatisticsButton": "StatisticsPopupPanel",
|
||||
"ShortcutsButton": "ShortcutsPopup",
|
||||
"AboutButton": "AboutPopup",
|
||||
"QRButton": "QRPopup",
|
||||
@@ -285,10 +284,3 @@ func test_alt_enter_switches_fullscreen_and_settings_shows_it() -> void:
|
||||
assert_bool(settings.fullscreen_button.button_pressed).is_equal(!was_fullscreen)
|
||||
await _press_keys([KEY_ALT, KEY_ENTER])
|
||||
assert_bool(Settings.fullscreen).is_equal(was_fullscreen)
|
||||
|
||||
|
||||
func test_statistics_counts_the_soundtracks() -> void:
|
||||
_top_bar_button("StatisticsButton").pressed.emit()
|
||||
|
||||
assert_str((_window.get_node("StatisticsPopupPanel/StatisticsLabel") as Label).text) \
|
||||
.is_equal("Total amount of soundtracks in the playlist: 2")
|
||||
@@ -0,0 +1,372 @@
|
||||
extends MainWindowTestSuite
|
||||
## The Statistics tab: switching between it and the quiz, the soundtrack cards, sorting,
|
||||
## searching, the show filter and the popup with all songs of a soundtrack.
|
||||
|
||||
const STATISTICS: Array[Dictionary] = [
|
||||
{"soundtrack_uuid": "st-1", "soundtrack_name": "Chrono Trigger", "soundtrack_played": 5,
|
||||
"soundtrack_last_played": "2026-09-30T20:00:00+02:00", "songs": [
|
||||
{"song_name": "Corridors of Time", "times_played": 1},
|
||||
{"song_name": "Frog's Theme", "times_played": 3},
|
||||
{"song_name": "Wind Scene", "times_played": 0},
|
||||
{"song_name": "Battle with Magus", "times_played": 2},
|
||||
{"song_name": "To Far Away Times", "times_played": 0},
|
||||
{"song_name": "Schala's Theme", "times_played": 4},
|
||||
{"song_name": "Peaceful Days", "times_played": 1},
|
||||
]},
|
||||
{"soundtrack_uuid": "st-2", "soundtrack_name": "Terranigma", "soundtrack_played": 0,
|
||||
"songs": [{"song_name": "Underworld", "times_played": 0}]},
|
||||
{"soundtrack_uuid": "st-3", "soundtrack_name": "earthbound", "soundtrack_played": 9,
|
||||
"soundtrack_last_played": "2026-10-01T10:00:00Z", "songs": []},
|
||||
{"soundtrack_uuid": "st-4", "soundtrack_name": "Final Fantasy VI", "soundtrack_played": 1,
|
||||
"soundtrack_last_played": "2026-08-01T10:00:00Z", "songs": [
|
||||
{"song_name": "Dancing Mad", "times_played": 1},
|
||||
]},
|
||||
]
|
||||
|
||||
var _view: StatisticsView
|
||||
|
||||
|
||||
func _before_window_starts() -> void:
|
||||
_server.respond_json("/statistics/soundtracks", STATISTICS)
|
||||
|
||||
|
||||
func before_test() -> void:
|
||||
super()
|
||||
_view = _window.get_node("StatisticsView")
|
||||
|
||||
|
||||
func _open_statistics() -> void:
|
||||
await _click_at(_center(_window.get_node("TopBar/StatisticsTab")))
|
||||
await _runner.simulate_frames(2)
|
||||
|
||||
|
||||
func _center(control: Control) -> Vector2:
|
||||
return control.get_global_rect().get_center()
|
||||
|
||||
|
||||
func _shown_names() -> Array[String]:
|
||||
var names: Array[String] = []
|
||||
for card: SoundtrackCard in _view.cards:
|
||||
if card.visible:
|
||||
names.append(card.soundtrack.soundtrack_name)
|
||||
return names
|
||||
|
||||
|
||||
func _card(soundtrack_name: String) -> SoundtrackCard:
|
||||
for card: SoundtrackCard in _view.cards:
|
||||
if card.soundtrack.soundtrack_name == soundtrack_name:
|
||||
return card
|
||||
return null
|
||||
|
||||
|
||||
func _visible_texts(list: VBoxContainer) -> Array[String]:
|
||||
var texts: Array[String] = []
|
||||
for label: Label in list.get_children():
|
||||
if label.visible:
|
||||
texts.append(label.text)
|
||||
return texts
|
||||
|
||||
|
||||
# Tabs
|
||||
|
||||
func test_quiz_tab_is_chosen_at_startup() -> void:
|
||||
assert_bool(_view.visible).is_false()
|
||||
assert_bool((_window.get_node("TopBar/QuizTab") as Button).button_pressed).is_true()
|
||||
assert_int(_server.count("/statistics/soundtracks")).is_equal(0)
|
||||
|
||||
|
||||
func test_clicking_statistics_tab_shows_statistics_instead_of_the_quiz() -> void:
|
||||
await _open_statistics()
|
||||
|
||||
assert_bool(_view.visible).is_true()
|
||||
assert_bool(_quiz.visible).is_false()
|
||||
assert_bool((_window.get_node("TopBar/StatisticsTab") as Button).button_pressed).is_true()
|
||||
assert_bool((_window.get_node("TopBar/QuizTab") as Button).button_pressed).is_false()
|
||||
assert_vector(_view.get_global_rect().position).is_equal(Vector2(0, 56))
|
||||
assert_vector(_view.get_global_rect().size).is_equal(Vector2(1920, 1024))
|
||||
|
||||
|
||||
func test_clicking_quiz_tab_goes_back_to_the_quiz() -> void:
|
||||
await _open_statistics()
|
||||
|
||||
await _click_at(_center(_window.get_node("TopBar/QuizTab")))
|
||||
|
||||
assert_bool(_view.visible).is_false()
|
||||
assert_bool(_quiz.visible).is_true()
|
||||
assert_bool((_window.get_node("TopBar/QuizTab") as Button).button_pressed).is_true()
|
||||
|
||||
|
||||
func test_clicks_on_statistics_do_not_reach_the_quiz_behind() -> void:
|
||||
await _open_statistics()
|
||||
var next_button: Button = _quiz.get_node("SongControls/NextButton")
|
||||
|
||||
await _click_at(_center(next_button))
|
||||
|
||||
assert_int(Playlist.currently_playing_song).is_equal(-1)
|
||||
|
||||
|
||||
func test_song_keeps_playing_behind_statistics() -> void:
|
||||
await _play_first_song()
|
||||
|
||||
await _open_statistics()
|
||||
|
||||
assert_int(Playlist.currently_playing_song).is_equal(0)
|
||||
assert_bool((_quiz.get_node("MusicPlayer") as MusicPlayer).audio_player.stream_paused) \
|
||||
.is_false()
|
||||
|
||||
|
||||
func test_statistics_are_loaded_again_each_time_the_tab_opens() -> void:
|
||||
await _open_statistics()
|
||||
await _click_at(_center(_window.get_node("TopBar/QuizTab")))
|
||||
await _open_statistics()
|
||||
|
||||
assert_int(_server.count("/statistics/soundtracks")).is_equal(2)
|
||||
assert_int(_view.cards.size()).is_equal(STATISTICS.size())
|
||||
|
||||
|
||||
func test_statistics_are_loaded_again_after_a_sync() -> void:
|
||||
await _open_statistics()
|
||||
|
||||
_window.get_node("SyncPopup").sync_finished.emit()
|
||||
|
||||
assert_int(_server.count("/statistics/soundtracks")).is_equal(2)
|
||||
|
||||
|
||||
# Cards
|
||||
|
||||
func test_one_card_per_soundtrack_sorted_a_to_z() -> void:
|
||||
await _open_statistics()
|
||||
|
||||
assert_array(_shown_names()).contains_exactly(
|
||||
["Chrono Trigger", "earthbound", "Final Fantasy VI", "Terranigma"])
|
||||
assert_str(_view.count_label.text).is_equal("4 soundtracks")
|
||||
|
||||
|
||||
func test_card_shows_plays_last_played_and_most_and_least_played_songs() -> void:
|
||||
await _open_statistics()
|
||||
var card: SoundtrackCard = _card("Chrono Trigger")
|
||||
|
||||
assert_str(card.name_label.text).is_equal("Chrono Trigger")
|
||||
assert_str(card.played_label.text).is_equal("Played 5 times")
|
||||
assert_str(card.last_played_label.text).is_equal("Last played 2026-09-30")
|
||||
assert_array(_visible_texts(card.most_played_list)).contains_exactly(
|
||||
["4 × Schala's Theme", "3 × Frog's Theme", "2 × Battle with Magus"])
|
||||
assert_array(_visible_texts(card.least_played_list)).contains_exactly(
|
||||
["0 × To Far Away Times", "0 × Wind Scene", "1 × Corridors of Time"])
|
||||
|
||||
|
||||
func test_card_of_never_played_soundtrack() -> void:
|
||||
await _open_statistics()
|
||||
var card: SoundtrackCard = _card("Terranigma")
|
||||
|
||||
assert_str(card.played_label.text).is_equal("Never played")
|
||||
assert_bool(card.last_played_label.visible).is_false()
|
||||
assert_array(_visible_texts(card.most_played_list)).contains_exactly(["0 × Underworld"])
|
||||
assert_bool(card.least_played_header.visible).is_false()
|
||||
|
||||
|
||||
func test_card_without_songs() -> void:
|
||||
await _open_statistics()
|
||||
var card: SoundtrackCard = _card("earthbound")
|
||||
|
||||
assert_str(card.most_played_header.text).is_equal("No songs")
|
||||
assert_array(_visible_texts(card.most_played_list)).is_empty()
|
||||
assert_bool(card.least_played_header.visible).is_false()
|
||||
|
||||
|
||||
# Sorting, searching and filtering
|
||||
|
||||
func test_sort_orders() -> void:
|
||||
await _open_statistics()
|
||||
var expected: Dictionary = {
|
||||
StatisticsView.SortOrder.Z_TO_A:
|
||||
["Terranigma", "Final Fantasy VI", "earthbound", "Chrono Trigger"],
|
||||
StatisticsView.SortOrder.MOST_PLAYED:
|
||||
["earthbound", "Chrono Trigger", "Final Fantasy VI", "Terranigma"],
|
||||
StatisticsView.SortOrder.LEAST_PLAYED:
|
||||
["Terranigma", "Final Fantasy VI", "Chrono Trigger", "earthbound"],
|
||||
StatisticsView.SortOrder.A_TO_Z:
|
||||
["Chrono Trigger", "earthbound", "Final Fantasy VI", "Terranigma"],
|
||||
}
|
||||
for order: StatisticsView.SortOrder in expected:
|
||||
_view.sort_option.select(order)
|
||||
_view.sort_option.item_selected.emit(order)
|
||||
|
||||
assert_array(_shown_names()).contains_exactly(expected[order])
|
||||
# The grid shows the cards in the same order
|
||||
for i: int in _view.cards.size():
|
||||
assert_int(_view.cards[i].get_index()).is_equal(i)
|
||||
|
||||
|
||||
func test_sort_by_plays_breaks_ties_by_name() -> void:
|
||||
var tied: Array[Dictionary] = [
|
||||
{"soundtrack_name": "Lufia II", "soundtrack_played": 2},
|
||||
{"soundtrack_name": "Earthbound", "soundtrack_played": 2},
|
||||
]
|
||||
_server.respond_json("/statistics/soundtracks", tied)
|
||||
await _open_statistics()
|
||||
|
||||
_view.sort_option.select(StatisticsView.SortOrder.MOST_PLAYED)
|
||||
_view.apply_sort()
|
||||
|
||||
assert_array(_shown_names()).contains_exactly(["Earthbound", "Lufia II"])
|
||||
|
||||
|
||||
func test_typing_in_search_filters_cards_and_keeps_the_sort() -> void:
|
||||
await _open_statistics()
|
||||
_view.sort_option.select(StatisticsView.SortOrder.Z_TO_A)
|
||||
_view.apply_sort()
|
||||
|
||||
# What typing does; the test runner's key presses type every letter twice
|
||||
_view.search_field.text = "tri"
|
||||
_view.search_field.text_changed.emit("tri")
|
||||
|
||||
assert_str(_view.search_field.text).is_equal("tri")
|
||||
# "Chrono Trigger" contains it, "Terranigma" has the letters in order
|
||||
assert_array(_shown_names()).contains_exactly(["Terranigma", "Chrono Trigger"])
|
||||
assert_str(_view.count_label.text).is_equal("2 of 4 soundtracks")
|
||||
|
||||
|
||||
func test_search_uses_the_search_popup_matching() -> void:
|
||||
await _open_statistics()
|
||||
|
||||
_view.search_field.text = "finalfantasy"
|
||||
_view.apply_filter()
|
||||
|
||||
assert_array(_shown_names()).contains_exactly(["Final Fantasy VI"])
|
||||
|
||||
|
||||
func test_clear_button_shows_all_cards() -> void:
|
||||
await _open_statistics()
|
||||
_view.search_field.text = "chrono"
|
||||
_view.apply_filter()
|
||||
|
||||
await _click_at(_center(_view.clear_button))
|
||||
|
||||
assert_str(_view.search_field.text).is_empty()
|
||||
assert_int(_shown_names().size()).is_equal(4)
|
||||
|
||||
|
||||
func test_show_played_and_never_played() -> void:
|
||||
await _open_statistics()
|
||||
|
||||
_view.show_option.select(StatisticsView.ShowFilter.PLAYED)
|
||||
_view.show_option.item_selected.emit(StatisticsView.ShowFilter.PLAYED)
|
||||
assert_array(_shown_names()).contains_exactly(
|
||||
["Chrono Trigger", "earthbound", "Final Fantasy VI"])
|
||||
assert_str(_view.count_label.text).is_equal("3 of 4 soundtracks")
|
||||
|
||||
_view.show_option.select(StatisticsView.ShowFilter.NEVER_PLAYED)
|
||||
_view.show_option.item_selected.emit(StatisticsView.ShowFilter.NEVER_PLAYED)
|
||||
assert_array(_shown_names()).contains_exactly(["Terranigma"])
|
||||
assert_str(_view.count_label.text).is_equal("1 of 4 soundtracks")
|
||||
|
||||
_view.show_option.select(StatisticsView.ShowFilter.ALL)
|
||||
_view.show_option.item_selected.emit(StatisticsView.ShowFilter.ALL)
|
||||
assert_int(_shown_names().size()).is_equal(4)
|
||||
assert_str(_view.count_label.text).is_equal("4 soundtracks")
|
||||
|
||||
|
||||
func test_show_filter_and_search_together() -> void:
|
||||
await _open_statistics()
|
||||
_view.show_option.select(StatisticsView.ShowFilter.PLAYED)
|
||||
_view.search_field.text = "t"
|
||||
_view.apply_filter()
|
||||
|
||||
# Terranigma matches the search but has never been played
|
||||
assert_array(_shown_names()).contains_exactly(
|
||||
["Chrono Trigger", "earthbound", "Final Fantasy VI"])
|
||||
|
||||
|
||||
func test_filters_stay_when_statistics_are_loaded_again() -> void:
|
||||
await _open_statistics()
|
||||
_view.show_option.select(StatisticsView.ShowFilter.NEVER_PLAYED)
|
||||
_view.sort_option.select(StatisticsView.SortOrder.Z_TO_A)
|
||||
|
||||
_view.refresh()
|
||||
|
||||
assert_array(_shown_names()).contains_exactly(["Terranigma"])
|
||||
assert_str(_view.cards[0].soundtrack.soundtrack_name).is_equal("Terranigma")
|
||||
|
||||
|
||||
# Popup
|
||||
|
||||
func test_clicking_a_card_shows_all_its_songs_most_played_first() -> void:
|
||||
await _open_statistics()
|
||||
var popup: SoundtrackStatsPopup = _window.get_node("SoundtrackStatsPopup")
|
||||
|
||||
await _click_at(_center(_card("Chrono Trigger")))
|
||||
|
||||
assert_bool(popup.visible).is_true()
|
||||
assert_str(popup.title_label.text).is_equal("Chrono Trigger")
|
||||
assert_str(popup.summary_label.text) \
|
||||
.is_equal("Played 5 times · Last played 2026-09-30 · 7 songs")
|
||||
var rows: Array[String] = []
|
||||
for row: ListRow in popup.song_list.get_children():
|
||||
rows.append(row.text)
|
||||
assert_array(rows).contains_exactly([
|
||||
"4 × Schala's Theme", "3 × Frog's Theme", "2 × Battle with Magus",
|
||||
"1 × Corridors of Time", "1 × Peaceful Days", "0 × To Far Away Times", "0 × Wind Scene",
|
||||
])
|
||||
|
||||
|
||||
func test_clicking_outside_the_popup_closes_it() -> void:
|
||||
await _open_statistics()
|
||||
var popup: SoundtrackStatsPopup = _window.get_node("SoundtrackStatsPopup")
|
||||
await _click_at(_center(_card("Chrono Trigger")))
|
||||
await _runner.simulate_frames(1)
|
||||
|
||||
await _click_at(Vector2(100, 1000))
|
||||
|
||||
assert_bool(popup.visible).is_false()
|
||||
assert_bool(_view.visible).is_true()
|
||||
|
||||
|
||||
# Loading
|
||||
|
||||
func test_failed_load_shows_error_and_retry_loads() -> void:
|
||||
_server.respond_json("/statistics/soundtracks", {"error": "Internal Server Error"}, 500)
|
||||
await _open_statistics()
|
||||
|
||||
assert_str(_view.status_label.text).is_equal("Could not load statistics")
|
||||
assert_bool(_view.retry_button.visible).is_true()
|
||||
assert_array(_view.cards).is_empty()
|
||||
|
||||
_server.respond_json("/statistics/soundtracks", STATISTICS)
|
||||
await _click_at(_center(_view.retry_button))
|
||||
|
||||
assert_str(_view.status_label.text).is_empty()
|
||||
assert_bool(_view.retry_button.visible).is_false()
|
||||
assert_int(_view.cards.size()).is_equal(4)
|
||||
|
||||
|
||||
func test_shows_loading_until_the_server_answers() -> void:
|
||||
_server.hold("/statistics/soundtracks")
|
||||
await _open_statistics()
|
||||
|
||||
assert_str(_view.status_label.text).is_equal("Loading statistics…")
|
||||
|
||||
_server.release("/statistics/soundtracks")
|
||||
|
||||
assert_str(_view.status_label.text).is_empty()
|
||||
assert_int(_view.cards.size()).is_equal(4)
|
||||
|
||||
|
||||
func test_failed_reload_keeps_the_cards() -> void:
|
||||
await _open_statistics()
|
||||
_server.respond_json("/statistics/soundtracks", [], 500)
|
||||
|
||||
_view.refresh()
|
||||
|
||||
assert_int(_view.cards.size()).is_equal(4)
|
||||
assert_bool(_view.retry_button.visible).is_false()
|
||||
|
||||
|
||||
func test_fewer_soundtracks_after_reload_removes_cards() -> void:
|
||||
await _open_statistics()
|
||||
_server.respond_json("/statistics/soundtracks", STATISTICS.slice(0, 2))
|
||||
|
||||
_view.refresh()
|
||||
|
||||
assert_int(_view.cards.size()).is_equal(2)
|
||||
assert_int(_view.card_grid.get_child_count()).is_equal(2)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bmq2a4by6wjpq
|
||||
@@ -7,7 +7,7 @@ const THEME_PATH: String = "res://themes/main_theme.tres"
|
||||
## Folders with the game's scenes and scripts.
|
||||
const GAME_FOLDERS: Array[String] = [
|
||||
"res://main", "res://quiz", "res://player", "res://popups", "res://common", "res://data",
|
||||
"res://server",
|
||||
"res://server", "res://statistics",
|
||||
]
|
||||
|
||||
## The named styles the game uses, and the type each one is based on.
|
||||
@@ -30,6 +30,10 @@ const STYLES: Dictionary = {
|
||||
"HighlightMedium": "Label",
|
||||
"SongRowLabel": "Label",
|
||||
"SongRowLabelPlaying": "Label",
|
||||
"StatisticsCard": "PanelContainer",
|
||||
"StatisticsCardHover": "PanelContainer",
|
||||
"StatisticsGrid": "GridContainer",
|
||||
"TabButton": "Button",
|
||||
}
|
||||
|
||||
var _theme: Theme = load(THEME_PATH)
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
extends GdUnitTestSuite
|
||||
## The classes generated from the MusicServer OpenAPI spec read the server's JSON.
|
||||
##
|
||||
## Each sample is a response the way the server sends it, parsed from text like the
|
||||
## client does, so numbers arrive as floats.
|
||||
|
||||
|
||||
func _parse(json: String) -> Dictionary:
|
||||
return JSON.parse_string(json)
|
||||
|
||||
|
||||
func test_song_info() -> void:
|
||||
var info: SongInfo = SongInfo.from_dict(_parse("""{
|
||||
"SoundtrackUuid": "st-1", "Soundtrack": "Chrono Trigger", "SoundtrackPlayed": 4,
|
||||
"SongUuid": "so-1", "Song": "Corridors of Time", "SongPlayed": 2,
|
||||
"CurrentlyPlaying": true, "SongNo": 3}"""))
|
||||
|
||||
assert_str(info.soundtrack_uuid).is_equal("st-1")
|
||||
assert_str(info.soundtrack).is_equal("Chrono Trigger")
|
||||
assert_int(info.soundtrack_played).is_equal(4)
|
||||
assert_str(info.song_uuid).is_equal("so-1")
|
||||
assert_str(info.song).is_equal("Corridors of Time")
|
||||
assert_int(info.song_played).is_equal(2)
|
||||
assert_bool(info.currently_playing).is_true()
|
||||
assert_int(info.song_no).is_equal(3)
|
||||
|
||||
|
||||
func test_song() -> void:
|
||||
var song: Song = Song.from_dict(_parse("""{"uuid": "so-1", "name": "Dancing Mad",
|
||||
"times_played": 7, "soundtrack_uuid": "st-2", "soundtrack_name": "Final Fantasy VI",
|
||||
"file_name": "dancing_mad.mp3"}"""))
|
||||
|
||||
assert_str(song.uuid).is_equal("so-1")
|
||||
assert_str(song.name).is_equal("Dancing Mad")
|
||||
assert_int(song.times_played).is_equal(7)
|
||||
assert_str(song.soundtrack_uuid).is_equal("st-2")
|
||||
assert_str(song.soundtrack_name).is_equal("Final Fantasy VI")
|
||||
assert_str(song.file_name).is_equal("dancing_mad.mp3")
|
||||
|
||||
|
||||
func test_soundtrack() -> void:
|
||||
var soundtrack: Soundtrack = Soundtrack.from_dict(_parse("""{"uuid": "st-1",
|
||||
"name": "Terranigma", "times_played": 3, "added": "2026-01-02T10:00:00Z",
|
||||
"last_played": "2026-10-01T20:00:00Z"}"""))
|
||||
|
||||
assert_str(soundtrack.uuid).is_equal("st-1")
|
||||
assert_str(soundtrack.name).is_equal("Terranigma")
|
||||
assert_int(soundtrack.times_played).is_equal(3)
|
||||
assert_str(soundtrack.added).is_equal("2026-01-02T10:00:00Z")
|
||||
assert_str(soundtrack.last_played).is_equal("2026-10-01T20:00:00Z")
|
||||
|
||||
|
||||
func test_soundtrack_with_song_list() -> void:
|
||||
var soundtrack: SoundtrackWithSongList = SoundtrackWithSongList.from_dict(_parse("""{
|
||||
"uuid": "st-1", "name": "Lufia II", "times_played": 1, "added": "2026-01-02T10:00:00Z",
|
||||
"songs": [{"uuid": "so-1", "name": "Battle #1", "times_played": 1,
|
||||
"soundtrack_uuid": "st-1", "soundtrack_name": "Lufia II"}]}"""))
|
||||
|
||||
assert_str(soundtrack.name).is_equal("Lufia II")
|
||||
assert_str(soundtrack.last_played).is_empty()
|
||||
assert_int(soundtrack.songs.size()).is_equal(1)
|
||||
assert_str(soundtrack.songs[0].name).is_equal("Battle #1")
|
||||
|
||||
|
||||
func test_soundtrack_with_songs() -> void:
|
||||
var soundtrack: SoundtrackWithSongs = SoundtrackWithSongs.from_dict(_parse("""{
|
||||
"soundtrack_id": 5, "soundtrack_uuid": "st-1", "soundtrack_name": "Earthbound",
|
||||
"soundtrack_played": 6, "soundtrack_last_played": "2026-10-01T20:00:00Z",
|
||||
"songs": [{"soundtrack_id": 5, "soundtrack_uuid": "st-1", "song_uuid": "so-1",
|
||||
"soundtrack_name": "Earthbound", "song_name": "Onett", "path": "/music/onett.mp3",
|
||||
"times_played": 2, "file_name": "onett.mp3"}]}"""))
|
||||
|
||||
assert_str(soundtrack.soundtrack_uuid).is_equal("st-1")
|
||||
assert_str(soundtrack.soundtrack_name).is_equal("Earthbound")
|
||||
assert_int(soundtrack.soundtrack_played).is_equal(6)
|
||||
assert_str(soundtrack.soundtrack_last_played).is_equal("2026-10-01T20:00:00Z")
|
||||
assert_str(soundtrack.songs[0].song_name).is_equal("Onett")
|
||||
|
||||
|
||||
func test_soundtrack_with_songs_tolerates_null_songs() -> void:
|
||||
var soundtrack: SoundtrackWithSongs = SoundtrackWithSongs.from_dict(_parse("""{
|
||||
"soundtrack_uuid": "st-1", "songs": null}"""))
|
||||
|
||||
assert_array(soundtrack.songs).is_empty()
|
||||
|
||||
|
||||
func test_song_info_for_stats() -> void:
|
||||
var song: SongInfoForStats = SongInfoForStats.from_dict(_parse("""{"soundtrack_id": 5,
|
||||
"soundtrack_uuid": "st-1", "song_uuid": "so-1", "soundtrack_name": "Super Metroid",
|
||||
"song_name": "Brinstar", "path": "/music/brinstar.mp3", "times_played": 9,
|
||||
"file_name": "brinstar.mp3"}"""))
|
||||
|
||||
assert_str(song.song_uuid).is_equal("so-1")
|
||||
assert_str(song.soundtrack_name).is_equal("Super Metroid")
|
||||
assert_str(song.song_name).is_equal("Brinstar")
|
||||
assert_str(song.path).is_equal("/music/brinstar.mp3")
|
||||
assert_int(song.times_played).is_equal(9)
|
||||
assert_str(song.file_name).is_equal("brinstar.mp3")
|
||||
|
||||
|
||||
func test_statistics_summary() -> void:
|
||||
var summary: StatisticsSummary = StatisticsSummary.from_dict(_parse("""{
|
||||
"total_soundtracks": 120, "played_soundtracks": 100, "never_played_soundtracks": 20,
|
||||
"total_soundtrack_plays": 250, "avg_soundtrack_plays": 2.5,
|
||||
"max_soundtrack_plays": 12, "min_soundtrack_plays": 1}"""))
|
||||
|
||||
assert_int(summary.total_soundtracks).is_equal(120)
|
||||
assert_int(summary.played_soundtracks).is_equal(100)
|
||||
assert_int(summary.never_played_soundtracks).is_equal(20)
|
||||
assert_int(summary.total_soundtrack_plays).is_equal(250)
|
||||
assert_float(summary.avg_soundtrack_plays).is_equal(2.5)
|
||||
assert_int(summary.max_soundtrack_plays).is_equal(12)
|
||||
assert_int(summary.min_soundtrack_plays).is_equal(1)
|
||||
|
||||
|
||||
func test_sync_response() -> void:
|
||||
var result: SyncResponse = SyncResponse.from_dict(_parse("""{
|
||||
"soundtracks_added": ["Terranigma"], "soundtracks_re_added": [],
|
||||
"soundtracks_changed_title": {"FF6": "Final Fantasy VI"},
|
||||
"soundtracks_changed_content": ["Chrono Trigger"], "soundtracks_removed": ["Old"],
|
||||
"catched_errors": ["Could not read x.mp3"], "total_time": "00:00:03"}"""))
|
||||
|
||||
assert_array(result.soundtracks_added).contains_exactly(["Terranigma"])
|
||||
assert_array(result.soundtracks_re_added).is_empty()
|
||||
assert_dict(result.soundtracks_changed_title).is_equal({"FF6": "Final Fantasy VI"})
|
||||
assert_array(result.soundtracks_changed_content).contains_exactly(["Chrono Trigger"])
|
||||
assert_array(result.soundtracks_removed).contains_exactly(["Old"])
|
||||
assert_array(result.catched_errors).contains_exactly(["Could not read x.mp3"])
|
||||
assert_str(result.total_time).is_equal("00:00:03")
|
||||
|
||||
|
||||
func test_progress_response() -> void:
|
||||
var progress: ProgressResponse = ProgressResponse.from_dict(_parse("""{
|
||||
"progress": "40", "time_spent": "00:00:02"}"""))
|
||||
|
||||
assert_str(progress.progress).is_equal("40")
|
||||
assert_str(progress.time_spent).is_equal("00:00:02")
|
||||
|
||||
|
||||
func test_version_data() -> void:
|
||||
var version: VersionData = VersionData.from_dict(_parse("""{"version": "5.0.0",
|
||||
"changelog": ["Initial release", "Bug fixes"]}"""))
|
||||
|
||||
assert_str(version.version).is_equal("5.0.0")
|
||||
assert_array(version.changelog).contains_exactly(["Initial release", "Bug fixes"])
|
||||
|
||||
|
||||
func test_token_response() -> void:
|
||||
var response: TokenResponse = TokenResponse.from_dict(_parse("""{"token": "abc",
|
||||
"expires_at": "2026-10-07T12:00:00Z", "client_type": "godot"}"""))
|
||||
|
||||
assert_str(response.token).is_equal("abc")
|
||||
assert_str(response.expires_at).is_equal("2026-10-07T12:00:00Z")
|
||||
assert_str(response.client_type).is_equal("godot")
|
||||
|
||||
|
||||
func test_token_request_defaults_to_web_client() -> void:
|
||||
var request: TokenRequest = TokenRequest.from_dict({})
|
||||
|
||||
assert_str(request.client_type).is_equal("web")
|
||||
assert_str(request.password).is_empty()
|
||||
|
||||
|
||||
func test_status_message() -> void:
|
||||
assert_str(StatusMessage.from_dict(_parse("""{"status": "ok"}""")).status).is_equal("ok")
|
||||
|
||||
|
||||
func test_api_error() -> void:
|
||||
assert_str(ApiError.from_dict(_parse("""{"error": "Not Found"}""")).error) \
|
||||
.is_equal("Not Found")
|
||||
|
||||
|
||||
func test_missing_and_wrong_fields_give_defaults() -> void:
|
||||
var info: SongInfo = SongInfo.from_dict({"SongNo": "three", "Song": null})
|
||||
|
||||
assert_int(info.song_no).is_equal(0)
|
||||
assert_str(info.song).is_empty()
|
||||
assert_bool(info.currently_playing).is_false()
|
||||
|
||||
|
||||
func test_to_dict_gives_back_the_json() -> void:
|
||||
var json: Dictionary = {
|
||||
"soundtrack_id": 5, "soundtrack_uuid": "st-1", "soundtrack_name": "Earthbound",
|
||||
"soundtrack_played": 6, "soundtrack_last_played": "2026-10-01T20:00:00Z",
|
||||
"songs": [{"soundtrack_id": 5, "soundtrack_uuid": "st-1", "song_uuid": "so-1",
|
||||
"soundtrack_name": "Earthbound", "song_name": "Onett", "path": "/onett.mp3",
|
||||
"times_played": 2, "file_name": "onett.mp3"}],
|
||||
}
|
||||
|
||||
assert_dict(SoundtrackWithSongs.from_dict(json).to_dict()).is_equal(json)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bbuyu23x8rxbv
|
||||
@@ -1,5 +1,6 @@
|
||||
extends GdUnitTestSuite
|
||||
## MusicServerClient: which paths it asks for and how it handles the answers.
|
||||
## MusicServerClient: which paths it asks for, how it handles the answers and how it
|
||||
## gets and renews its API token.
|
||||
|
||||
var _server: FakeHttpTransport
|
||||
|
||||
@@ -12,16 +13,50 @@ func after_test() -> void:
|
||||
_server.uninstall()
|
||||
|
||||
|
||||
func test_fetch_song_list_passes_parsed_json() -> void:
|
||||
func test_fetch_song_list_passes_song_infos() -> void:
|
||||
var songs: Array = [{"SongNo": 0, "Soundtrack": "Chrono Trigger", "Song": "Corridors of Time"}]
|
||||
_server.respond_json("/music/list", songs)
|
||||
var received: Array = []
|
||||
var received: Array[SongInfo] = []
|
||||
|
||||
MusicServerClient.fetch_song_list(func(data: Array) -> void: received.append_array(data))
|
||||
MusicServerClient.fetch_song_list(
|
||||
func(data: Array[SongInfo]) -> void: received.append_array(data))
|
||||
|
||||
assert_int(received.size()).is_equal(1)
|
||||
assert_int(int(received[0].SongNo)).is_equal(0)
|
||||
assert_str(received[0].Soundtrack).is_equal("Chrono Trigger")
|
||||
assert_int(received[0].song_no).is_equal(0)
|
||||
assert_str(received[0].soundtrack).is_equal("Chrono Trigger")
|
||||
assert_str(received[0].song).is_equal("Corridors of Time")
|
||||
|
||||
|
||||
func test_fetch_song_info_passes_song_info() -> void:
|
||||
_server.respond_json("/music/info", {"SongNo": 7, "Soundtrack": "Terranigma",
|
||||
"Song": "Underworld", "SoundtrackUuid": "st-1", "SongUuid": "so-1"})
|
||||
var received: Array[SongInfo] = []
|
||||
|
||||
MusicServerClient.fetch_song_info(func(info: SongInfo) -> void: received.append(info))
|
||||
|
||||
assert_int(received[0].song_no).is_equal(7)
|
||||
assert_str(received[0].song_uuid).is_equal("so-1")
|
||||
|
||||
|
||||
func test_empty_song_list_sent_as_null_gives_empty_array() -> void:
|
||||
_server.respond_bytes("/music/list", "null".to_utf8_buffer())
|
||||
var received: Array = []
|
||||
|
||||
MusicServerClient.fetch_song_list(func(data: Array[SongInfo]) -> void: received.append(data))
|
||||
|
||||
assert_int(received.size()).is_equal(1)
|
||||
assert_array(received[0]).is_empty()
|
||||
|
||||
|
||||
func test_json_list_that_is_not_an_array_calls_on_failed() -> void:
|
||||
_server.respond_json("/music/list", {"error": "not a list"})
|
||||
var outcome: Array[String] = []
|
||||
|
||||
MusicServerClient.fetch_song_list(
|
||||
func(_data: Array[SongInfo]) -> void: outcome.append("done"),
|
||||
func() -> void: outcome.append("failed"))
|
||||
|
||||
assert_array(outcome).contains_exactly(["failed"])
|
||||
|
||||
|
||||
func test_fetch_random_song_uses_normal_endpoint() -> void:
|
||||
@@ -59,7 +94,7 @@ func test_invalid_json_does_not_call_back() -> void:
|
||||
_server.respond_bytes("/music/info", "not json".to_utf8_buffer())
|
||||
var called: Array[bool] = [false]
|
||||
|
||||
MusicServerClient.fetch_song_info(func(_data: Variant) -> void: called[0] = true)
|
||||
MusicServerClient.fetch_song_info(func(_data: SongInfo) -> void: called[0] = true)
|
||||
|
||||
assert_bool(called[0]).is_false()
|
||||
|
||||
@@ -80,7 +115,7 @@ func test_invalid_json_calls_on_failed() -> void:
|
||||
var outcome: Array[String] = []
|
||||
|
||||
MusicServerClient.fetch_song_info(
|
||||
func(_data: Variant) -> void: outcome.append("done"),
|
||||
func(_data: SongInfo) -> void: outcome.append("done"),
|
||||
func() -> void: outcome.append("failed"))
|
||||
|
||||
assert_array(outcome).contains_exactly(["failed"])
|
||||
@@ -122,17 +157,389 @@ func test_character_name_is_url_encoded() -> void:
|
||||
assert_array(_server.requested_paths).contains_exactly(["/character?name=Paper%20Mario.png"])
|
||||
|
||||
|
||||
func test_requests_go_to_selected_server() -> void:
|
||||
func test_start_sync_sends_post() -> void:
|
||||
_server.respond_json("/sync", "Sync started")
|
||||
var called: Array[bool] = [false]
|
||||
|
||||
MusicServerClient.start_sync(func() -> void: called[0] = true)
|
||||
|
||||
assert_array(_server.requested_paths).contains_exactly(["/sync"])
|
||||
assert_array(_server.requested_methods).contains_exactly([HTTPClient.METHOD_POST])
|
||||
assert_bool(called[0]).is_true()
|
||||
|
||||
|
||||
func test_start_full_sync_sends_post() -> void:
|
||||
MusicServerClient.start_full_sync(func() -> void: pass)
|
||||
|
||||
assert_array(_server.requested_paths).contains_exactly(["/sync/full"])
|
||||
assert_array(_server.requested_methods).contains_exactly([HTTPClient.METHOD_POST])
|
||||
|
||||
|
||||
func test_sync_already_running_calls_on_failed() -> void:
|
||||
_server.respond_json("/sync", {"error": "Sync is already running"}, 423)
|
||||
var outcome: Array[String] = []
|
||||
|
||||
MusicServerClient.start_sync(
|
||||
func() -> void: outcome.append("done"),
|
||||
func() -> void: outcome.append("failed"))
|
||||
|
||||
assert_array(outcome).contains_exactly(["failed"])
|
||||
|
||||
|
||||
func test_requests_go_to_selected_server_under_api_path() -> void:
|
||||
var previous_path: String = Settings.default_path
|
||||
Settings.default_path = "http://localhost:8080"
|
||||
var requested_urls: Array[String] = []
|
||||
var recording_transport: HttpTransport = _RecordingTransport.new(requested_urls)
|
||||
MusicServerClient.transport = recording_transport
|
||||
MusicServerClient.transport = _RecordingTransport.new(requested_urls)
|
||||
|
||||
MusicServerClient.fetch_song_info(func(_data: Variant) -> void: pass)
|
||||
MusicServerClient.fetch_song_info(func(_data: SongInfo) -> void: pass)
|
||||
|
||||
Settings.default_path = previous_path
|
||||
assert_array(requested_urls).contains_exactly(["http://localhost:8080/music/info"])
|
||||
assert_array(requested_urls).contains_exactly([
|
||||
"http://localhost:8080/api/v1/token",
|
||||
"http://localhost:8080/api/v1/music/info",
|
||||
])
|
||||
|
||||
|
||||
# Tokens
|
||||
|
||||
func test_request_sends_token_in_authorization_header() -> void:
|
||||
_server.respond_json("/music/info", {})
|
||||
|
||||
MusicServerClient.fetch_song_info(func(_data: SongInfo) -> void: pass)
|
||||
|
||||
assert_int(_server.token_requests).is_equal(1)
|
||||
assert_array(_server.requested_headers[0]) \
|
||||
.contains_exactly(["Authorization: Bearer fake-token-1"])
|
||||
|
||||
|
||||
func test_token_request_sends_password_and_client_type() -> void:
|
||||
var previous_password: String = Settings.api_password
|
||||
Settings.api_password = "secret"
|
||||
|
||||
MusicServerClient.fetch_song_info(func(_data: SongInfo) -> void: pass)
|
||||
|
||||
Settings.api_password = previous_password
|
||||
assert_dict(_server.last_token_request) \
|
||||
.is_equal({"password": "secret", "client_type": "godot"})
|
||||
|
||||
|
||||
func test_token_is_reused() -> void:
|
||||
_server.respond_json("/music/info", {})
|
||||
_server.respond_json("/music/list", [])
|
||||
|
||||
MusicServerClient.fetch_song_info(func(_data: SongInfo) -> void: pass)
|
||||
MusicServerClient.fetch_song_list(func(_data: Array[SongInfo]) -> void: pass)
|
||||
MusicServerClient.fetch_song_info(func(_data: SongInfo) -> void: pass)
|
||||
|
||||
assert_int(_server.token_requests).is_equal(1)
|
||||
assert_array(_server.requested_paths) \
|
||||
.contains_exactly(["/music/info", "/music/list", "/music/info"])
|
||||
|
||||
|
||||
func test_requests_while_getting_a_token_share_it() -> void:
|
||||
_server.respond_json("/music/info", {})
|
||||
_server.respond_json("/music/list", [])
|
||||
_server.hold("/token")
|
||||
var done: Array[String] = []
|
||||
|
||||
MusicServerClient.fetch_song_info(func(_data: SongInfo) -> void: done.append("info"))
|
||||
MusicServerClient.fetch_song_list(func(_data: Array[SongInfo]) -> void: done.append("list"))
|
||||
assert_array(_server.requested_paths).is_empty()
|
||||
_server.release("/token")
|
||||
|
||||
assert_int(_server.token_requests).is_equal(1)
|
||||
assert_array(done).contains_exactly(["info", "list"])
|
||||
|
||||
|
||||
func test_expired_token_is_renewed_and_request_sent_again() -> void:
|
||||
_server.respond_json("/music/info", {"SongNo": 3})
|
||||
MusicServerClient.fetch_song_info(func(_data: SongInfo) -> void: pass)
|
||||
_server.expire_tokens()
|
||||
var received: Array[SongInfo] = []
|
||||
|
||||
MusicServerClient.fetch_song_info(func(info: SongInfo) -> void: received.append(info))
|
||||
|
||||
assert_int(_server.token_requests).is_equal(2)
|
||||
assert_array(_server.requested_paths) \
|
||||
.contains_exactly(["/music/info", "/music/info", "/music/info"])
|
||||
assert_array(_server.requested_headers[2]) \
|
||||
.contains_exactly(["Authorization: Bearer fake-token-2"])
|
||||
assert_int(received.size()).is_equal(1)
|
||||
assert_int(received[0].song_no).is_equal(3)
|
||||
|
||||
|
||||
func test_request_is_sent_again_only_once() -> void:
|
||||
_server.respond_json("/music/info", {"error": "Unauthorized"}, 401)
|
||||
var outcome: Array[String] = []
|
||||
|
||||
MusicServerClient.fetch_song_info(
|
||||
func(_data: SongInfo) -> void: outcome.append("done"),
|
||||
func() -> void: outcome.append("failed"))
|
||||
|
||||
assert_int(_server.token_requests).is_equal(2)
|
||||
assert_int(_server.count("/music/info")).is_equal(2)
|
||||
assert_array(outcome).contains_exactly(["failed"])
|
||||
|
||||
|
||||
func test_refused_password_calls_on_failed_without_sending_request() -> void:
|
||||
_server.respond_json("/token", {"error": "Invalid password"}, 401)
|
||||
var outcome: Array[String] = []
|
||||
|
||||
MusicServerClient.fetch_song_info(
|
||||
func(_data: SongInfo) -> void: outcome.append("done"),
|
||||
func() -> void: outcome.append("failed"))
|
||||
|
||||
assert_array(_server.requested_paths).is_empty()
|
||||
assert_array(outcome).contains_exactly(["failed"])
|
||||
|
||||
|
||||
func test_rate_limited_token_request_calls_on_failed() -> void:
|
||||
_server.respond_json("/token", {"error": "Too many requests"}, 429)
|
||||
var outcome: Array[String] = []
|
||||
|
||||
MusicServerClient.add_to_queue(
|
||||
func() -> void: outcome.append("done"),
|
||||
func() -> void: outcome.append("failed"))
|
||||
|
||||
assert_array(outcome).contains_exactly(["failed"])
|
||||
|
||||
|
||||
func test_new_token_after_a_failed_token_request() -> void:
|
||||
_server.respond_json("/token", {"error": "Too many requests"}, 429)
|
||||
MusicServerClient.add_to_queue(func() -> void: pass)
|
||||
_server.respond_json("/token", {"token": "second", "expires_at": "", "client_type": "godot"})
|
||||
_server.respond_bytes("/music/addQue", PackedByteArray())
|
||||
_server.expire_tokens()
|
||||
|
||||
MusicServerClient.add_to_queue(func() -> void: pass)
|
||||
|
||||
assert_int(_server.token_requests).is_equal(2)
|
||||
assert_array(_server.requested_headers.back()) \
|
||||
.contains_exactly(["Authorization: Bearer second"])
|
||||
|
||||
|
||||
func test_changing_server_gets_a_new_token() -> void:
|
||||
var previous_path: String = Settings.default_path
|
||||
_server.respond_json("/music/info", {})
|
||||
MusicServerClient.fetch_song_info(func(_data: SongInfo) -> void: pass)
|
||||
|
||||
Settings.default_path = "http://localhost:8080"
|
||||
MusicServerClient.fetch_song_info(func(_data: SongInfo) -> void: pass)
|
||||
Settings.default_path = previous_path
|
||||
|
||||
assert_int(_server.token_requests).is_equal(2)
|
||||
|
||||
|
||||
func test_health_needs_no_token() -> void:
|
||||
_server.respond_json("/health", {"status": "ok"})
|
||||
var received: Array = []
|
||||
|
||||
MusicServerClient.fetch_health(func(data: Dictionary) -> void: received.append(data))
|
||||
|
||||
assert_int(_server.token_requests).is_equal(0)
|
||||
assert_array(_server.requested_headers[0]).is_empty()
|
||||
assert_array(received).contains_exactly([{"status": "ok"}])
|
||||
|
||||
|
||||
func test_delete_token_sends_token_and_forgets_it() -> void:
|
||||
_server.respond_json("/music/info", {})
|
||||
MusicServerClient.fetch_song_info(func(_data: SongInfo) -> void: pass)
|
||||
_server.respond_json("/token", {"status": "deleted"})
|
||||
var called: Array[bool] = [false]
|
||||
|
||||
MusicServerClient.delete_token(func() -> void: called[0] = true)
|
||||
|
||||
assert_bool(called[0]).is_true()
|
||||
assert_int(_server.requested_methods.back()).is_equal(HTTPClient.METHOD_DELETE)
|
||||
assert_array(_server.requested_headers.back()) \
|
||||
.contains_exactly(["Authorization: Bearer fake-token-1"])
|
||||
|
||||
|
||||
func test_delete_token_without_token_sends_nothing() -> void:
|
||||
var called: Array[bool] = [false]
|
||||
|
||||
MusicServerClient.delete_token(func() -> void: called[0] = true)
|
||||
|
||||
assert_bool(called[0]).is_true()
|
||||
assert_array(_server.requested_paths).is_empty()
|
||||
|
||||
|
||||
# Other endpoints
|
||||
|
||||
func test_endpoint_paths_and_methods() -> void:
|
||||
var noop: Callable = func(_data: Variant = null) -> void: pass
|
||||
var calls: Array[Array] = [
|
||||
[func() -> void: MusicServerClient.fetch_random_song_classic(noop),
|
||||
"/music/rand/classic", HTTPClient.METHOD_GET],
|
||||
[func() -> void: MusicServerClient.fetch_next_song(noop),
|
||||
"/music/next", HTTPClient.METHOD_GET],
|
||||
[func() -> void: MusicServerClient.fetch_previous_song(noop),
|
||||
"/music/previous", HTTPClient.METHOD_GET],
|
||||
[func() -> void: MusicServerClient.fetch_sound_test_song(noop),
|
||||
"/music/soundTest", HTTPClient.METHOD_GET],
|
||||
[func() -> void: MusicServerClient.mark_current_song_played(noop),
|
||||
"/music/addPlayed", HTTPClient.METHOD_GET],
|
||||
[func() -> void: MusicServerClient.fetch_all_soundtracks(noop),
|
||||
"/music/all", HTTPClient.METHOD_GET],
|
||||
[func() -> void: MusicServerClient.fetch_all_soundtracks_ordered(noop),
|
||||
"/music/all/order", HTTPClient.METHOD_GET],
|
||||
[func() -> void: MusicServerClient.fetch_soundtracks(noop),
|
||||
"/soundtracks", HTTPClient.METHOD_GET],
|
||||
[func() -> void: MusicServerClient.fetch_soundtrack("a b", noop),
|
||||
"/soundtracks/a%20b", HTTPClient.METHOD_GET],
|
||||
[func() -> void: MusicServerClient.fetch_song_by_uuid("so-1", noop),
|
||||
"/songs/so-1", HTTPClient.METHOD_GET],
|
||||
[func() -> void: MusicServerClient.fetch_song_file("so-1", noop),
|
||||
"/songs/so-1/file", HTTPClient.METHOD_GET],
|
||||
[func() -> void: MusicServerClient.fetch_character_list(noop),
|
||||
"/character/list", HTTPClient.METHOD_GET],
|
||||
[func() -> void: MusicServerClient.fetch_sync_progress(noop),
|
||||
"/sync/progress", HTTPClient.METHOD_GET],
|
||||
[func() -> void: MusicServerClient.fetch_version(noop),
|
||||
"/version", HTTPClient.METHOD_GET],
|
||||
[func() -> void: MusicServerClient.fetch_version_history(noop),
|
||||
"/version/history", HTTPClient.METHOD_GET],
|
||||
[func() -> void: MusicServerClient.fetch_latest_client_version(noop),
|
||||
"/download", HTTPClient.METHOD_GET],
|
||||
[func() -> void: MusicServerClient.fetch_client_assets(noop),
|
||||
"/download/list", HTTPClient.METHOD_GET],
|
||||
[func() -> void: MusicServerClient.download_client_windows(noop),
|
||||
"/download/windows", HTTPClient.METHOD_GET],
|
||||
[func() -> void: MusicServerClient.download_client_linux(noop),
|
||||
"/download/linux", HTTPClient.METHOD_GET],
|
||||
[func() -> void: MusicServerClient.cleanup_tokens(noop),
|
||||
"/token/cleanup", HTTPClient.METHOD_POST],
|
||||
[func() -> void: MusicServerClient.fetch_soundtrack_statistics(noop),
|
||||
"/statistics/soundtracks", HTTPClient.METHOD_GET],
|
||||
[func() -> void: MusicServerClient.fetch_statistics_summary(noop),
|
||||
"/statistics/summary", HTTPClient.METHOD_GET],
|
||||
[func() -> void: MusicServerClient.fetch_most_played_soundtracks(5, noop),
|
||||
"/statistics/soundtracks/most-played?limit=5", HTTPClient.METHOD_GET],
|
||||
[func() -> void: MusicServerClient.fetch_least_played_soundtracks(5, noop),
|
||||
"/statistics/soundtracks/least-played?limit=5", HTTPClient.METHOD_GET],
|
||||
[func() -> void: MusicServerClient.fetch_last_played_soundtracks(5, noop),
|
||||
"/statistics/soundtracks/last-played?limit=5", HTTPClient.METHOD_GET],
|
||||
[func() -> void: MusicServerClient.fetch_oldest_played_soundtracks(5, noop),
|
||||
"/statistics/soundtracks/oldest-played?limit=5", HTTPClient.METHOD_GET],
|
||||
[func() -> void: MusicServerClient.fetch_never_played_soundtracks(noop),
|
||||
"/statistics/soundtracks/never-played", HTTPClient.METHOD_GET],
|
||||
[func() -> void: MusicServerClient.fetch_most_played_songs(5, noop),
|
||||
"/statistics/songs/most-played?limit=5", HTTPClient.METHOD_GET],
|
||||
[func() -> void: MusicServerClient.fetch_least_played_songs(5, noop),
|
||||
"/statistics/songs/least-played?limit=5", HTTPClient.METHOD_GET],
|
||||
]
|
||||
for entry: Array in calls:
|
||||
_server.requested_paths.clear()
|
||||
_server.requested_methods.clear()
|
||||
entry[0].call()
|
||||
assert_array(_server.requested_paths).contains_exactly([entry[1]])
|
||||
assert_array(_server.requested_methods).contains_exactly([entry[2]])
|
||||
|
||||
|
||||
func test_statistics_summary_is_typed() -> void:
|
||||
_server.respond_json("/statistics/summary", {"total_soundtracks": 120,
|
||||
"played_soundtracks": 100, "avg_soundtrack_plays": 2.5})
|
||||
var received: Array[StatisticsSummary] = []
|
||||
|
||||
MusicServerClient.fetch_statistics_summary(
|
||||
func(summary: StatisticsSummary) -> void: received.append(summary))
|
||||
|
||||
assert_int(received[0].total_soundtracks).is_equal(120)
|
||||
assert_int(received[0].played_soundtracks).is_equal(100)
|
||||
assert_float(received[0].avg_soundtrack_plays).is_equal(2.5)
|
||||
|
||||
|
||||
func test_most_played_soundtracks_are_typed_with_their_songs() -> void:
|
||||
_server.respond_json("/statistics/soundtracks/most-played?limit=2", [
|
||||
{"soundtrack_uuid": "st-1", "soundtrack_name": "Chrono Trigger",
|
||||
"soundtrack_played": 9, "songs": [{"song_name": "Frog's Theme"}]},
|
||||
{"soundtrack_uuid": "st-2", "soundtrack_name": "Lufia II", "soundtrack_played": 4,
|
||||
"songs": []},
|
||||
])
|
||||
var received: Array[SoundtrackWithSongs] = []
|
||||
|
||||
MusicServerClient.fetch_most_played_soundtracks(2,
|
||||
func(list: Array[SoundtrackWithSongs]) -> void: received.append_array(list))
|
||||
|
||||
assert_int(received.size()).is_equal(2)
|
||||
assert_str(received[0].soundtrack_name).is_equal("Chrono Trigger")
|
||||
assert_int(received[0].soundtrack_played).is_equal(9)
|
||||
assert_str(received[0].songs[0].song_name).is_equal("Frog's Theme")
|
||||
assert_array(received[1].songs).is_empty()
|
||||
|
||||
|
||||
func test_soundtrack_statistics_are_typed() -> void:
|
||||
_server.respond_json("/statistics/soundtracks", [
|
||||
{"soundtrack_name": "Chrono Trigger", "soundtrack_played": 2,
|
||||
"songs": [{"song_name": "Frog's Theme", "times_played": 2}]},
|
||||
{"soundtrack_name": "Lufia II", "soundtrack_played": 0, "songs": []},
|
||||
])
|
||||
var received: Array[SoundtrackWithSongs] = []
|
||||
|
||||
MusicServerClient.fetch_soundtrack_statistics(
|
||||
func(list: Array[SoundtrackWithSongs]) -> void: received.append_array(list))
|
||||
|
||||
assert_int(received.size()).is_equal(2)
|
||||
assert_str(received[0].songs[0].song_name).is_equal("Frog's Theme")
|
||||
assert_int(received[1].soundtrack_played).is_equal(0)
|
||||
|
||||
|
||||
func test_most_played_songs_are_typed() -> void:
|
||||
_server.respond_json("/statistics/songs/most-played?limit=1", [
|
||||
{"song_uuid": "so-1", "song_name": "Dancing Mad", "times_played": 12},
|
||||
])
|
||||
var received: Array[SongInfoForStats] = []
|
||||
|
||||
MusicServerClient.fetch_most_played_songs(1,
|
||||
func(list: Array[SongInfoForStats]) -> void: received.append_array(list))
|
||||
|
||||
assert_str(received[0].song_name).is_equal("Dancing Mad")
|
||||
assert_int(received[0].times_played).is_equal(12)
|
||||
|
||||
|
||||
func test_statistics_calls_on_failed_on_server_error() -> void:
|
||||
var outcome: Array[String] = []
|
||||
var on_done: Callable = func(_data: Variant) -> void: outcome.append("done")
|
||||
var on_failed: Callable = func() -> void: outcome.append("failed")
|
||||
for path: String in ["/statistics/summary", "/statistics/soundtracks",
|
||||
"/statistics/soundtracks/most-played?limit=3",
|
||||
"/statistics/soundtracks/least-played?limit=3",
|
||||
"/statistics/soundtracks/last-played?limit=3",
|
||||
"/statistics/soundtracks/oldest-played?limit=3",
|
||||
"/statistics/soundtracks/never-played",
|
||||
"/statistics/songs/most-played?limit=3",
|
||||
"/statistics/songs/least-played?limit=3"]:
|
||||
_server.respond_json(path, {"error": "Internal Server Error"}, 500)
|
||||
|
||||
_call_every_statistics_endpoint(on_done, on_failed)
|
||||
|
||||
assert_array(outcome).contains_exactly(["failed", "failed", "failed", "failed", "failed",
|
||||
"failed", "failed", "failed", "failed"])
|
||||
|
||||
|
||||
func test_statistics_calls_on_failed_when_token_is_refused() -> void:
|
||||
_server.respond_json("/token", {"error": "Invalid password"}, 401)
|
||||
var outcome: Array[String] = []
|
||||
|
||||
_call_every_statistics_endpoint(func(_data: Variant) -> void: outcome.append("done"),
|
||||
func() -> void: outcome.append("failed"))
|
||||
|
||||
assert_array(_server.requested_paths).is_empty()
|
||||
assert_array(outcome).contains_exactly(["failed", "failed", "failed", "failed", "failed",
|
||||
"failed", "failed", "failed", "failed"])
|
||||
|
||||
|
||||
func _call_every_statistics_endpoint(on_done: Callable, on_failed: Callable) -> void:
|
||||
MusicServerClient.fetch_statistics_summary(on_done, on_failed)
|
||||
MusicServerClient.fetch_soundtrack_statistics(on_done, on_failed)
|
||||
MusicServerClient.fetch_most_played_soundtracks(3, on_done, on_failed)
|
||||
MusicServerClient.fetch_least_played_soundtracks(3, on_done, on_failed)
|
||||
MusicServerClient.fetch_last_played_soundtracks(3, on_done, on_failed)
|
||||
MusicServerClient.fetch_oldest_played_soundtracks(3, on_done, on_failed)
|
||||
MusicServerClient.fetch_never_played_soundtracks(on_done, on_failed)
|
||||
MusicServerClient.fetch_most_played_songs(3, on_done, on_failed)
|
||||
MusicServerClient.fetch_least_played_songs(3, on_done, on_failed)
|
||||
|
||||
|
||||
class _RecordingTransport:
|
||||
@@ -144,5 +551,13 @@ class _RecordingTransport:
|
||||
super(null)
|
||||
_urls = urls
|
||||
|
||||
func request(url: String, _method: int, _on_complete: Callable) -> void:
|
||||
func request(
|
||||
url: String,
|
||||
_method: int,
|
||||
on_complete: Callable,
|
||||
_headers: PackedStringArray = PackedStringArray(),
|
||||
_body: String = "",
|
||||
) -> void:
|
||||
_urls.append(url)
|
||||
if url.ends_with("/token"):
|
||||
on_complete.call(200, JSON.stringify({"token": "t"}).to_utf8_buffer())
|
||||
@@ -1,14 +1,13 @@
|
||||
extends GdUnitTestSuite
|
||||
## How the search popup finds and orders matching soundtracks.
|
||||
## How SoundtrackSearch, used by the Search popup and the statistics view, finds and
|
||||
## orders matching soundtracks.
|
||||
|
||||
const SearchWindowScript: GDScript = preload("res://popups/search_popup.gd")
|
||||
|
||||
var _search: Control
|
||||
var _search: SoundtrackSearch
|
||||
|
||||
|
||||
func before_test() -> void:
|
||||
_search = auto_free(SearchWindowScript.new())
|
||||
_search.set_soundtracks([
|
||||
_search = SoundtrackSearch.new()
|
||||
_search.set_names([
|
||||
"Chrono Trigger",
|
||||
"Final Fantasy VI",
|
||||
"Mario & Luigi: Superstar Saga",
|
||||
@@ -60,5 +59,20 @@ func test_no_matches() -> void:
|
||||
|
||||
|
||||
func test_clean_term() -> void:
|
||||
assert_str(_search.clean_term("Rock 'n Roll Racing")).is_equal("rockandrollracing")
|
||||
assert_str(_search.clean_term("Ys I & II")).is_equal("ysiandii")
|
||||
assert_str(SoundtrackSearch.clean_term("Rock 'n Roll Racing")).is_equal("rockandrollracing")
|
||||
assert_str(SoundtrackSearch.clean_term("Ys I & II")).is_equal("ysiandii")
|
||||
|
||||
|
||||
func test_search_popup_searches_with_soundtrack_search() -> void:
|
||||
var popup: SearchPopup = auto_free(load("res://popups/SearchPopup.tscn").instantiate())
|
||||
add_child(popup)
|
||||
popup.set_soundtracks(["Chrono Trigger", "Super Mario 64"])
|
||||
popup.show_all_soundtracks()
|
||||
|
||||
popup.perform_search("smr")
|
||||
|
||||
var shown: Array[String] = []
|
||||
for label: Label in popup.search_list.get_children():
|
||||
if label.visible:
|
||||
shown.append(label.text)
|
||||
assert_array(shown).contains_exactly(["Super Mario 64"])
|
||||
File renamed without changes.
+3
-2
@@ -42,8 +42,9 @@ inspector or `theme_type_variation` in code:
|
||||
| `TextMedium` | Label | Rows in the sync result (18) |
|
||||
| `HighlightLarge`, `HighlightMedium` | Label | Orange text: sync headers and every other sync row |
|
||||
| `SongRowLabel`, `SongRowLabelPlaying` | Label | A song's text, and the one that is playing |
|
||||
|
||||
The **PopupPanel** type itself is styled too, for the Statistics popup.
|
||||
| `TabButton` | Button | The Quiz and Statistics tabs; the chosen one is orange |
|
||||
| `StatisticsCard`, `StatisticsCardHover` | PanelContainer | A soundtrack card in the statistics, and the one under the mouse |
|
||||
| `StatisticsGrid` | GridContainer | The grid of soundtrack cards, with the space between them |
|
||||
|
||||
**Palette**: colours used from code.
|
||||
|
||||
|
||||
@@ -703,6 +703,50 @@ vertical = true
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_2mdtn"]
|
||||
bg_color = Color(0.3, 0.3, 0.3, 1)
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_tabpressed"]
|
||||
content_margin_left = 8.0
|
||||
content_margin_top = 4.0
|
||||
content_margin_right = 8.0
|
||||
content_margin_bottom = 4.0
|
||||
bg_color = Color(1, 0.5, 0, 0.85)
|
||||
corner_radius_top_left = 3
|
||||
corner_radius_top_right = 3
|
||||
corner_radius_bottom_right = 3
|
||||
corner_radius_bottom_left = 3
|
||||
corner_detail = 5
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_statscard"]
|
||||
content_margin_left = 12.0
|
||||
content_margin_top = 10.0
|
||||
content_margin_right = 12.0
|
||||
content_margin_bottom = 10.0
|
||||
bg_color = Color(0.16, 0.16, 0.16, 1)
|
||||
border_width_left = 2
|
||||
border_width_top = 2
|
||||
border_width_right = 2
|
||||
border_width_bottom = 2
|
||||
border_color = Color(0.3, 0.3, 0.3, 1)
|
||||
corner_radius_top_left = 6
|
||||
corner_radius_top_right = 6
|
||||
corner_radius_bottom_right = 6
|
||||
corner_radius_bottom_left = 6
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_statscardhover"]
|
||||
content_margin_left = 12.0
|
||||
content_margin_top = 10.0
|
||||
content_margin_right = 12.0
|
||||
content_margin_bottom = 10.0
|
||||
bg_color = Color(0.2, 0.2, 0.2, 1)
|
||||
border_width_left = 2
|
||||
border_width_top = 2
|
||||
border_width_right = 2
|
||||
border_width_bottom = 2
|
||||
border_color = Color(1, 0.5, 0, 1)
|
||||
corner_radius_top_left = 6
|
||||
corner_radius_top_right = 6
|
||||
corner_radius_bottom_right = 6
|
||||
corner_radius_bottom_left = 6
|
||||
|
||||
[resource]
|
||||
Button/colors/font_color = Color(0.875, 0.875, 0.875, 1)
|
||||
Button/colors/font_disabled_color = Color(0.875, 0.875, 0.875, 0.5)
|
||||
@@ -865,6 +909,16 @@ SongRowLabelPlaying/base_type = &"Label"
|
||||
SongRowLabelPlaying/colors/font_color = Color(1, 0.5, 0, 1)
|
||||
SongRowLabelPlaying/font_sizes/font_size = 20
|
||||
SongRowLabelPlaying/styles/normal = SubResource("StyleBoxFlat_lkqs8")
|
||||
StatisticsCard/base_type = &"PanelContainer"
|
||||
StatisticsCard/styles/panel = SubResource("StyleBoxFlat_statscard")
|
||||
StatisticsCardHover/base_type = &"PanelContainer"
|
||||
StatisticsCardHover/styles/panel = SubResource("StyleBoxFlat_statscardhover")
|
||||
StatisticsGrid/base_type = &"GridContainer"
|
||||
StatisticsGrid/constants/h_separation = 16
|
||||
StatisticsGrid/constants/v_separation = 16
|
||||
TabButton/base_type = &"Button"
|
||||
TabButton/styles/hover_pressed = SubResource("StyleBoxFlat_tabpressed")
|
||||
TabButton/styles/pressed = SubResource("StyleBoxFlat_tabpressed")
|
||||
TextEdit/colors/background_color = Color(0, 0, 0, 0)
|
||||
TextEdit/colors/caret_background_color = Color(0, 0, 0, 1)
|
||||
TextEdit/colors/caret_color = Color(0.875, 0.875, 0.875, 1)
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
extends SceneTree
|
||||
## Generates a typed class for every schema in the MusicServer OpenAPI spec.
|
||||
##
|
||||
## Usage:
|
||||
## godot --headless --path . -s tools/generate_api_types.gd [-- <spec>]
|
||||
##
|
||||
## [code]<spec>[/code] defaults to [code]server/openapi.json[/code]. To update it, run
|
||||
## [code]just openapi[/code] in MusicServer, or save [code]<server>/openapi.json[/code]
|
||||
## from a running server. Each schema becomes [code]server/generated/<name>.gd[/code]
|
||||
## with typed properties, [code]from_dict()[/code] and [code]to_dict()[/code]. Files
|
||||
## for schemas that no longer exist are removed. The output is sorted, so running it
|
||||
## again without API changes gives no diff.
|
||||
|
||||
const DEFAULT_SPEC_PATH: String = "res://server/openapi.json"
|
||||
const OUTPUT_DIR: String = "res://server/generated"
|
||||
const HELPER_CLASS: String = "ApiJson"
|
||||
const MAX_LINE_LENGTH: int = 100
|
||||
const HEADER: String = "# Generated by tools/generate_api_types.gd from server/openapi.json.\n" \
|
||||
+ "# Do not edit; change the server and generate again.\n"
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
var args: PackedStringArray = OS.get_cmdline_user_args()
|
||||
var spec_path: String = args[0] if !args.is_empty() else DEFAULT_SPEC_PATH
|
||||
var spec: Variant = JSON.parse_string(FileAccess.get_file_as_string(spec_path))
|
||||
if !(spec is Dictionary) || !spec.has("components"):
|
||||
push_error("Could not read an OpenAPI spec from " + spec_path)
|
||||
quit(1)
|
||||
return
|
||||
var schemas: Dictionary = spec.components.get("schemas", {})
|
||||
DirAccess.make_dir_recursive_absolute(OUTPUT_DIR)
|
||||
|
||||
var written: Array[String] = [_file_name(HELPER_CLASS)]
|
||||
_write(_file_name(HELPER_CLASS), _helper_source())
|
||||
var schema_names: Array = schemas.keys()
|
||||
schema_names.sort()
|
||||
for schema_name: String in schema_names:
|
||||
var file_name: String = _file_name(schema_name)
|
||||
_write(file_name, _class_source(schema_name, schemas[schema_name]))
|
||||
written.append(file_name)
|
||||
_remove_stale_files(written)
|
||||
print("Wrote %d classes to %s" % [written.size(), OUTPUT_DIR])
|
||||
quit()
|
||||
|
||||
|
||||
func _class_source(schema_name: String, schema: Dictionary) -> String:
|
||||
var class_id: String = _class_name(schema_name)
|
||||
var properties: Dictionary = schema.get("properties", {})
|
||||
var keys: Array = properties.keys()
|
||||
keys.sort()
|
||||
|
||||
var lines: PackedStringArray = [
|
||||
HEADER + "class_name " + class_id,
|
||||
"extends RefCounted",
|
||||
"## The [code]%s[/code] schema of the MusicServer API." % schema_name,
|
||||
"",
|
||||
]
|
||||
for key: String in keys:
|
||||
var property: Dictionary = properties[key]
|
||||
if property.has("description"):
|
||||
lines.append("## " + property.description)
|
||||
lines.append("var %s: %s%s" % [_property_name(key), _gd_type(property),
|
||||
_initial_value(property)])
|
||||
|
||||
lines.append_array([
|
||||
"",
|
||||
"",
|
||||
"## Reads the fields from parsed JSON. Missing fields keep their defaults.",
|
||||
"static func from_dict(data: Dictionary) -> %s:" % class_id,
|
||||
"\tvar result: %s = %s.new()" % [class_id, class_id],
|
||||
])
|
||||
for key: String in keys:
|
||||
lines.append_array(_read_lines(key, properties[key]))
|
||||
lines.append("\treturn result")
|
||||
|
||||
lines.append_array([
|
||||
"",
|
||||
"",
|
||||
"## Returns the fields under their JSON names, ready for [method JSON.stringify].",
|
||||
"func to_dict() -> Dictionary:",
|
||||
])
|
||||
if keys.is_empty():
|
||||
lines.append("\treturn {}")
|
||||
else:
|
||||
lines.append("\treturn {")
|
||||
for key: String in keys:
|
||||
lines.append("\t\t\"%s\": %s," % [key, _write_value(key, properties[key])])
|
||||
lines.append("\t}")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
func _read_lines(key: String, property: Dictionary) -> PackedStringArray:
|
||||
var target: String = "result." + _property_name(key)
|
||||
var quoted_key: String = "\"%s\"" % key
|
||||
if property.has("$ref"):
|
||||
return ["\t%s = %s.from_dict(%s.get_dictionary(data, %s))"
|
||||
% [target, _ref_class(property), HELPER_CLASS, quoted_key]]
|
||||
match property.get("type"):
|
||||
"array":
|
||||
var items: Dictionary = property.get("items", {})
|
||||
if items.has("$ref"):
|
||||
return [
|
||||
"\tfor item: Variant in %s.get_array(data, %s):" % [HELPER_CLASS, quoted_key],
|
||||
"\t\tif item is Dictionary:",
|
||||
"\t\t\t%s.append(%s.from_dict(item))" % [target, _ref_class(items)],
|
||||
]
|
||||
return ["\t%s.assign(%s.get_array(data, %s))" % [target, HELPER_CLASS, quoted_key]]
|
||||
"object":
|
||||
return ["\t%s.assign(%s.get_dictionary(data, %s))"
|
||||
% [target, HELPER_CLASS, quoted_key]]
|
||||
"integer":
|
||||
return ["\t%s = %s.get_int(data, %s, %s)"
|
||||
% [target, HELPER_CLASS, quoted_key, _default(property)]]
|
||||
"number":
|
||||
return ["\t%s = %s.get_float(data, %s, %s)"
|
||||
% [target, HELPER_CLASS, quoted_key, _default(property)]]
|
||||
"boolean":
|
||||
return ["\t%s = %s.get_bool(data, %s, %s)"
|
||||
% [target, HELPER_CLASS, quoted_key, _default(property)]]
|
||||
_:
|
||||
return ["\t%s = %s.get_string(data, %s, %s)"
|
||||
% [target, HELPER_CLASS, quoted_key, _default(property)]]
|
||||
|
||||
|
||||
func _write_value(key: String, property: Dictionary) -> String:
|
||||
var value: String = _property_name(key)
|
||||
if property.has("$ref"):
|
||||
return value + ".to_dict()"
|
||||
if property.get("type") == "array" && property.get("items", {}).has("$ref"):
|
||||
return "%s.map(func(item: %s) -> Dictionary: return item.to_dict())" \
|
||||
% [value, _ref_class(property.items)]
|
||||
return value
|
||||
|
||||
|
||||
func _gd_type(property: Dictionary) -> String:
|
||||
if property.has("$ref"):
|
||||
return _ref_class(property)
|
||||
match property.get("type"):
|
||||
"array":
|
||||
return "Array[%s]" % _gd_type(property.get("items", {}))
|
||||
"object":
|
||||
var values: Variant = property.get("additionalProperties")
|
||||
if values is Dictionary && !values.is_empty():
|
||||
return "Dictionary[String, %s]" % _gd_type(values)
|
||||
return "Dictionary"
|
||||
"integer":
|
||||
return "int"
|
||||
"number":
|
||||
return "float"
|
||||
"boolean":
|
||||
return "bool"
|
||||
"string":
|
||||
return "String"
|
||||
return "Variant"
|
||||
|
||||
|
||||
func _initial_value(property: Dictionary) -> String:
|
||||
if property.has("$ref"):
|
||||
return " = %s.new()" % _ref_class(property)
|
||||
if property.has("default"):
|
||||
return " = " + _default(property)
|
||||
return ""
|
||||
|
||||
|
||||
# The default value as GDScript source
|
||||
func _default(property: Dictionary) -> String:
|
||||
var type: String = _gd_type(property)
|
||||
if property.has("default"):
|
||||
var value: Variant = property.default
|
||||
if type == "int":
|
||||
return str(int(value))
|
||||
return JSON.stringify(value)
|
||||
match type:
|
||||
"int":
|
||||
return "0"
|
||||
"float":
|
||||
return "0.0"
|
||||
"bool":
|
||||
return "false"
|
||||
return "\"\""
|
||||
|
||||
|
||||
func _ref_class(property: Dictionary) -> String:
|
||||
return _class_name(String(property["$ref"]).get_file())
|
||||
|
||||
|
||||
func _class_name(schema_name: String) -> String:
|
||||
return schema_name.to_snake_case().to_pascal_case()
|
||||
|
||||
|
||||
func _property_name(key: String) -> String:
|
||||
return key.to_snake_case()
|
||||
|
||||
|
||||
func _file_name(schema_name: String) -> String:
|
||||
return schema_name.to_snake_case() + ".gd"
|
||||
|
||||
|
||||
func _helper_source() -> String:
|
||||
return HEADER + """class_name ApiJson
|
||||
extends RefCounted
|
||||
## Reads fields from parsed JSON for the generated API classes. A field that is
|
||||
## missing, null or of the wrong type gives the default instead.
|
||||
|
||||
|
||||
static func get_string(data: Dictionary, key: String, default: String = "") -> String:
|
||||
var value: Variant = data.get(key)
|
||||
return value if value is String else default
|
||||
|
||||
|
||||
static func get_int(data: Dictionary, key: String, default: int = 0) -> int:
|
||||
var value: Variant = data.get(key)
|
||||
return int(value) if value is int || value is float else default
|
||||
|
||||
|
||||
static func get_float(data: Dictionary, key: String, default: float = 0.0) -> float:
|
||||
var value: Variant = data.get(key)
|
||||
return float(value) if value is int || value is float else default
|
||||
|
||||
|
||||
static func get_bool(data: Dictionary, key: String, default: bool = false) -> bool:
|
||||
var value: Variant = data.get(key)
|
||||
return value if value is bool else default
|
||||
|
||||
|
||||
static func get_array(data: Dictionary, key: String) -> Array:
|
||||
var value: Variant = data.get(key)
|
||||
return value if value is Array else []
|
||||
|
||||
|
||||
static func get_dictionary(data: Dictionary, key: String) -> Dictionary:
|
||||
var value: Variant = data.get(key)
|
||||
return value if value is Dictionary else {}
|
||||
"""
|
||||
|
||||
|
||||
func _write(file_name: String, source: String) -> void:
|
||||
var lines: PackedStringArray = []
|
||||
for line: String in source.split("\n"):
|
||||
lines.append(_wrap(line))
|
||||
var file: FileAccess = FileAccess.open(OUTPUT_DIR.path_join(file_name), FileAccess.WRITE)
|
||||
file.store_string("\n".join(lines))
|
||||
file.close()
|
||||
|
||||
|
||||
# Breaks a statement longer than the style guide's 100 characters after its first
|
||||
# opening parenthesis, with the continuation two levels deeper
|
||||
func _wrap(line: String) -> String:
|
||||
var indent: int = line.length() - line.lstrip("\t").length()
|
||||
var split_at: int = line.find("(")
|
||||
if line.replace("\t", " ").length() <= MAX_LINE_LENGTH || split_at < 0 \
|
||||
|| line.strip_edges().begins_with("#"):
|
||||
return line
|
||||
return line.substr(0, split_at + 1) + "\n" + "\t".repeat(indent + 2) \
|
||||
+ line.substr(split_at + 1)
|
||||
|
||||
|
||||
func _remove_stale_files(written: Array[String]) -> void:
|
||||
for file_name: String in DirAccess.get_files_at(OUTPUT_DIR):
|
||||
var script_name: String = file_name.trim_suffix(".uid")
|
||||
if script_name.ends_with(".gd") && !written.has(script_name):
|
||||
DirAccess.remove_absolute(OUTPUT_DIR.path_join(file_name))
|
||||
@@ -0,0 +1 @@
|
||||
uid://cummnobkmb3le
|
||||
Reference in new issue
Block a user