Show soundtrack covers and edit alternative names (#49)
Build / test (push) Successful in 1m43s
Build / build (push) Successful in 1m14s

Covers: statistics cards show the soundtrack's cover on the left, and the
statistics popup shows it large. Until it arrives, and without a cover, the
soundtrack's initials stand in. Covers load only for cards on or near the
screen, three at a time, and are cached in covers/ next to the settings
(CoverCache), downloaded again only when cover_modified changes. The index and
file handling are shared with CharacterCache in a new FileCache.

Alternative names: the popup shows them and has an editor (add, remove, save)
that sends PUT /soundtracks/{uuid}/alternative-names. The statistics search and
the Search popup find soundtracks by them; Search shows the matching name after
the real one. Search now loads /soundtracks, falling back to the plain list.

The API types are regenerated from the server's spec, with a SoundtrackCover
theme style in every theme.

Closes #49

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
SansanandClaude Opus 5.5 committed 2026-10-14 22:06:30 +02:00
1 parent a735c515bf
commit ef6ac8f1fd
37 files changed
+8029 -1763

No files matched your search

File diff suppressed because it is too large. Load diff
+4
View File
@@ -25,6 +25,9 @@ var requested_paths: Array[String] = []
var requested_methods: Array[int] = []
## Headers of each request, in the same order as [member requested_paths].
var requested_headers: Array[PackedStringArray] = []
## The JSON body of each request, empty for none, in the same order as
## [member requested_paths].
var requested_bodies: Array[String] = []
## How many tokens have been asked for.
var token_requests: int = 0
## The JSON body of the last token request.
@@ -120,6 +123,7 @@ func request(
requested_paths.append(path)
requested_methods.append(method)
requested_headers.append(headers)
requested_bodies.append(body)
if !OPEN_PATHS.has(path.get_slice("?", 0)) && !_has_valid_token(headers):
on_complete.call(401, JSON.stringify({"error": "Unauthorized"}).to_utf8_buffer(),
PackedStringArray())
+16
View File
@@ -91,6 +91,12 @@ static func make_server() -> FakeHttpTransport:
server.respond_bytes("/sync", PackedByteArray())
server.respond_json("/sync/progress", SYNC_PROGRESS)
server.respond_json("/statistics/soundtracks", soundtrack_statistics())
# The character pictures stand in for covers
var pictures: Array[String] = character_names()
for i: int in SOUNDTRACKS.size():
server.respond_bytes("/soundtracks/soundtrack-%d/cover" % i,
FileAccess.get_file_as_bytes(CHARACTERS_DIR.path_join(
pictures[i % pictures.size()])))
server.respond_json("/version/history", SERVER_HISTORY)
return server
@@ -118,6 +124,11 @@ static func soundtrack_statistics() -> Array[Dictionary]:
}
if played > 0:
soundtrack.soundtrack_last_played = "2026-09-%02dT20:00:00Z" % (i + 1)
# Every other soundtrack has a cover, the rest show their initials
if i % 2 == 0:
soundtrack.cover_modified = 1700000000
if i == 0:
soundtrack.alternative_names = ["CT", "クロノ・トリガー"]
statistics.append(soundtrack)
return statistics
@@ -292,6 +303,11 @@ func run(on_state: Callable) -> void:
break
await _settle()
await on_state.call("popup_SoundtrackStats", stats_popup)
stats_popup.edit_names()
stats_popup.new_name_field.text = "Chrono"
await _settle()
await on_state.call("popup_SoundtrackStats_names", stats_popup)
stats_popup.stop_editing()
stats_popup.close()
_main.show_tab(&"quiz")
await _settle()
+187
View File
@@ -0,0 +1,187 @@
extends MainWindowTestSuite
## Soundtrack covers on the statistics cards and in the popup, loaded for the cards on
## screen, and editing a soundtrack's alternative names, which search then finds it by.
const STATISTICS: Array[Dictionary] = [
{"soundtrack_uuid": "st-1", "soundtrack_name": "Chrono Trigger", "soundtrack_played": 5,
"cover_modified": 100, "alternative_names": [], "songs": [
{"song_name": "Corridors of Time", "times_played": 1},
]},
{"soundtrack_uuid": "st-2", "soundtrack_name": "Terranigma", "soundtrack_played": 0,
"songs": []},
]
var _view: StatisticsView
var _popup: SoundtrackStatsPopup
var _png: PackedByteArray
func _before_window_starts() -> void:
_server.respond_json("/statistics/soundtracks", STATISTICS)
var image: Image = Image.create(6, 8, false, Image.FORMAT_RGB8)
_png = image.save_png_to_buffer()
_server.respond_bytes("/soundtracks/st-1/cover", _png)
func before_test() -> void:
# Covers are kept next to the settings, which the tests keep in a folder of their own
OS.set_environment(Settings.CONFIG_DIR_ENV, create_temp_dir("covers_%d" % randi()))
await super()
_view = _window.get_node("StatisticsView")
_popup = _window.get_node("SoundtrackStatsPopup")
func _open_statistics() -> void:
_window.show_tab(&"statistics")
await _runner.simulate_frames(3)
func _card(soundtrack_name: String) -> SoundtrackCard:
for card: SoundtrackCard in _view.cards:
if card.soundtrack.soundtrack_name == soundtrack_name:
return card
return null
func test_card_shows_its_cover() -> void:
await _open_statistics()
var card: SoundtrackCard = _card("Chrono Trigger")
assert_object(card.cover_picture.texture).is_not_null()
assert_bool(card.cover_placeholder.visible).is_false()
func test_card_without_a_cover_shows_initials_and_asks_nothing() -> void:
await _open_statistics()
var card: SoundtrackCard = _card("Terranigma")
assert_object(card.cover_picture.texture).is_null()
assert_bool(card.cover_placeholder.visible).is_true()
assert_str(card.cover_placeholder.text).is_equal("T")
assert_int(_server.count("/soundtracks/st-2/cover")).is_equal(0)
func test_cover_missing_on_the_server_keeps_the_initials() -> void:
_server.respond_bytes("/soundtracks/st-1/cover", PackedByteArray(), 404)
await _open_statistics()
assert_bool(_card("Chrono Trigger").cover_placeholder.visible).is_true()
assert_str(_card("Chrono Trigger").cover_placeholder.text).is_equal("CT")
func test_covers_load_for_cards_on_screen_as_they_scroll_in() -> void:
var many: Array[Dictionary] = []
for i: int in 40:
many.append({"soundtrack_uuid": "many-%02d" % i, "soundtrack_name": "Game %02d" % i,
"soundtrack_played": 1, "cover_modified": 100, "songs": []})
_server.respond_bytes("/soundtracks/many-%02d/cover" % i, _png)
_server.respond_json("/statistics/soundtracks", many)
await _open_statistics()
var asked: Callable = func() -> int:
return _server.requested_paths.filter(
func(path: String) -> bool: return path.begins_with("/soundtracks/many-")).size()
var on_screen: int = asked.call()
assert_int(on_screen).is_greater(0)
assert_int(on_screen).is_less(40)
var scroll_bar: VScrollBar = _view.card_scroll.get_v_scroll_bar()
scroll_bar.value = scroll_bar.max_value
await _runner.simulate_frames(2)
assert_int(asked.call()).is_greater(on_screen)
func test_popup_shows_the_cover_large() -> void:
await _open_statistics()
_popup.show_soundtrack(_card("Chrono Trigger").soundtrack)
assert_object(_popup.cover_picture.texture).is_not_null()
assert_bool(_popup.cover_placeholder.visible).is_false()
assert_str(_popup.names_label.text).is_equal("No other names yet")
func test_editing_names_saves_them_and_search_finds_the_new_name() -> void:
_server.respond_json("/soundtracks/st-1/alternative-names", {"uuid": "st-1",
"name": "Chrono Trigger", "alternative_names": ["CT", "クロノ・トリガー"]})
await _open_statistics()
_popup.show_soundtrack(_card("Chrono Trigger").soundtrack)
_popup.edit_names()
assert_bool(_popup.names_editor.visible).is_true()
_popup.new_name_field.text = "CT"
_popup.add_name()
_popup.new_name_field.text = "クロノ・トリガー"
_popup.save_names()
var index: int = _server.requested_paths.find("/soundtracks/st-1/alternative-names")
assert_int(index).is_not_equal(-1)
assert_int(_server.requested_methods[index]).is_equal(HTTPClient.METHOD_PUT)
var body: Dictionary = JSON.parse_string(_server.requested_bodies[index])
assert_array(body.names).contains_exactly(["CT", "クロノ・トリガー"])
assert_bool(_popup.names_editor.visible).is_false()
assert_str(_popup.names_label.text).is_equal("CT, クロノ・トリガー")
_view.search_field.text = "クロノ"
_view.apply_filter()
var shown: Array = _view.cards.filter(func(card: SoundtrackCard) -> bool: return card.visible)
assert_array(shown.map(func(card: SoundtrackCard) -> String:
return card.soundtrack.soundtrack_name)).contains_exactly(["Chrono Trigger"])
func test_editor_skips_empty_repeated_and_own_names() -> void:
await _open_statistics()
_popup.show_soundtrack(_card("Chrono Trigger").soundtrack)
_popup.edit_names()
for name: String in [" ", "CT", "ct", "Chrono Trigger"]:
_popup.new_name_field.text = name
_popup.add_name()
assert_int(_popup.editor_names.get_child_count()).is_equal(1)
func test_removing_a_name_in_the_editor() -> void:
await _open_statistics()
_popup.show_soundtrack(_card("Chrono Trigger").soundtrack)
_popup.edit_names()
_popup.new_name_field.text = "CT"
_popup.add_name()
_popup.remove_name("CT")
assert_int(_popup.editor_names.get_child_count()).is_equal(0)
func test_failed_save_keeps_the_editor_open() -> void:
_server.respond_json("/soundtracks/st-1/alternative-names", {}, 500)
await _open_statistics()
_popup.show_soundtrack(_card("Chrono Trigger").soundtrack)
_popup.edit_names()
_popup.new_name_field.text = "CT"
_popup.save_names()
assert_bool(_popup.names_editor.visible).is_true()
assert_bool(_popup.editor_status.visible).is_true()
assert_bool(_popup.save_names_button.disabled).is_false()
func test_search_popup_finds_soundtracks_by_alternative_names() -> void:
_server.respond_json("/soundtracks", [
{"uuid": "st-1", "name": "Chrono Trigger", "alternative_names": ["CT"]},
{"uuid": "st-2", "name": "Terranigma", "alternative_names": []},
])
var search: SearchPopup = _window.get_node("SearchPopup")
search.get_list_of_soundtracks()
search.open()
search.search_field.text = "ct"
search.search()
assert_array(search.shown_names()).contains(["Chrono Trigger"])
var row: PanelContainer = search.search_results.get_child(0)
assert_str((row.get_child(0) as RichTextLabel).get_parsed_text()).contains("CT")
@@ -0,0 +1 @@
uid://d3oc3e1fsttoh
+1
View File
@@ -50,6 +50,7 @@ const STYLES: Dictionary = {
"ProgressSlider": "HSlider",
"VolumeBar": "HSlider",
"StatisticsCard": "PanelContainer",
"SoundtrackCover": "Panel",
"StatisticsCardHover": "PanelContainer",
"StatisticsGrid": "GridContainer",
"TabButton": "Button",
+4 -1
View File
@@ -182,9 +182,12 @@ func test_to_dict_gives_back_the_json() -> void:
var json: Dictionary = {
"soundtrack_id": 5, "soundtrack_uuid": "st-1", "soundtrack_name": "Earthbound",
"soundtrack_played": 6, "soundtrack_last_played": "2026-10-01T20:00:00Z",
"alternative_names": ["Mother 2"], "cover_modified": 1730000000,
"songs": [{"soundtrack_id": 5, "soundtrack_uuid": "st-1", "song_uuid": "so-1",
"soundtrack_name": "Earthbound", "song_name": "Onett", "path": "/onett.mp3",
"times_played": 2, "file_name": "onett.mp3"}],
}
assert_dict(SoundtrackWithSongs.from_dict(json).to_dict()).is_equal(json)
# Compared as JSON, since the names come back as a typed Array[String]
assert_str(JSON.stringify(SoundtrackWithSongs.from_dict(json).to_dict(), "", true)) \
.is_equal(JSON.stringify(json, "", true))
+142
View File
@@ -0,0 +1,142 @@
extends GdUnitTestSuite
## Soundtrack covers cached on disk: downloaded once, again only when the server's cover
## time changed, a few at a time, and deleted with their soundtrack.
var _server: FakeHttpTransport
var _config_dir_before: String
var _png: PackedByteArray
func before_test() -> void:
_server = FakeHttpTransport.new().install()
_config_dir_before = OS.get_environment(Settings.CONFIG_DIR_ENV)
OS.set_environment(Settings.CONFIG_DIR_ENV, create_temp_dir("cover_cache_%d" % randi()))
var image: Image = Image.create(4, 6, false, Image.FORMAT_RGB8)
_png = image.save_png_to_buffer()
func after_test() -> void:
OS.set_environment(Settings.CONFIG_DIR_ENV, _config_dir_before)
_server.uninstall()
func _cover_path(uuid: String) -> String:
return "/soundtracks/%s/cover" % uuid
func _serve(uuid: String) -> void:
_server.respond_bytes(_cover_path(uuid), _png)
# Asks a new cache, like opening the statistics after a restart, and returns the covers
func _fetch(uuid: String, modified: int, cache: CoverCache = null) -> Array[Image]:
var covers: CoverCache = cache if cache != null else CoverCache.new()
var images: Array[Image] = []
covers.fetch_cover(uuid, modified, func(image: Image) -> void: images.append(image))
return images
func test_first_fetch_downloads_and_keeps_the_cover() -> void:
_serve("st-1")
var images: Array[Image] = _fetch("st-1", 100)
assert_int(images.size()).is_equal(1)
assert_int(images[0].get_width()).is_equal(4)
assert_bool(FileAccess.file_exists(CoverCache.folder().path_join("st-1"))).is_true()
func test_unchanged_cover_comes_from_disk() -> void:
_serve("st-1")
_fetch("st-1", 100)
var images: Array[Image] = _fetch("st-1", 100)
assert_int(images.size()).is_equal(1)
assert_int(_server.count(_cover_path("st-1"))).is_equal(1)
func test_changed_cover_is_downloaded_again() -> void:
_serve("st-1")
_fetch("st-1", 100)
_fetch("st-1", 200)
assert_int(_server.count(_cover_path("st-1"))).is_equal(2)
func test_soundtrack_without_a_cover_asks_nothing() -> void:
var images: Array[Image] = _fetch("st-1", 0)
assert_array(images).is_empty()
assert_int(_server.count(_cover_path("st-1"))).is_equal(0)
func test_missing_cover_on_the_server_gives_nothing() -> void:
var images: Array[Image] = _fetch("st-1", 100)
assert_array(images).is_empty()
assert_int(_server.count(_cover_path("st-1"))).is_equal(1)
func test_cover_that_cannot_be_read_is_not_kept() -> void:
_server.respond_bytes(_cover_path("st-1"), "not a picture".to_utf8_buffer())
var images: Array[Image] = _fetch("st-1", 100)
assert_array(images).is_empty()
assert_bool(FileAccess.file_exists(CoverCache.folder().path_join("st-1"))).is_false()
func test_covers_of_removed_soundtracks_are_deleted() -> void:
_serve("st-1")
_serve("st-2")
var covers: CoverCache = CoverCache.new()
_fetch("st-1", 100, covers)
_fetch("st-2", 100, covers)
covers.keep_only({"st-1": 100, "st-3": 0})
assert_bool(FileAccess.file_exists(CoverCache.folder().path_join("st-1"))).is_true()
assert_bool(FileAccess.file_exists(CoverCache.folder().path_join("st-2"))).is_false()
func test_cover_removed_on_the_server_is_deleted() -> void:
_serve("st-1")
var covers: CoverCache = CoverCache.new()
_fetch("st-1", 100, covers)
covers.keep_only({"st-1": 0})
assert_bool(FileAccess.file_exists(CoverCache.folder().path_join("st-1"))).is_false()
func test_only_a_few_covers_download_at_a_time() -> void:
var covers: CoverCache = CoverCache.new()
for i: int in 5:
_server.hold(_cover_path("st-%d" % i))
_serve("st-%d" % i)
_fetch("st-%d" % i, 100, covers)
var asked: int = 0
for i: int in 5:
asked += _server.count(_cover_path("st-%d" % i))
assert_int(asked).is_equal(CoverCache.MAX_DOWNLOADS)
_server.release(_cover_path("st-0"))
assert_int(_server.count(_cover_path("st-3"))).is_equal(1)
assert_int(_server.count(_cover_path("st-4"))).is_equal(0)
func test_cover_asked_for_twice_is_downloaded_once() -> void:
var covers: CoverCache = CoverCache.new()
_server.hold(_cover_path("st-1"))
_serve("st-1")
var first: Array[Image] = _fetch("st-1", 100, covers)
var second: Array[Image] = _fetch("st-1", 100, covers)
_server.release(_cover_path("st-1"))
assert_int(_server.count(_cover_path("st-1"))).is_equal(1)
assert_int(first.size()).is_equal(1)
assert_int(second.size()).is_equal(1)
+1
View File
@@ -0,0 +1 @@
uid://chubfhdhfqvyu
+22
View File
@@ -90,3 +90,25 @@ func test_matched_letters_in_order() -> void:
func test_matched_letters_of_no_match() -> void:
assert_array(SoundtrackSearch.matched_letters("Chrono Trigger", "metroid")).is_empty()
assert_array(SoundtrackSearch.matched_letters("Super Mario 64", "sm")).is_empty()
func test_alternative_names_find_the_real_name() -> void:
_search.set_names(["Final Fantasy VII", "Chrono Trigger"],
{"Final Fantasy VII": ["FF7", "ファイナルファンタジーVII"]})
assert_array(_search.find_matches("ff7")).contains_exactly(["Final Fantasy VII"])
assert_str(_search.matched_alternative("Final Fantasy VII")).is_equal("FF7")
assert_array(_search.find_matches("ファイナル")).contains_exactly(["Final Fantasy VII"])
func test_real_name_match_shows_no_alternative() -> void:
_search.set_names(["Final Fantasy VII"], {"Final Fantasy VII": ["FF7"]})
assert_array(_search.find_matches("fantasy")).contains_exactly(["Final Fantasy VII"])
assert_str(_search.matched_alternative("Final Fantasy VII")).is_empty()
func test_soundtrack_is_found_once_when_several_names_match() -> void:
_search.set_names(["Final Fantasy VII"], {"Final Fantasy VII": ["Final Fantasy 7"]})
assert_array(_search.find_matches("final")).contains_exactly(["Final Fantasy VII"])