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, a sync and the statistics ## tab. ## ## 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"], ] 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("/character/list", 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) server.respond_json("/statistics/soundtracks", soundtrack_statistics()) return server ## Play statistics for every soundtrack in [constant SOUNDTRACKS], the same on every ## run. Some have never been played. static func soundtrack_statistics() -> Array[Dictionary]: var statistics: Array[Dictionary] = [] for i: int in SOUNDTRACKS.size(): var played: int = (i * 7) % 11 var songs: Array[Dictionary] = [] for song: int in 5: songs.append({ "song_uuid": "song-%d-%d" % [i, song], "soundtrack_uuid": "soundtrack-%d" % i, "soundtrack_name": SOUNDTRACKS[i], "song_name": "Track %d" % (song + 1), "times_played": maxi(0, played - song * 2), }) var soundtrack: Dictionary = { "soundtrack_uuid": "soundtrack-%d" % i, "soundtrack_name": SOUNDTRACKS[i], "soundtrack_played": played, "songs": songs, } if played > 0: soundtrack.soundtrack_last_played = "2026-09-%02dT20:00:00Z" % (i + 1) statistics.append(soundtrack) return statistics ## 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() var statistics_view: StatisticsView = _node("StatisticsView") _main.show_tab(&"statistics") await _settle() await on_state.call("statistics", statistics_view) statistics_view.show_option.select(StatisticsView.ShowFilter.NEVER_PLAYED) statistics_view.apply_filter() await _settle() await on_state.call("statistics_never_played", statistics_view) statistics_view.show_option.select(StatisticsView.ShowFilter.ALL) statistics_view.sort_option.select(StatisticsView.SortOrder.MOST_PLAYED) statistics_view.apply_sort() statistics_view.search_field.text = "chrono" statistics_view.apply_filter() await _settle() await on_state.call("statistics_search", statistics_view) var stats_popup: SoundtrackStatsPopup = _node("SoundtrackStatsPopup") for card: SoundtrackCard in statistics_view.cards: if card.visible: stats_popup.show_soundtrack(card.soundtrack) break await _settle() await on_state.call("popup_SoundtrackStats", stats_popup) stats_popup.close() _main.show_tab(&"quiz") 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]) _: popup.open() return popup func _close_popup(popup: Node) -> void: if popup is BasePopup: popup.close() else: popup.visible = false