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
+25379
-2306
No files matched your search
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user