Files
MusicPlayer/tests/fakes/ui_tour.gd
T
Sansan 8b71723a34
Build / test (pull_request) Successful in 31s
Build / build (pull_request) Skipped
Build / test (push) Successful in 31s
Build / build (push) Successful in 1m13s
Put the whole look into one project theme
themes/main_theme.tres is the project theme. It holds Godot's default look for
every control type the game uses, plus named styles for the game's own look
(popups, the background, spacers, list rows, title and text sizes, highlights)
and a palette for the popup dimming. Scenes and scripts name these styles
instead of setting colours, sizes or style boxes, and the five old theme files
are gone. themes/README.md explains the styles and how to make a new theme.

The screenshot tour now uses the real character pictures, so the layout
baseline is taken again. Screenshots are pixel-identical to before, and
test_theme.gd checks that the look stays in the theme.
2026-10-06 13:33:35 +02:00

285 lines
9.8 KiB
GDScript

class_name UiTour
extends RefCounted
## Walks the main window through a fixed set of states against a fake MusicServer:
## startup, players added, a song playing, every popup open and a sync.
##
## Used for screenshots ([code]tools/take_screenshots.gd[/code]) and for the layout
## test, so both look at exactly the same states. Nodes are looked up by name in the
## main window and in its Quiz and TopBar scenes, so the tour works no matter which of
## them a node lives in.
const SOUNDTRACKS: Array[String] = [
"Chrono Trigger", "Final Fantasy VI", "Secret of Mana", "Terranigma",
"Earthbound", "Super Metroid", "The Legend of Zelda: A Link to the Past",
"Donkey Kong Country 2", "Lufia II", "Illusion of Gaia",
]
const SONG_INFO: Dictionary = {
"SongNo": 0, "Soundtrack": "Chrono Trigger", "Song": "Corridors of Time",
}
const SYNC_PROGRESS: Dictionary = {"progress": 40, "time_spent": "00:00:02"}
const SYNC_RESULT: Dictionary = {
"soundtracks_added": ["Terranigma", "Lufia II"],
"soundtracks_re_added": [],
"soundtracks_changed_title": {"FF6": "Final Fantasy VI"},
"soundtracks_changed_content": ["Chrono Trigger"],
"soundtracks_removed": ["Old Soundtrack"],
"catched_errors": [],
"total_time": "00:00:03",
}
## The character pictures the fake server hands out.
const CHARACTERS_DIR: String = "res://characters"
## 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
## were renamed can still be compared with new ones.
const POPUPS: Array[Array] = [
["Search", "SearchPopup"], ["SyncWindow", "SyncPopup"],
["SettingsWindow", "SettingsPopup"], ["QRWindow", "QRPopup"],
["NewCharacterSelect", "CharacterSelectPopup"], ["ShortcutsWindow", "ShortcutsPopup"],
["AboutWindow", "AboutPopup"], ["WinnerWindow", "WinnerPopup"], ["Log", "LogPopup"],
["StatisticsPopupPanel", "StatisticsPopupPanel"],
]
var _main: Control
func _init(main: Control) -> void:
_main = main
## Returns a fake server that answers every request the main window makes on the tour.
## Call [method FakeHttpTransport.install] before the main window is created.
static func make_server() -> FakeHttpTransport:
var server: FakeHttpTransport = FakeHttpTransport.new()
var mp3: PackedByteArray = FileAccess.get_file_as_bytes("res://tests/data/short.mp3")
server.respond_json("/music/all", SOUNDTRACKS)
server.respond_json("/music/all/order", SOUNDTRACKS)
server.respond_json("/music/list", [])
var characters: Array[String] = character_names()
server.respond_json("/characters", characters)
for character_name: String in characters:
server.respond_bytes("/character?name=" + character_name.uri_encode(),
FileAccess.get_file_as_bytes(CHARACTERS_DIR.path_join(character_name)))
server.respond_bytes("/music/rand", mp3)
server.respond_bytes("/music/addQue", PackedByteArray())
server.respond_json("/music/info", SONG_INFO)
server.respond_bytes("/music/reset", PackedByteArray())
server.respond_bytes("/music/played?song=0", PackedByteArray())
server.respond_bytes("/sync", PackedByteArray())
server.respond_json("/sync/progress", SYNC_PROGRESS)
return server
## The pictures in [constant CHARACTERS_DIR], sorted by name.
static func character_names() -> Array[String]:
var names: Array[String] = []
for file_name: String in DirAccess.get_files_at(CHARACTERS_DIR):
if file_name.ends_with(".png"):
names.append(file_name)
names.sort()
return names
## Puts Settings and Playlist in the state the tour expects. Call it before the main
## window is created.
static func prepare_autoloads() -> void:
Playlist.reset_playlist()
Playlist.first_song_played = false
Playlist.is_song_downloading = false
Settings.player_array.clear()
Settings.edit_players = false
Settings.currently_syncing = false
Settings.character_select_open = false
Settings.add_to_stats = true
Settings.hide_next_track = true
Settings.use_low_played_mode = false
Settings.winning_score = 20
# The inspiration list scrolls by itself, which would make every picture different
Settings.inspiration_list_speed = 0
## Runs the tour. [param on_state] is called (and awaited) with the name of each state
## and the popup that is open in it, or [code]null[/code] when no popup is open.
func run(on_state: Callable) -> void:
# The debug build lowers the winning score on startup; keep it out of reach
Settings.winning_score = 20
_stop_caret_blink(_main)
await _settle()
await on_state.call("startup", null)
_call("add_players")
await _settle()
await on_state.call("add_player_row", null)
_add_player("Crono")
_add_player("Marle")
_call("add_players")
await _settle()
await on_state.call("players_added", null)
_call("play_next_song")
await _settle()
_freeze_music_player()
var picture: Image = Image.new()
picture.load_png_from_buffer(
FileAccess.get_file_as_bytes(CHARACTERS_DIR.path_join(character_names()[0])))
_player_row(0).new_character_selected(ImageTexture.create_from_image(picture))
_player_row(0).add_point()
_player_row(0).add_point()
_player_row(1).add_point()
_call("show_answer_pressed")
await _settle()
_freeze_music_player()
_fix_log_times()
await _settle()
await on_state.call("playing", null)
for state_and_node: Array in POPUPS:
var popup: Node = await _open_popup(state_and_node[1])
await _settle()
await on_state.call("popup_" + state_and_node[0], popup)
_close_popup(popup)
await _settle()
var sync_popup: BasePopup = _node("SyncPopup")
Settings.currently_syncing = false
sync_popup.has_synced = false
sync_popup.start_sync()
sync_popup.sync_timer.stop()
sync_popup._on_sync_timeout()
await _settle()
await on_state.call("sync_in_progress", sync_popup)
sync_popup.close()
await _settle()
await on_state.call("sync_buttons_locked", null)
_server().respond_json("/sync/progress", SYNC_RESULT)
sync_popup._on_sync_timeout()
sync_popup.open()
await _settle()
await on_state.call("sync_result", sync_popup)
sync_popup.close()
await _settle()
## Returns where every Control and Window in [param subtree] is on screen, keyed by its
## path from [param main]: [code]{"rect": [x, y, width, height], "visible": bool}[/code].
## Nodes added at runtime without a name are keyed by their index, e.g. [code]#2[/code],
## so the keys are the same on every run.
static func dump_layout(main: Node, subtree: Node) -> Dictionary:
var layout: Dictionary = {}
var path: String = String(main.get_path_to(subtree))
_dump_node(subtree, path, layout)
return layout
static func _dump_node(node: Node, path: String, layout: Dictionary) -> void:
if node is Control:
var rect: Rect2 = (node as Control).get_global_rect()
layout[path] = {"rect": _round_rect(rect), "visible": node.is_visible_in_tree()}
elif node is Window:
var window: Window = node
layout[path] = {
"rect": _round_rect(Rect2(window.position, window.size)),
"visible": window.visible,
}
for child: Node in node.get_children():
var key: String = "#%d" % child.get_index() if "@" in child.name \
else String(child.name)
_dump_node(child, key if path == "." else path + "/" + key, layout)
static func _round_rect(rect: Rect2) -> Array[float]:
return [snappedf(rect.position.x, 0.01), snappedf(rect.position.y, 0.01),
snappedf(rect.size.x, 0.01), snappedf(rect.size.y, 0.01)]
## Finds a node of the main window by [param node_name], whether it is a direct child
## of the main window or of its Quiz or TopBar scene.
func _node(node_name: String) -> Node:
for parent: Node in [_main, _main.get_node_or_null("Quiz"),
_main.get_node_or_null("TopBar")]:
if parent != null && parent.has_node(node_name):
return parent.get_node(node_name)
push_error("UiTour: no node named " + node_name)
return null
## Calls [param method] on the players panel, the quiz or the main window, whichever
## has it first.
func _call(method: String, args: Array = []) -> Variant:
for path: String in ["Quiz/PlayersPanel", "Quiz"]:
var target: Node = _main.get_node_or_null(path)
if target != null && target.has_method(method):
return target.callv(method, args)
return _main.callv(method, args)
# A blinking text cursor would be shown in some pictures and not in others
func _stop_caret_blink(node: Node) -> void:
if node is TextEdit || node is LineEdit:
node.caret_blink = false
for child: Node in node.get_children():
_stop_caret_blink(child)
func _server() -> FakeHttpTransport:
return MusicServerClient.transport
func _settle() -> void:
for i: int in 4:
await _main.get_tree().process_frame
func _add_player(player_name: String) -> void:
(_node("PlayersPanel").get_node("VBoxContainer/AddPlayerContainer/PlayerNameField") \
as TextEdit).text = player_name
_call("add_player")
func _player_row(index: int) -> PlayerRow:
return _node("PlayersPanel").get_node(
"VBoxContainer/ScrollContainer/PlayersListVBoxContainer").get_child(index)
## Stops the song so the time and progress bar look the same on every run.
func _freeze_music_player() -> void:
var music_player: MusicPlayer = _node("MusicPlayer")
music_player.audio_player.stop()
music_player.sound_player.stop()
music_player.progress_slider.value = 0
music_player.music_time_label.text = "00:00 / 00:01"
## The log starts each row with the time of day; use a fixed time instead.
func _fix_log_times() -> void:
var log_popup: BasePopup = _node("LogPopup")
for row: Label in log_popup.log_box.get_children():
row.text = "12:00:00 " + row.text.substr(9)
func _open_popup(popup_name: String) -> Node:
var popup: Node = _node(popup_name)
match popup_name:
"SyncPopup":
# Opening the popup starts a sync; show the empty popup instead
popup.open()
"CharacterSelectPopup":
popup.show_grid("Crono")
"WinnerPopup":
popup.show_winner(Settings.player_array[0])
"StatisticsPopupPanel":
_call("get_statistics")
_:
popup.open()
return popup
func _close_popup(popup: Node) -> void:
if popup is BasePopup:
popup.close()
else:
popup.visible = false