Add a Statistics tab with a card for every soundtrack
Part of #41. The top bar has Quiz and Statistics tabs instead of the Statistics button. The Statistics tab replaces the quiz below the top bar (the song keeps playing) and shows every soundtrack as a card with its plays, last played date and most and least played songs, loaded from the new GET /api/v1/statistics/soundtracks. The cards can be searched (with the Search popup's matching, now in SoundtrackSearch), filtered on played or never played, and sorted A-Z, Z-A, most or least played. Clicking a card opens a popup with all its songs. Screenshots of the other states only differ in the top bar, and the layout baseline is taken again with the new statistics states.
This commit is contained in:
1 parent
f7619baf34
commit
a21b6e9b22
30 files changed
+25345
-2272
No files matched your search
+2
-2
@@ -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 |
|
||||
|
||||
|
||||
@@ -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
|
||||
+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
|
||||
@@ -144,6 +144,12 @@ func fetch_statistics_summary(on_done: Callable, on_failed: Callable = Callable(
|
||||
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(
|
||||
|
||||
@@ -2455,6 +2455,56 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/statistics/soundtracks": {
|
||||
"get": {
|
||||
"description": "Returns every soundtrack with its songs and play counts, sorted by name.",
|
||||
"operationId": "get-all-soundtrack-statistics",
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/SoundtrackWithSongs"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "OK"
|
||||
},
|
||||
"401": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/APIError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Unauthorized"
|
||||
},
|
||||
"500": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/APIError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Internal Server Error"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
}
|
||||
],
|
||||
"summary": "All soundtracks with statistics",
|
||||
"tags": [
|
||||
"statistics"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/statistics/soundtracks/last-played": {
|
||||
"get": {
|
||||
"description": "Returns the most recently played soundtracks with their songs.",
|
||||
|
||||
@@ -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
|
||||
+24082
-2114
File diff suppressed because it is too large.
Load diff
+56
-4
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -410,6 +410,8 @@ func test_endpoint_paths_and_methods() -> void:
|
||||
"/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),
|
||||
@@ -467,6 +469,22 @@ func test_most_played_soundtracks_are_typed_with_their_songs() -> void:
|
||||
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},
|
||||
@@ -484,7 +502,7 @@ 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",
|
||||
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",
|
||||
@@ -497,7 +515,7 @@ func test_statistics_calls_on_failed_on_server_error() -> void:
|
||||
_call_every_statistics_endpoint(on_done, on_failed)
|
||||
|
||||
assert_array(outcome).contains_exactly(["failed", "failed", "failed", "failed", "failed",
|
||||
"failed", "failed", "failed"])
|
||||
"failed", "failed", "failed", "failed"])
|
||||
|
||||
|
||||
func test_statistics_calls_on_failed_when_token_is_refused() -> void:
|
||||
@@ -509,11 +527,12 @@ func test_statistics_calls_on_failed_when_token_is_refused() -> void:
|
||||
|
||||
assert_array(_server.requested_paths).is_empty()
|
||||
assert_array(outcome).contains_exactly(["failed", "failed", "failed", "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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user