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

+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",