Show every song in the statistics, as an alternative to the cards

A Soundtracks / Songs switch in the statistics toolbar shows a list of
every song with its soundtrack and how often it was played. It is searched
on both song and soundtrack names, filtered and sorted like the cards, and
clicking a song shows its soundtrack's popup.

The server has tens of thousands of songs, so the list only makes the rows
on screen and reuses them while scrolling, songs are sorted once per load,
and searching waits for a pause in typing.

Closes #47
This commit is contained in:
Sansan committed 2026-10-09 17:02:27 +02:00
1 parent cd4331881c
commit a4e6da4c28
14 files changed
+10058 -1670

No files matched your search

File diff suppressed because it is too large. Load diff
+9
View File
@@ -267,6 +267,15 @@ func run(on_state: Callable) -> void:
statistics_view.apply_filter()
await _settle()
await on_state.call("statistics_search", statistics_view)
statistics_view.set_mode(StatisticsView.Mode.SONGS)
await _settle()
await on_state.call("statistics_songs_search", statistics_view)
statistics_view.clear_search()
await _settle()
await on_state.call("statistics_songs", statistics_view)
statistics_view.search_field.text = "chrono"
statistics_view.set_mode(StatisticsView.Mode.SOUNDTRACKS)
await _settle()
var stats_popup: SoundtrackStatsPopup = _node("SoundtrackStatsPopup")
for card: SoundtrackCard in statistics_view.cards:
if card.visible:
+195 -2
View File
@@ -1,6 +1,7 @@
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.
## The Statistics tab: switching between it and the quiz, the soundtrack cards, the list
## of songs, 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,
@@ -318,6 +319,198 @@ func test_filters_stay_when_statistics_are_loaded_again() -> void:
assert_str(_view.cards[0].soundtrack.soundtrack_name).is_equal("Terranigma")
# Songs
func _show_songs_mode() -> void:
await _open_statistics()
await _click_at(_center(_view.songs_mode_button))
await _runner.simulate_frames(1)
func _listed_songs() -> Array[String]:
var listed: Array[String] = []
for song: SongInfoForStats in _view.song_list.songs:
listed.append(song.song_name)
return listed
func _search(text: String) -> void:
_view.search_field.text = text
_view.apply_filter()
func test_songs_button_shows_every_song_a_to_z_instead_of_the_cards() -> void:
await _show_songs_mode()
assert_bool(_view.song_list.visible).is_true()
assert_bool(_view.card_scroll.visible).is_false()
assert_bool(_view.songs_mode_button.button_pressed).is_true()
assert_bool(_view.soundtracks_mode_button.button_pressed).is_false()
assert_array(_listed_songs()).contains_exactly([
"Battle with Magus", "Corridors of Time", "Dancing Mad", "Frog's Theme",
"Peaceful Days", "Schala's Theme", "To Far Away Times", "Underworld", "Wind Scene",
"Zeal Palace",
])
assert_str(_view.count_label.text).is_equal("10 songs")
assert_str(_view.search_field.placeholder_text).is_equal("Search songs and soundtracks")
func test_song_rows_show_the_song_its_soundtrack_and_plays() -> void:
await _show_songs_mode()
var row: SongStatsRow = _view.song_list.shown_rows()[0]
assert_str(row.song_label.get_parsed_text()).is_equal("Battle with Magus")
assert_str(row.soundtrack_label.get_parsed_text()).is_equal("Chrono Trigger")
assert_str(row.played_label.text).is_equal("2")
assert_int(_view.song_list.shown_rows().size()).is_equal(10)
func test_soundtracks_button_shows_the_cards_again() -> void:
await _show_songs_mode()
await _click_at(_center(_view.soundtracks_mode_button))
assert_bool(_view.card_scroll.visible).is_true()
assert_bool(_view.song_list.visible).is_false()
assert_str(_view.count_label.text).is_equal("4 soundtracks")
func test_song_search_finds_song_names_and_soundtrack_names() -> void:
await _show_songs_mode()
_search("magus")
assert_array(_listed_songs()).contains_exactly(["Battle with Magus"])
assert_str(_view.count_label.text).is_equal("1 of 10 songs")
# Every song of a soundtrack whose name matches
_search("final fantasy")
assert_array(_listed_songs()).contains_exactly(["Dancing Mad"])
func test_song_search_highlights_what_matched() -> void:
await _show_songs_mode()
_search("magus")
var row: SongStatsRow = _view.song_list.shown_rows()[0]
assert_str(row.song_label.text).contains("[color=")
assert_str(row.song_label.get_parsed_text()).is_equal("Battle with Magus")
assert_str(row.soundtrack_label.text).not_contains("[color=")
func test_typing_a_song_search_waits_for_a_pause() -> void:
await _show_songs_mode()
_view.search_field.text = "magus"
_view.search_field.text_changed.emit("magus")
assert_int(_view.song_list.songs.size()).is_equal(10)
await await_millis(300)
assert_array(_listed_songs()).contains_exactly(["Battle with Magus"])
func test_show_filter_for_songs() -> void:
await _show_songs_mode()
_view.show_option.select(StatisticsView.ShowFilter.NEVER_PLAYED)
_view.show_option.item_selected.emit(StatisticsView.ShowFilter.NEVER_PLAYED)
assert_array(_listed_songs()).contains_exactly(
["To Far Away Times", "Underworld", "Wind Scene"])
_view.show_option.select(StatisticsView.ShowFilter.PLAYED)
_view.show_option.item_selected.emit(StatisticsView.ShowFilter.PLAYED)
assert_int(_listed_songs().size()).is_equal(7)
assert_str(_view.count_label.text).is_equal("7 of 10 songs")
func test_song_sort_orders() -> void:
await _show_songs_mode()
var expected: Dictionary = {
StatisticsView.SortOrder.MOST_PLAYED: ["Schala's Theme", "Frog's Theme",
"Battle with Magus", "Corridors of Time", "Dancing Mad", "Peaceful Days",
"Zeal Palace", "To Far Away Times", "Underworld", "Wind Scene"],
StatisticsView.SortOrder.LEAST_PLAYED: ["To Far Away Times", "Underworld",
"Wind Scene", "Corridors of Time", "Dancing Mad", "Peaceful Days",
"Zeal Palace", "Battle with Magus", "Frog's Theme", "Schala's Theme"],
StatisticsView.SortOrder.Z_TO_A: ["Zeal Palace", "Wind Scene", "Underworld",
"To Far Away Times", "Schala's Theme", "Peaceful Days", "Frog's Theme",
"Dancing Mad", "Corridors of Time", "Battle with Magus"],
}
for order: StatisticsView.SortOrder in expected:
_view.sort_option.select(order)
_view.sort_option.item_selected.emit(order)
assert_array(_listed_songs()).contains_exactly(expected[order])
func test_songs_with_the_same_name_are_sorted_by_soundtrack() -> void:
_server.respond_json("/statistics/soundtracks", [
{"soundtrack_name": "Lufia II", "songs": [{"song_name": "Main Theme"}]},
{"soundtrack_name": "Earthbound", "songs": [{"song_name": "Main Theme"}]},
])
await _show_songs_mode()
var soundtracks: Array[String] = []
for song: SongInfoForStats in _view.song_list.songs:
soundtracks.append(song.soundtrack_name)
assert_array(soundtracks).contains_exactly(["Earthbound", "Lufia II"])
func test_clicking_a_song_shows_its_soundtrack() -> void:
await _show_songs_mode()
var popup: SoundtrackStatsPopup = _window.get_node("SoundtrackStatsPopup")
_search("dancing")
await _click_at(_center(_view.song_list.shown_rows()[0]))
assert_bool(popup.visible).is_true()
assert_str(popup.title_label.text).is_equal("Final Fantasy VI")
func test_songs_stay_shown_when_statistics_are_loaded_again() -> void:
await _show_songs_mode()
_server.respond_json("/statistics/soundtracks", STATISTICS.slice(1, 2))
_view.refresh()
assert_bool(_view.song_list.visible).is_true()
assert_array(_listed_songs()).contains_exactly(["Underworld"])
func test_only_the_rows_on_screen_are_made_for_many_songs() -> void:
var songs: Array[Dictionary] = []
for i: int in 5000:
songs.append({"song_name": "Song %04d" % i, "times_played": i % 7})
_server.respond_json("/statistics/soundtracks",
[{"soundtrack_name": "Big", "songs": songs}])
await _show_songs_mode()
var rows: int = _view.song_list.rows_area.get_child_count()
assert_int(rows).is_less(60)
assert_str(_view.count_label.text).is_equal("5000 songs")
_view.song_list.scroll_to(2500)
await _runner.simulate_frames(1)
assert_str(_view.song_list.shown_rows()[0].song.song_name).is_equal("Song 2500")
assert_int(_view.song_list.rows_area.get_child_count()).is_equal(rows)
func test_mouse_wheel_scrolls_the_songs() -> void:
var songs: Array[Dictionary] = []
for i: int in 200:
songs.append({"song_name": "Song %03d" % i})
_server.respond_json("/statistics/soundtracks",
[{"soundtrack_name": "Big", "songs": songs}])
await _show_songs_mode()
var transform: Transform2D = _window.get_viewport().get_final_transform()
_runner.set_mouse_position(transform * _center(_view.song_list.rows_area))
await _runner.simulate_mouse_button_pressed(MOUSE_BUTTON_WHEEL_DOWN)
await _runner.await_input_processed()
assert_str(_view.song_list.shown_rows()[0].song.song_name).is_equal("Song 003")
# Popup
func test_clicking_a_card_shows_all_its_songs_most_played_first() -> void:
+4
View File
@@ -39,6 +39,10 @@ const STYLES: Dictionary = {
"LargeButton": "Button",
"SearchField": "LineEdit",
"SearchResult": "RichTextLabel",
"ListRowHover": "PanelContainer",
"SongStatsHeader": "PanelContainer",
"SongStatsText": "RichTextLabel",
"SongStatsSecondary": "RichTextLabel",
}
## Theme types that only scripts read from, so they are not styles a node can pick.