Change soundtrack covers from the app and show the cover with the answer
Build / test (push) Successful in 1m50s
Build / build (push) Successful in 1m14s

Cover menu in the soundtrack popup ("Change cover"):
- Choose picture uploads a JPEG, PNG or WebP picture (up to 10 MB).
- Pick by ID uses a game's cover on SteamGridDB or an anime's on AniList.
- Remove cover deletes it, so the server searches for a new one.
- The server's reason is shown when it refuses a change.
After a change the new cover time is fetched, and the popup, the
statistics card and the quiz show the new cover.

Quiz: once the answer is shown, the soundtrack's cover shows to the left of
the titles. Cover and titles are centered together, left and right and up
and down, and each title keeps to two lines. Cover times come from the
soundtrack list, and songs keep their soundtrack's UUID, also in saves.

Requests can now send any body with its content type, and every 2xx answer
counts as success. Cover frames are 2:3 like the server's covers. Small
copies of real covers are used by the tests and screenshots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
SansanandClaude Opus 5.5 committed 2026-10-14 23:32:09 +02:00
1 parent ef6ac8f1fd
commit 06421bb63e
33 files changed
+3966 -920

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

File diff suppressed because it is too large. Load diff
+10 -5
View File
@@ -25,9 +25,12 @@ 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
## The body of each request as text, e.g. its JSON, empty for none, in the same order as
## [member requested_paths].
var requested_bodies: Array[String] = []
## The body of each request as bytes, e.g. an uploaded picture, in the same order as
## [member requested_paths].
var requested_raw_bodies: Array[PackedByteArray] = []
## How many tokens have been asked for.
var token_requests: int = 0
## The JSON body of the last token request.
@@ -110,20 +113,22 @@ func request(
method: int,
on_complete: Callable,
headers: PackedStringArray = PackedStringArray(),
body: String = "",
body: PackedByteArray = PackedByteArray(),
) -> void:
var path: String = url.trim_prefix(Settings.default_path + MusicServerClient.API_PATH)
if path == "/token" && method == HTTPClient.METHOD_POST:
token_requests += 1
if _held_paths.has(path):
_held_paths[path].append(_answer_token_request.bind(body, on_complete))
_held_paths[path].append(
_answer_token_request.bind(body.get_string_from_utf8(), on_complete))
return
_answer_token_request(body, on_complete)
_answer_token_request(body.get_string_from_utf8(), on_complete)
return
requested_paths.append(path)
requested_methods.append(method)
requested_headers.append(headers)
requested_bodies.append(body)
requested_bodies.append(body.get_string_from_utf8())
requested_raw_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())
+19 -6
View File
@@ -15,7 +15,8 @@ const SOUNDTRACKS: Array[String] = [
"Donkey Kong Country 2", "Lufia II", "Illusion of Gaia",
]
const SONG_INFO: Dictionary = {
"SongNo": 0, "Soundtrack": "Chrono Trigger", "Song": "Corridors of Time",
"SongNo": 0, "Soundtrack": "Chrono Trigger", "SoundtrackUuid": "soundtrack-0",
"Song": "Corridors of Time",
}
const SYNC_PROGRESS: Dictionary = {"progress": 40, "time_spent": "00:00:02"}
const SYNC_RESULT: Dictionary = {
@@ -48,6 +49,8 @@ const JAPANESE_SONGS: Array[String] = [
## The character pictures the fake server hands out.
const CHARACTERS_DIR: String = "res://characters"
## Small copies of covers from the MusicServer, 2:3 like all its covers.
const COVERS_DIR: String = "res://tests/data/covers"
## Popups in the order the tour opens them: the state name, then the popup's node. The
## state names are what the popups were first called, so screenshots taken before they
@@ -91,12 +94,18 @@ 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():
# Real covers from the server, made smaller, for every other soundtrack
for i: int in range(0, SOUNDTRACKS.size(), 2):
server.respond_bytes("/soundtracks/soundtrack-%d/cover" % i,
FileAccess.get_file_as_bytes(CHARACTERS_DIR.path_join(
pictures[i % pictures.size()])))
FileAccess.get_file_as_bytes(COVERS_DIR.path_join("cover_%d.jpg" % (i >> 1))))
# The soundtrack list tells the quiz which soundtracks have covers
var soundtracks: Array[Dictionary] = []
for soundtrack: Dictionary in soundtrack_statistics():
soundtracks.append({"uuid": soundtrack.soundtrack_uuid,
"name": soundtrack.soundtrack_name,
"cover_modified": soundtrack.get("cover_modified", 0),
"alternative_names": soundtrack.get("alternative_names", [])})
server.respond_json("/soundtracks", soundtracks)
server.respond_json("/version/history", SERVER_HISTORY)
return server
@@ -307,6 +316,10 @@ func run(on_state: Callable) -> void:
stats_popup.new_name_field.text = "Chrono"
await _settle()
await on_state.call("popup_SoundtrackStats_names", stats_popup)
stats_popup.change_cover()
stats_popup.cover_id_field.text = "5248061"
await _settle()
await on_state.call("popup_SoundtrackStats_cover", stats_popup)
stats_popup.stop_editing()
stats_popup.close()
_main.show_tab(&"quiz")
+1 -1
View File
@@ -206,7 +206,7 @@ func test_every_button_bounces() -> void:
func test_helpers_do_nothing_with_reduced_animations() -> void:
Settings.reduce_animations = true
var label: Label = _quiz.get_node("NowPlaying/SongLabel")
var label: Label = _quiz.get_node("NowPlaying/Titles/SongLabel")
Anim.pop(label)
Anim.fade_in(label)
+2 -2
View File
@@ -55,8 +55,8 @@ func _play_a_little() -> Array[PlayerObject]:
func _now_playing() -> Array[String]:
var now_playing: Node = _quiz.get_node("NowPlaying")
return [(now_playing.get_node("SoundtrackLabel") as Label).text,
(now_playing.get_node("SongLabel") as Label).text]
return [(now_playing.get_node("Titles/SoundtrackLabel") as Label).text,
(now_playing.get_node("Titles/SongLabel") as Label).text]
# Saving
+207 -2
View File
@@ -1,6 +1,7 @@
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.
## Soundtrack covers on the statistics cards, in the popup and next to the answer in the
## quiz, loaded for the cards on screen; changing a cover from the popup; 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,
@@ -21,6 +22,13 @@ func _before_window_starts() -> void:
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)
# The soundtrack list tells the quiz which soundtracks have covers
_server.respond_json("/soundtracks", [
{"uuid": "st-1", "name": "Chrono Trigger", "cover_modified": 100},
{"uuid": "st-2", "name": "Terranigma"},
])
_next_song_is({"SongNo": 0, "Soundtrack": "Chrono Trigger", "SoundtrackUuid": "st-1",
"Song": "Corridors of Time"})
func before_test() -> void:
@@ -185,3 +193,200 @@ func test_search_popup_finds_soundtracks_by_alternative_names() -> void:
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")
func _open_cover_menu() -> void:
await _open_statistics()
_popup.show_soundtrack(_card("Chrono Trigger").soundtrack)
_popup.change_cover()
# The server's answer for the soundtrack once its cover changed
func _cover_time_becomes(modified: int) -> void:
_server.respond_json("/soundtracks/st-1", {"uuid": "st-1", "name": "Chrono Trigger",
"cover_modified": modified, "songs": []})
func _picture_file(bytes: PackedByteArray) -> String:
var path: String = create_temp_dir("pictures_%d" % randi()).path_join("cover.jpg")
var file: FileAccess = FileAccess.open(path, FileAccess.WRITE)
file.store_buffer(bytes)
file.close()
return path
func _downloads(path: String) -> int:
var count: int = 0
for i: int in _server.requested_paths.size():
if _server.requested_paths[i] == path \
&& _server.requested_methods[i] == HTTPClient.METHOD_GET:
count += 1
return count
func _now_playing() -> NowPlaying:
return _quiz.get_node("NowPlaying")
func test_cover_menu_opens_in_place_of_the_songs() -> void:
await _open_cover_menu()
assert_bool(_popup.cover_editor.visible).is_true()
assert_bool(_popup.song_scroll.visible).is_false()
assert_bool(_popup.edit_names_button.disabled).is_true()
assert_bool(_popup.remove_cover_button.disabled).is_false()
_popup.stop_editing()
assert_bool(_popup.cover_editor.visible).is_false()
assert_bool(_popup.song_scroll.visible).is_true()
func test_soundtrack_without_a_cover_has_nothing_to_remove() -> void:
await _open_statistics()
_popup.show_soundtrack(_card("Terranigma").soundtrack)
_popup.change_cover()
assert_bool(_popup.remove_cover_button.disabled).is_true()
func test_uploaded_picture_becomes_the_cover() -> void:
var bytes: PackedByteArray = FileAccess.get_file_as_bytes(
"res://tests/data/covers/cover_0.jpg")
_cover_time_becomes(200)
await _open_cover_menu()
_popup.upload_picture(_picture_file(bytes))
var index: int = _server.requested_methods.find(HTTPClient.METHOD_PUT)
assert_str(_server.requested_paths[index]).is_equal("/soundtracks/st-1/cover")
assert_array(_server.requested_raw_bodies[index]).is_equal(bytes)
assert_array(_server.requested_headers[index]).contains(["Content-Type: image/jpeg"])
# The new cover is downloaded again for the popup and the card
assert_int(_card("Chrono Trigger").soundtrack.cover_modified).is_equal(200)
await _runner.simulate_frames(2)
# Downloaded once more, for the popup and the card together
assert_int(_downloads("/soundtracks/st-1/cover")).is_equal(2)
assert_object(_card("Chrono Trigger").cover_picture.texture).is_not_null()
assert_str(_popup.cover_status.text).is_equal("The new cover is saved.")
func test_file_that_is_not_a_picture_is_not_uploaded() -> void:
await _open_cover_menu()
_popup.upload_picture(_picture_file("not a picture".to_utf8_buffer()))
assert_int(_server.requested_methods.count(HTTPClient.METHOD_PUT)).is_equal(0)
assert_str(_popup.cover_status.text).contains("JPEG, PNG and WebP")
func test_cover_picked_by_id() -> void:
_server.respond_bytes("/soundtracks/st-1/cover/source", PackedByteArray(), 204)
_cover_time_becomes(300)
await _open_cover_menu()
_popup.cover_source_button.select(1)
_popup.cover_id_field.text = " 21 "
_popup.use_cover_source()
var index: int = _server.requested_paths.find("/soundtracks/st-1/cover/source")
assert_int(_server.requested_methods[index]).is_equal(HTTPClient.METHOD_PUT)
var body: Dictionary = JSON.parse_string(_server.requested_bodies[index])
assert_str(body.source).is_equal("anilist")
assert_str(body.id).is_equal("21")
assert_int(_popup.soundtrack.cover_modified).is_equal(300)
func test_no_id_asks_nothing() -> void:
await _open_cover_menu()
_popup.use_cover_source()
assert_int(_server.count("/soundtracks/st-1/cover/source")).is_equal(0)
func test_removed_cover_shows_the_initials() -> void:
_cover_time_becomes(0)
await _open_cover_menu()
_popup.remove_cover()
var index: int = _server.requested_methods.find(HTTPClient.METHOD_DELETE)
assert_str(_server.requested_paths[index]).is_equal("/soundtracks/st-1/cover")
assert_bool(_popup.cover_placeholder.visible).is_true()
assert_bool(_card("Chrono Trigger").cover_placeholder.visible).is_true()
assert_bool(FileAccess.file_exists(CoverCache.folder().path_join("st-1"))).is_false()
assert_bool(_popup.remove_cover_button.disabled).is_true()
func test_refused_change_shows_the_servers_reason() -> void:
_server.respond_json("/soundtracks/st-1/cover/source",
{"error": "covers are not enabled on this server"}, 503)
await _open_cover_menu()
_popup.cover_id_field.text = "21"
_popup.use_cover_source()
assert_str(_popup.cover_status.text).is_equal(
"Could not change the cover: covers are not enabled on this server.")
assert_bool(_popup.use_source_button.disabled).is_false()
func test_answer_shows_the_soundtracks_cover() -> void:
await _play_first_song()
assert_bool(_now_playing().has_cover()).is_false()
_press("SongControls/ShowAnswerButton")
assert_bool(_now_playing().has_cover()).is_true()
await _runner.simulate_frames(1)
# The cover sits just left of the titles, and the two are centered together
var area: Rect2 = _now_playing().get_global_rect()
var cover: Rect2 = _now_playing().cover_frame.get_global_rect()
var titles: Rect2 = _now_playing().titles.get_global_rect()
assert_float(titles.position.x - cover.end.x).is_equal(32.0)
assert_float(absf((cover.position.x - area.position.x) - (area.end.x - titles.end.x))) \
.is_less_equal(1.0)
# Up and down, the cover is centered between the top bar and the song list, and the
# titles on the cover
assert_float(absf((cover.position.y - area.position.y) - (area.end.y - cover.end.y))) \
.is_less_equal(1.0)
var text_middle: float = (_now_playing().soundtrack_label.get_global_rect().position.y
+ _now_playing().song_label.get_global_rect().end.y) / 2.0
assert_float(absf(text_middle - cover.get_center().y)).is_less_equal(1.0)
func test_answer_without_a_cover_shows_only_the_titles() -> void:
_view.covers.set_cover_times({"st-1": 0})
await _play_first_song()
_press("SongControls/ShowAnswerButton")
assert_bool(_now_playing().has_cover()).is_false()
assert_int(_server.count("/soundtracks/st-1/cover")).is_equal(0)
func test_next_song_hides_the_cover_until_its_answer() -> void:
await _play_first_song()
_press("SongControls/ShowAnswerButton")
_press("SongControls/NextButton")
# The playing song is stopped first, which takes half a second
await await_millis(700)
assert_int(Playlist.currently_playing_song).is_equal(1)
assert_bool(_now_playing().has_cover()).is_false()
func test_changed_cover_shows_in_the_quiz_too() -> void:
await _play_first_song()
_press("SongControls/ShowAnswerButton")
_cover_time_becomes(0)
_popup.show_soundtrack(SoundtrackWithSongs.from_dict(STATISTICS[0]))
_popup.change_cover()
_popup.remove_cover()
assert_bool(_now_playing().has_cover()).is_false()
+2 -2
View File
@@ -33,7 +33,7 @@ func test_next_plays_preloaded_song_and_fetches_another() -> void:
func test_title_is_hidden_until_answer_is_shown() -> void:
var soundtrack_label: Label = _quiz.get_node("NowPlaying/SoundtrackLabel")
var soundtrack_label: Label = _quiz.get_node("NowPlaying/Titles/SoundtrackLabel")
_press("SongControls/NextButton")
await _runner.simulate_frames(2)
@@ -224,7 +224,7 @@ func test_show_answer_before_first_song_does_not_reveal_next_song() -> void:
await _play_first_song()
assert_str((_quiz.get_node("NowPlaying/SoundtrackLabel") as Label).text) \
assert_str((_quiz.get_node("NowPlaying/Titles/SoundtrackLabel") as Label).text) \
.is_equal("????????")
+14 -1
View File
@@ -11,7 +11,7 @@ func before_test() -> void:
func _label(label_name: String) -> Label:
return _now_playing.get_node(label_name)
return _now_playing.get_node("Titles/" + label_name)
func test_starts_empty() -> void:
@@ -32,3 +32,16 @@ func test_soundtrack_is_above_song() -> void:
assert_float(_label("SoundtrackLabel").get_global_rect().end.y) \
.is_less_equal(_label("SongLabel").get_global_rect().position.y)
func test_long_titles_keep_to_two_lines_each() -> void:
_now_playing.size = Vector2(1088, 328)
_now_playing.show_song("Final Fantasy Dissidia 012 Duodecim (Sista Fantasin "
+ "Meningsskiljaktigheter 012 Tolv) Final Mix Special Edition",
"Cosmos (Arrangement) - Main Theme of the Warriors of Light, Long Version")
await _runner.simulate_frames(2)
for label_name: String in ["SoundtrackLabel", "SongLabel"]:
assert_int(_label(label_name).get_visible_line_count()).is_less_equal(2)
assert_float(_label("SongLabel").get_global_rect().end.y) \
.is_less_equal(_now_playing.get_global_rect().end.y)
+2 -2
View File
@@ -54,8 +54,8 @@ func _next() -> void:
func _now_playing() -> Array[String]:
var now_playing: Node = _quiz.get_node("NowPlaying")
return [(now_playing.get_node("SoundtrackLabel") as Label).text,
(now_playing.get_node("SongLabel") as Label).text]
return [(now_playing.get_node("Titles/SoundtrackLabel") as Label).text,
(now_playing.get_node("Titles/SongLabel") as Label).text]
func test_picking_a_folder_makes_no_server_requests() -> void:
+2 -2
View File
@@ -121,12 +121,12 @@ func test_clicking_quiz_buttons_plays_and_shows_answer() -> void:
await _click_at(_center(_quiz.get_node("SongControls/NextButton")))
await _runner.simulate_frames(2)
assert_int(Playlist.currently_playing_song).is_equal(0)
assert_str((_quiz.get_node("NowPlaying/SoundtrackLabel") as Label).text) \
assert_str((_quiz.get_node("NowPlaying/Titles/SoundtrackLabel") as Label).text) \
.is_not_equal("Chrono Trigger")
await _click_at(_center(_quiz.get_node("SongControls/ShowAnswerButton")))
assert_str((_quiz.get_node("NowPlaying/SoundtrackLabel") as Label).text) \
assert_str((_quiz.get_node("NowPlaying/Titles/SoundtrackLabel") as Label).text) \
.is_equal("Chrono Trigger")
+2 -2
View File
@@ -639,7 +639,7 @@ class _HeaderTransport:
_method: int,
on_complete: Callable,
_request_headers: PackedStringArray = PackedStringArray(),
_body: String = "",
_body: PackedByteArray = PackedByteArray(),
) -> void:
on_complete.call(200, JSON.stringify({"token": "t"} if _url.ends_with("/token") else [])
.to_utf8_buffer(), _headers)
@@ -659,7 +659,7 @@ class _RecordingTransport:
_method: int,
on_complete: Callable,
_headers: PackedStringArray = PackedStringArray(),
_body: String = "",
_body: PackedByteArray = PackedByteArray(),
) -> void:
_urls.append(url)
if url.ends_with("/token"):